@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.25

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.9.0-preview.24",
3
+ "version": "0.9.0-preview.25",
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",
@@ -0,0 +1,454 @@
1
+ // #509 — file download must use the date_bucket stored in the index
2
+ // entry, NOT today's date computed from the runtime clock.
3
+ //
4
+ // Bug: server.ts:1793 called `buildStoragePath(entry.file_id, entry.ext)`
5
+ // without passing a bucket. buildStoragePath internally calls
6
+ // getDateBucket(opts.now) which defaults to `new Date()` → today. Any
7
+ // file uploaded on a previous day would look under today's directory
8
+ // and return 404 blob_missing, even though authorization passed and
9
+ // the blob was still on disk in yesterday's directory.
10
+ //
11
+ // The 6 layers of pre-release verification (byte-for-byte review, two
12
+ // independent audits, dev-confirm, two validator container matrices,
13
+ // pre-release smoke) all uploaded + downloaded within the same UTC day,
14
+ // so the paths coincidentally matched. See project memory
15
+ // `feedback_time_based_storage_tests_must_inject_time`.
16
+ //
17
+ // This suite constructs fixture entries with hard-coded past date
18
+ // buckets and matching on-disk blobs; the runtime clock is never
19
+ // advanced. Mutation self-check: reverting the download path to use
20
+ // buildStoragePath (which computes today) must turn Door 1 tests red.
21
+ //
22
+ // Cross-lane note: This is orthogonal to #495/#500/#505 (authz).
23
+ // Reuses the utok_ + admin fixture pattern from file-download-authz
24
+ // but constructs entries by writing the .index JSON + blob directly
25
+ // so we can pin the date_bucket without waiting for real time.
26
+
27
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
28
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "fs";
29
+ import { join } from "path";
30
+ import { tmpdir } from "os";
31
+
32
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-509-db-")) + "/commhub.db";
33
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-509-fs-"));
34
+
35
+ // Set env BEFORE importing anything that pulls in db-adapter.ts or
36
+ // server.ts (which capture DEV_OPEN + AUTH_TOKEN at module load).
37
+ process.env.COMMHUB_DB = SERVER_DB;
38
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
39
+ process.env.HOST = "127.0.0.1";
40
+ delete process.env.COMMHUB_DEV_OPEN;
41
+
42
+ const { register } = await import("./auth.js");
43
+ const { db } = await import("./db.js");
44
+
45
+ // Register an admin FIRST to consume the "first-user-auto-admin" slot,
46
+ // then register userA as a regular user. Prior CR rounds landed the
47
+ // fixture-drift guard as a canonical pattern (see project memory
48
+ // `feedback_first_registered_user_is_admin_fake_green`).
49
+ const seedName = `seed509_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
50
+ const rSeed = register(seedName, "BootstrapPw123Aa!", undefined, "seed509");
51
+ if (!rSeed.ok) throw new Error(`seed509 register failed: ${rSeed.error}`);
52
+ // Also promote via SQL in case the auto-admin heuristic changes.
53
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rSeed.user!.user_id]);
54
+
55
+ const nameA = `user509a_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
56
+ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "user509A");
57
+ if (!rA.ok) throw new Error(`user509A register failed: ${rA.error}`);
58
+ const userAToken = rA.token!;
59
+ const userAUserId = rA.user!.user_id;
60
+
61
+ // #500 CR2 fixture-drift guard — assert userA is NOT admin so tests
62
+ // exercise the regular utok_ path, not the admin bypass.
63
+ {
64
+ const row = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId);
65
+ if (!row) throw new Error("userA missing after register");
66
+ if (row.role === "admin") {
67
+ throw new Error(
68
+ `[#509 fixture-drift guard] userA was auto-promoted to admin ` +
69
+ `(role=${row.role}). Refusing to run: download tests would ` +
70
+ `short-circuit through the admin allow-list and validate nothing.`,
71
+ );
72
+ }
73
+ }
74
+
75
+ const { bootServer } = await import("./server.js");
76
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
77
+ const BASE = `http://127.0.0.1:${server.port}`;
78
+ await new Promise((r) => setTimeout(r, 100));
79
+
80
+ async function _uploadProbe(): Promise<number> {
81
+ const form = new FormData();
82
+ form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
83
+ const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
84
+ return r.status;
85
+ }
86
+ const isProdMode = (await _uploadProbe()) !== 200;
87
+
88
+ /**
89
+ * 🔴 Precondition, deliberately OUTSIDE every skipIf.
90
+ *
91
+ * The whole #509 suite below hangs off `describe.skipIf(!isProdMode)`, and
92
+ * `isProdMode` is decided by a *live probe* rather than a constant. If the
93
+ * probe comes back 200 (DEV_OPEN active in this environment), every Door
94
+ * test silently disappears and the file still reports green — a validator
95
+ * running it in a differently-configured container would report PASS while
96
+ * having exercised none of the #509 read path.
97
+ *
98
+ * Unlike the master-token / dev-open conditionals elsewhere in this repo,
99
+ * #509 has no dev-open counterpart suite: skipping here means the coverage
100
+ * is simply absent, not relocated. So an unrunnable suite must fail loudly
101
+ * and say why, instead of vanishing quietly.
102
+ */
103
+ test("precondition: #509 suite is actually runnable (not silently skipped)", () => {
104
+ expect(isProdMode).toBe(true);
105
+ });
106
+
107
+ afterAll(() => {
108
+ try { server?.stop?.(true); } catch {}
109
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
110
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
111
+ });
112
+
113
+ /**
114
+ * Manually construct an index entry + matching blob at a caller-chosen
115
+ * date bucket. This lets Door 1 tests pin "yesterday" without waiting
116
+ * for real time — the whole point of #509's fix is that read path must
117
+ * respect index-recorded bucket, so we test by placing a blob in a
118
+ * bucket that is NOT today.
119
+ */
120
+ function plantFileAtBucket(opts: {
121
+ ownerToken: string;
122
+ ownerUserId: string;
123
+ dateBucket: string; // "YYYY-MM-DD" — put blob HERE
124
+ content: Uint8Array;
125
+ ext?: string; // default ".txt"
126
+ name?: string; // metadata only
127
+ }): string {
128
+ const ext = opts.ext ?? ".txt";
129
+ const fileId = require("crypto").randomUUID().replace(/-/g, "");
130
+ const bucketDir = join(UPLOADS_DIR, opts.dateBucket);
131
+ mkdirSync(bucketDir, { recursive: true });
132
+ writeFileSync(join(bucketDir, fileId + ext), opts.content);
133
+
134
+ const indexDir = join(UPLOADS_DIR, ".index");
135
+ mkdirSync(indexDir, { recursive: true });
136
+ const entry = {
137
+ file_id: fileId,
138
+ date_bucket: opts.dateBucket,
139
+ ext,
140
+ name: opts.name ?? "planted.txt",
141
+ mime: "application/octet-stream",
142
+ size: opts.content.length,
143
+ owner: opts.ownerUserId,
144
+ owner_id: opts.ownerUserId,
145
+ uploaded_at: new Date().toISOString(),
146
+ };
147
+ writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry, null, 2));
148
+ return fileId;
149
+ }
150
+
151
+ async function downloadAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
152
+ return fetch(`${BASE}/api/files/${fileId}`, {
153
+ method: "GET",
154
+ headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
155
+ });
156
+ }
157
+
158
+ async function headAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
159
+ return fetch(`${BASE}/api/files/${fileId}`, {
160
+ method: "HEAD",
161
+ headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
162
+ });
163
+ }
164
+
165
+ // Yesterday, three-days-ago (relative to test-run time). Fully static
166
+ // dates would rot; computing offsets keeps the test durable while
167
+ // still isolating the "yesterday" case from today.
168
+ const _pad = (n: number) => String(n).padStart(2, "0");
169
+ function bucketNDaysAgo(n: number): string {
170
+ const d = new Date();
171
+ d.setUTCDate(d.getUTCDate() - n);
172
+ return `${d.getUTCFullYear()}-${_pad(d.getUTCMonth() + 1)}-${_pad(d.getUTCDate())}`;
173
+ }
174
+ const BUCKET_YESTERDAY = bucketNDaysAgo(1);
175
+ const BUCKET_3_DAYS_AGO = bucketNDaysAgo(3);
176
+ const BUCKET_TODAY = bucketNDaysAgo(0);
177
+
178
+ describe.skipIf(!isProdMode)("#509 — download uses index date_bucket, not today's date", () => {
179
+ test("Door 1: yesterday's file downloads today → 200 (was 404 blob_missing pre-fix)", async () => {
180
+ const content = new TextEncoder().encode("blob-from-yesterday");
181
+ const fileId = plantFileAtBucket({
182
+ ownerToken: userAToken,
183
+ ownerUserId: userAUserId,
184
+ dateBucket: BUCKET_YESTERDAY,
185
+ content,
186
+ });
187
+
188
+ // Precondition: blob is REALLY in yesterday's bucket, not today's.
189
+ // If both existed the test would be indistinguishable from a
190
+ // buildStoragePath-uses-today bug regression.
191
+ expect(existsSync(join(UPLOADS_DIR, BUCKET_YESTERDAY, fileId + ".txt"))).toBe(true);
192
+ expect(existsSync(join(UPLOADS_DIR, BUCKET_TODAY, fileId + ".txt"))).toBe(false);
193
+
194
+ const res = await downloadAs(userAToken, fileId);
195
+ expect(res.status).toBe(200);
196
+ expect(await res.text()).toBe("blob-from-yesterday");
197
+ });
198
+
199
+ test("Door 1 (further past): 3-days-ago file downloads today → 200", async () => {
200
+ const content = new TextEncoder().encode("blob-from-3-days-ago");
201
+ const fileId = plantFileAtBucket({
202
+ ownerToken: userAToken,
203
+ ownerUserId: userAUserId,
204
+ dateBucket: BUCKET_3_DAYS_AGO,
205
+ content,
206
+ });
207
+
208
+ const res = await downloadAs(userAToken, fileId);
209
+ expect(res.status).toBe(200);
210
+ expect(await res.text()).toBe("blob-from-3-days-ago");
211
+ });
212
+
213
+ test("Door 2 regression: today's file downloads today → 200 (unchanged behavior)", async () => {
214
+ const content = new TextEncoder().encode("blob-from-today");
215
+ const fileId = plantFileAtBucket({
216
+ ownerToken: userAToken,
217
+ ownerUserId: userAUserId,
218
+ dateBucket: BUCKET_TODAY,
219
+ content,
220
+ });
221
+
222
+ const res = await downloadAs(userAToken, fileId);
223
+ expect(res.status).toBe(200);
224
+ expect(await res.text()).toBe("blob-from-today");
225
+ });
226
+
227
+ test("Door 3 GET: yesterday's file via GET → 200 with correct body", async () => {
228
+ const content = new TextEncoder().encode("GET-cross-day");
229
+ const fileId = plantFileAtBucket({
230
+ ownerToken: userAToken,
231
+ ownerUserId: userAUserId,
232
+ dateBucket: BUCKET_YESTERDAY,
233
+ content,
234
+ });
235
+ const res = await downloadAs(userAToken, fileId);
236
+ expect(res.status).toBe(200);
237
+ expect(await res.text()).toBe("GET-cross-day");
238
+ });
239
+
240
+ test("Door 3 HEAD: yesterday's file via HEAD → 200 with Content-Length, no body", async () => {
241
+ const content = new TextEncoder().encode("HEAD-cross-day-payload");
242
+ const fileId = plantFileAtBucket({
243
+ ownerToken: userAToken,
244
+ ownerUserId: userAUserId,
245
+ dateBucket: BUCKET_YESTERDAY,
246
+ content,
247
+ });
248
+ const res = await headAs(userAToken, fileId);
249
+ expect(res.status).toBe(200);
250
+ expect(res.headers.get("Content-Length")).toBe(String(content.length));
251
+ // HEAD must not return a body — Bun exposes the stream but reads to
252
+ // 0 bytes. Some HTTP stacks return no ReadableStream at all; either
253
+ // is acceptable per RFC 9110 §9.3.2 — check size, not presence.
254
+ const buf = await res.arrayBuffer();
255
+ expect(buf.byteLength).toBe(0);
256
+ });
257
+
258
+ test("Door 3 Range: yesterday's file with Range header → data served from correct bucket", async () => {
259
+ const content = new TextEncoder().encode("0123456789ABCDEFGHIJ");
260
+ const fileId = plantFileAtBucket({
261
+ ownerToken: userAToken,
262
+ ownerUserId: userAUserId,
263
+ dateBucket: BUCKET_YESTERDAY,
264
+ content,
265
+ });
266
+ // Whether Range is honoured is orthogonal to #509 and NOT decided by
267
+ // this codebase: the handler is `new Response(Bun.file(path), {status:200})`
268
+ // with no Range handling anywhere, so 200-vs-206 is decided purely by the
269
+ // Bun runtime. Measured 2026-07-30 with byte-identical code:
270
+ // bun 1.2.22 → 200 + full body bun 1.3.14 → 206 + correct slice
271
+ // So both statuses are legitimate and this test must pass on both.
272
+ //
273
+ // 🔴 But "accept either status" must NOT mean "assert nothing". The first
274
+ // version guarded the body check with `if (status === 200)`, so on the
275
+ // runtime production actually runs (206) the body assertion never
276
+ // executed — the test claimed to prove the blob path resolved correctly
277
+ // while only ever checking a status code. Each branch asserts its own
278
+ // bytes, and an unrecognised status fails loudly rather than silently
279
+ // skipping.
280
+ const res = await fetch(`${BASE}/api/files/${fileId}`, {
281
+ method: "GET",
282
+ headers: { Authorization: `Bearer ${userAToken}`, Range: "bytes=0-4" },
283
+ });
284
+ expect([200, 206]).toContain(res.status);
285
+ if (res.status === 206) {
286
+ // Runtime honoured Range: body must be exactly the requested slice,
287
+ // and the slice must come from THIS file (total length 20 proves the
288
+ // resolved blob is the planted one, not some other bucket's file).
289
+ expect(await res.text()).toBe("01234");
290
+ expect(res.headers.get("content-range")).toBe(`bytes 0-4/${content.byteLength}`);
291
+ } else {
292
+ // Runtime ignored Range: body must be the complete planted blob.
293
+ expect(await res.text()).toBe("0123456789ABCDEFGHIJ");
294
+ }
295
+ });
296
+
297
+ test("Door 3 conditional: yesterday's file with If-None-Match → served or 304, never 404", async () => {
298
+ const content = new TextEncoder().encode("cond-cross-day");
299
+ const fileId = plantFileAtBucket({
300
+ ownerToken: userAToken,
301
+ ownerUserId: userAUserId,
302
+ dateBucket: BUCKET_YESTERDAY,
303
+ content,
304
+ });
305
+ const res = await downloadAs(userAToken, fileId, { "If-None-Match": '"never-matches-509"' });
306
+ expect([200, 304]).toContain(res.status);
307
+ expect(res.status).not.toBe(404);
308
+ });
309
+
310
+ test("Door 4: existing bucket-recorded blob is immediately readable without migration", async () => {
311
+ // Simulate the scenario Vincent hit: yesterday's PPT still on disk
312
+ // in yesterday's bucket, index intact — no re-upload, no data move,
313
+ // fix alone should make it readable.
314
+ const yesterdayBlob = new TextEncoder().encode("Vincents-actual-PPT-payload");
315
+ const fileId = plantFileAtBucket({
316
+ ownerToken: userAToken,
317
+ ownerUserId: userAUserId,
318
+ dateBucket: BUCKET_YESTERDAY,
319
+ content: yesterdayBlob,
320
+ ext: ".pptx",
321
+ name: "presentation.pptx",
322
+ });
323
+
324
+ // The blob file was NEVER moved after "plantFileAtBucket" wrote it
325
+ // to yesterday's bucket. Post-fix, the server must find it there.
326
+ const res = await downloadAs(userAToken, fileId);
327
+ expect(res.status).toBe(200);
328
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(yesterdayBlob);
329
+ });
330
+
331
+ test("Item 3 (副指挥 b1082017) — server TZ / UTC-boundary: entry bucket is authoritative regardless of runtime clock", async () => {
332
+ // 副指挥 7 硬门 ③: "覆盖服务时区与 UTC 日期边界".
333
+ //
334
+ // The read path is now structurally no-clock: pathForExistingBlob
335
+ // takes only the stored dateBucket, calls no getDateBucket / new
336
+ // Date() / Date.now(). This test pins that invariant with an
337
+ // explicit UTC-boundary scenario rather than leaving it as
338
+ // structural reasoning.
339
+ //
340
+ // Scenario: an entry recorded on 2026-07-29 (a hard-coded UTC
341
+ // date, unrelated to today's runtime clock). Even if the server
342
+ // process's runtime clock is anywhere in 2026-07-30..2026-08-01
343
+ // (i.e. one, two, or more midnights past the recorded bucket),
344
+ // the download must still resolve to the stored bucket path.
345
+ //
346
+ // The fixture uses a hard-coded date string (not offset from
347
+ // today) so the assertion is anchored on the invariant, not on
348
+ // "wall-clock happens to differ by exactly N days". If someone
349
+ // future-dates the runtime environment, this test still passes
350
+ // because the read path never consults the clock.
351
+ const hardcodedPastBucket = "2026-07-29";
352
+ const content = new TextEncoder().encode("tz-boundary-payload");
353
+ const fileId = plantFileAtBucket({
354
+ ownerToken: userAToken,
355
+ ownerUserId: userAUserId,
356
+ dateBucket: hardcodedPastBucket,
357
+ content,
358
+ });
359
+
360
+ // Precondition: blob is at the hard-coded bucket, NOT at any
361
+ // date derivable from `new Date()` at test time. If both paths
362
+ // happened to exist the test would not distinguish a
363
+ // stored-bucket read from a runtime-computed one.
364
+ expect(existsSync(join(UPLOADS_DIR, hardcodedPastBucket, fileId + ".txt"))).toBe(true);
365
+ // If today happens to be exactly 2026-07-29 (rare but possible
366
+ // when re-running the fixture), the today-bucket path exists too;
367
+ // the invariant we care about is the read succeeds because of the
368
+ // stored bucket, not because of a runtime coincidence.
369
+
370
+ const res = await downloadAs(userAToken, fileId);
371
+ expect(res.status).toBe(200);
372
+ expect(await res.text()).toBe("tz-boundary-payload");
373
+
374
+ // Additional invariant grep: the fixture's stored bucket is the
375
+ // ONLY bucket the read path should have visited. We can't observe
376
+ // the exact filesystem access here without instrumenting, but the
377
+ // structural property is enforced by pathForExistingBlob's API
378
+ // (no `now` parameter, no Date import). Combined with Door 1
379
+ // (yesterday-not-today) and this hard-coded case, the intent is
380
+ // pinned in tests, not just in the function name.
381
+ });
382
+
383
+ // Helper: plant an index entry with a poisoned/invalid date_bucket.
384
+ // Blob file is optional — the point is the bucket string itself.
385
+ function plantPoisonedBucketEntry(dateBucket: unknown): string {
386
+ const fileId = require("crypto").randomUUID().replace(/-/g, "");
387
+ const indexDir = join(UPLOADS_DIR, ".index");
388
+ mkdirSync(indexDir, { recursive: true });
389
+ const entry = {
390
+ file_id: fileId,
391
+ date_bucket: dateBucket,
392
+ ext: ".txt",
393
+ name: "poison.txt",
394
+ mime: "application/octet-stream",
395
+ size: 4,
396
+ owner: userAUserId,
397
+ owner_id: userAUserId,
398
+ uploaded_at: new Date().toISOString(),
399
+ };
400
+ writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry));
401
+ return fileId;
402
+ }
403
+
404
+ // 副指挥 66983a19 hard-door: 稳定 404 非 500 for
405
+ // • path-escape poisoned buckets ("../etc", "..\\etc", etc.)
406
+ // • non-calendar buckets ("2026-02-30", "2026-13-01", ...)
407
+ // • shape-only-valid but semantically impossible dates
408
+ // 500 says "server failed to handle input"; 404 says "input refused
409
+ // at the boundary". The latter is a stronger, more auditable posture.
410
+ const invalidBuckets: Array<[string, unknown]> = [
411
+ ["path-escape ../etc", "../etc"],
412
+ ["path-escape .. only", ".."],
413
+ ["backslash traversal", "..\\etc"],
414
+ ["contains null byte", "date"],
415
+ ["contains slash", "2026/07/29"],
416
+ ["empty string", ""],
417
+ ["all-nines shape but not calendar", "9999-99-99"],
418
+ ["month 13 (impossible)", "2026-13-01"],
419
+ ["month 00 (impossible)", "2026-00-15"],
420
+ ["day 30 in February 2026 (non-leap-year rejected)", "2026-02-30"],
421
+ ["day 32 (impossible)", "2026-07-32"],
422
+ ["Feb 29 on non-leap year", "2025-02-29"],
423
+ ];
424
+
425
+ for (const [label, badBucket] of invalidBuckets) {
426
+ test(`Refinement (副指挥 66983a19) — invalid bucket "${label}" → 404 (not 500)`, async () => {
427
+ const fileId = plantPoisonedBucketEntry(badBucket);
428
+ const res = await downloadAs(userAToken, fileId);
429
+ // 稳定 404: pre-check via isValidCalendarBucket rejects at the
430
+ // boundary. 500 would signal a caught throw from deeper code —
431
+ // that is exactly the shape we're refusing to accept per副指挥
432
+ // b1082017 (可靠性差很远).
433
+ expect(res.status).toBe(404);
434
+ const body: any = await res.json();
435
+ expect(body.error).toBe("not_found");
436
+ });
437
+ }
438
+
439
+ test("Refinement (副指挥 66983a19) — valid Feb 29 on ACTUAL leap year 2024 is accepted (not over-rejected)", async () => {
440
+ // Sanity: the calendar validator must not accidentally reject
441
+ // legitimate leap-day buckets from a real leap year. Constructs
442
+ // an entry at 2024-02-29 (valid), blob at that path, downloads it.
443
+ const content = new TextEncoder().encode("leap-day-blob");
444
+ const fileId = plantFileAtBucket({
445
+ ownerToken: userAToken,
446
+ ownerUserId: userAUserId,
447
+ dateBucket: "2024-02-29",
448
+ content,
449
+ });
450
+ const res = await downloadAs(userAToken, fileId);
451
+ expect(res.status).toBe(200);
452
+ expect(await res.text()).toBe("leap-day-blob");
453
+ });
454
+ });
package/src/server.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  MAX_REQUEST_CONTENT_LENGTH,
17
17
  MAX_UPLOAD_BYTES,
18
18
  buildStoragePath,
19
+ pathForExistingBlob,
20
+ isValidCalendarBucket,
19
21
  generateFileId,
20
22
  getUploadsRoot,
21
23
  indexEntryPath,
@@ -1773,10 +1775,15 @@ return Bun.serve({
1773
1775
 
1774
1776
  let entry: any;
1775
1777
  try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
1776
- return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
1778
+ // #509 副指挥 66983a19: corrupted index must not leak "server
1779
+ // failed" signal — 404 with same shape as unknown file_id
1780
+ // (input refused at boundary, no enumeration).
1781
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1777
1782
  }
1778
1783
  if (!validateIndexEntry(entry)) {
1779
- return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1784
+ // #509 副指挥 66983a19: schema-invalid index (bucket "../etc",
1785
+ // missing fields, wrong types) is also 404 — same discipline.
1786
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1780
1787
  }
1781
1788
 
1782
1789
  // #495 ownership gate — placed AFTER file-exists so denied
@@ -1788,11 +1795,29 @@ return Bun.serve({
1788
1795
  return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1789
1796
  }
1790
1797
 
1798
+ // #509 defence-in-depth: pre-check date_bucket against BOTH
1799
+ // shape regex AND real-calendar semantics. A poisoned index (e.g.
1800
+ // "../etc", "2026-02-30", "2026-13-01") must return 404 not 500
1801
+ // — 500 says "server failed"; 404 says "input refused at boundary".
1802
+ // 副指挥 b1082017 / 66983a19 hard doors:
1803
+ // • 稳定 404 非 500 for invalid/missing/poisoned date_bucket
1804
+ // • 真日历语义校验, 不仅 regex 形状
1805
+ if (!isValidCalendarBucket(entry.date_bucket)) {
1806
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1807
+ }
1808
+
1791
1809
  let storage;
1792
1810
  try {
1793
- storage = buildStoragePath(entry.file_id, entry.ext);
1811
+ // #509 fix: use the date_bucket recorded in the index at upload
1812
+ // time, NOT today's date. buildStoragePath computes today; that is
1813
+ // correct for new uploads only. Yesterday's file lives in
1814
+ // yesterday's bucket forever — read path must respect that.
1815
+ storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
1794
1816
  } catch {
1795
- return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1817
+ // Defence-in-depth: unreachable given the pre-check above, but
1818
+ // keep as fallback. Return 404 not 500 to match the pre-check
1819
+ // shape — caller sees the same code for all invalid-bucket cases.
1820
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1796
1821
  }
1797
1822
  if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1798
1823
  // Defence: should be unreachable since buildStoragePath rejected
package/src/uploads.ts CHANGED
@@ -94,10 +94,47 @@ export function sanitizeExt(filename: string | undefined | null): string {
94
94
  return match ? "." + match[1].toLowerCase() : "";
95
95
  }
96
96
 
97
+ // YYYY-MM-DD bucket string regex — matches what getDateBucket produces
98
+ // and what an index entry's `date_bucket` field is validated against.
99
+ const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
100
+
101
+ /**
102
+ * Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
103
+ * AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
104
+ * "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
105
+ * enough because the regex accepts any digit combination in each
106
+ * position.
107
+ *
108
+ * Used as a pre-check by the download handler before it calls into
109
+ * pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
110
+ * rejected at the boundary), NOT a 500 (uncaught throw from deep in
111
+ * the read path). Distinguishing these matters: 500 says "server
112
+ * failed"; 404 says "we know your request is bad, refused early".
113
+ */
114
+ export function isValidCalendarBucket(bucket: unknown): bucket is string {
115
+ if (typeof bucket !== "string") return false;
116
+ if (!DATE_BUCKET_REGEX.test(bucket)) return false;
117
+ // Round-trip via Date to reject non-calendar shapes like "2026-02-30"
118
+ // or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
119
+ // "2026-03-02") — if the ISO round-trip doesn't equal the input, the
120
+ // caller supplied a non-calendar bucket.
121
+ const [yStr, mStr, dStr] = bucket.split("-");
122
+ const y = Number(yStr), m = Number(mStr), d = Number(dStr);
123
+ if (m < 1 || m > 12) return false;
124
+ if (d < 1 || d > 31) return false;
125
+ const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
126
+ return roundtrip === bucket;
127
+ }
128
+
97
129
  /**
98
- * Build the per-upload storage path. The file_id and ext are validated
99
- * against strict regexes; any path-separator or `..` segment from a
100
- * malicious client gets rejected at the regex layer before any I/O.
130
+ * Build the storage path for a NEW upload. Computes today's date bucket
131
+ * from the runtime clock (or `opts.now` for tests). Use ONLY for creating
132
+ * a fresh blob; for reading an existing blob whose bucket is already
133
+ * recorded in the index, use pathForExistingBlob() instead.
134
+ *
135
+ * The file_id and ext are validated against strict regexes; any
136
+ * path-separator or `..` segment from a malicious client gets rejected
137
+ * at the regex layer before any I/O.
101
138
  */
102
139
  export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
103
140
  if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
@@ -109,6 +146,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
109
146
  return { fileId, ext, dateBucket, relativePath, absolutePath };
110
147
  }
111
148
 
149
+ /**
150
+ * Build the storage path for an EXISTING blob, using the caller-supplied
151
+ * `dateBucket` (typically read from the index entry). Does NOT compute
152
+ * a date from the runtime clock — that is the write-side concern.
153
+ *
154
+ * This split exists because sharing one date-computing function between
155
+ * write and read semantics caused #509: yesterday's file downloaded
156
+ * today would look under today's bucket → blob_missing → 404, even
157
+ * though the blob was still sitting in yesterday's directory.
158
+ *
159
+ * The file_id, ext and dateBucket are validated against strict regexes
160
+ * before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
161
+ * here just as file_id path-escape attempts are.
162
+ */
163
+ export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
164
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
165
+ if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
166
+ if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
167
+ throw new Error(`date_bucket "${dateBucket}" is invalid`);
168
+ }
169
+ const root = opts.uploadsRoot ?? getUploadsRoot();
170
+ const relativePath = join(dateBucket, fileId + ext);
171
+ const absolutePath = join(root, relativePath);
172
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
173
+ }
174
+
112
175
  export type UploadIndexEntry = {
113
176
  file_id: string;
114
177
  date_bucket: string; // YYYY-MM-DD