deepspace 0.19.5 → 0.21.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
  3. package/dist/chunk-KDCDRHBA.js.map +1 -0
  4. package/dist/cli.js +219 -94
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.d.ts +3 -2
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +48 -48
  9. package/dist/documentation-react.d.ts +2 -1
  10. package/dist/documentation-react.js +1 -1
  11. package/dist/documentation-runtime.js +7 -7
  12. package/dist/documentation-server-core.d.ts +3 -2
  13. package/dist/documentation-server-core.js +1 -1
  14. package/dist/documentation.d.ts +40 -12
  15. package/dist/documentation.js +40 -7
  16. package/dist/documentation.js.map +1 -1
  17. package/dist/index.d.ts +225 -21
  18. package/dist/index.js +262 -112
  19. package/dist/index.js.map +1 -1
  20. package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
  21. package/dist/server.d.ts +46 -29
  22. package/dist/server.js +1041 -254
  23. package/dist/server.js.map +1 -1
  24. package/dist/testing-mcp.d.ts +69 -0
  25. package/dist/testing-mcp.js +99 -0
  26. package/dist/testing-mcp.js.map +1 -0
  27. package/dist/testing.d.ts +17 -20
  28. package/dist/testing.js +47 -25
  29. package/dist/testing.js.map +1 -1
  30. package/dist/worker.d.ts +73 -32
  31. package/dist/worker.js +1094 -244
  32. package/dist/worker.js.map +1 -1
  33. package/features/ai-chat/feature.json +2 -2
  34. package/features/ai-chat/src/AiChatPage.tsx +56 -21
  35. package/features/ai-chat/src/ChatPanel.tsx +33 -10
  36. package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
  37. package/features/cron/src/CronLogPage.tsx +107 -69
  38. package/features/file-attachments/feature.json +2 -1
  39. package/features/file-manager/feature.json +2 -1
  40. package/features/file-manager/src/FileManagerPage.tsx +227 -194
  41. package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
  42. package/package.json +5 -1
  43. package/dist/chunk-A7UFIOWO.js.map +0 -1
package/dist/server.js CHANGED
@@ -211,9 +211,6 @@ var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
211
211
  "application/ecmascript",
212
212
  "text/ecmascript"
213
213
  ]);
214
- function activeContentMessage(mimeType) {
215
- return `${mimeType} is not accepted: the files API serves your app's own origin, so stored HTML/SVG/JavaScript would execute there with your users' sessions. Ship it as a build asset under \`public/\` instead, where it is served as part of your app rather than as user-uploaded content.`;
216
- }
217
214
  function formatBytes(bytes) {
218
215
  if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
219
216
  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
@@ -233,21 +230,479 @@ function oversizePartMessage(bytes) {
233
230
  return `This part carries ${formatBytes(bytes)}; a part may carry ${formatBytes(UPLOAD_PART_BYTES)} \u2014 the size the upload advertised.`;
234
231
  }
235
232
 
236
- // src/server/utils/scoped-r2-files.ts
233
+ // src/server/utils/r2-storage-quota.ts
237
234
  var CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
235
+ var QUOTA_SUMMARY_VERSION = 1;
236
+ var MAX_QUOTA_SCAN_OBJECTS = 5e5;
237
+ var MAX_QUOTA_SCAN_LISTS = 900;
238
+ var QUOTA_REPAIR_COOLDOWN_MS = 6e4;
239
+ var PENDING_MARKER_MAX_AGE_MS = 60 * 6e4;
240
+ var EMPTY_MULTIPART_MARKER_MAX_AGE_MS = 60 * 6e4;
241
+ var ACTIVE_MULTIPART_MARKER_MAX_AGE_MS = 8 * 24 * 60 * 6e4;
238
242
  function fail(status, error, code, extra) {
239
243
  return Response.json(
240
244
  { error, ...code ? { code } : {}, ...extra },
241
245
  { status, headers: CORS_HEADERS }
242
246
  );
243
247
  }
244
- function contentDisposition(originalName) {
248
+ function quotaUnavailable(message) {
249
+ return fail(503, message, "storage_limit_unavailable");
250
+ }
251
+ function quotaKeySegment(value) {
252
+ return encodeURIComponent(value);
253
+ }
254
+ function markerPrefix(storage, kind) {
255
+ return `__deepspace/quota/${kind}/${quotaKeySegment(storage.quotaKey)}/`;
256
+ }
257
+ function pendingMarkerPrefix(storage) {
258
+ return markerPrefix(storage, "pending");
259
+ }
260
+ function multipartMarkerPrefix(storage) {
261
+ return markerPrefix(storage, "multipart");
262
+ }
263
+ function multipartMarkerKey(storage, reservationId) {
264
+ return `${multipartMarkerPrefix(storage)}${quotaKeySegment(reservationId)}`;
265
+ }
266
+ function accountPrefixesEqual(left, right) {
267
+ return left.length === right.length && left.every((prefix, index) => prefix === right[index]);
268
+ }
269
+ async function allocationPrefixes(storage) {
270
+ const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
271
+ if (siblings === null) return null;
272
+ return [.../* @__PURE__ */ new Set([storage.prefix, ...siblings])].sort();
273
+ }
274
+ function countScan(budget, count) {
275
+ budget.lists++;
276
+ budget.objects += count;
277
+ if (budget.lists > MAX_QUOTA_SCAN_LISTS || budget.objects > MAX_QUOTA_SCAN_OBJECTS) {
278
+ throw new Error("quota scan work limit exceeded");
279
+ }
280
+ }
281
+ async function prefixUsage(bucket, prefix, budget) {
282
+ let total = 0;
283
+ let cursor;
284
+ do {
285
+ const page = await bucket.list({ prefix, cursor, limit: 1e3 });
286
+ countScan(budget, page.objects.length);
287
+ for (const object of page.objects) total += object.size;
288
+ cursor = page.truncated ? page.cursor : void 0;
289
+ } while (cursor !== void 0);
290
+ return total;
291
+ }
292
+ function markerBytes(object) {
293
+ const raw = object.customMetadata?.reservedBytes;
294
+ const bytes = raw === void 0 ? NaN : Number(raw);
295
+ if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("invalid quota marker");
296
+ return bytes;
297
+ }
298
+ function markerMaxAge(object, fallback) {
299
+ if (object.customMetadata?.quotaKind !== "multipart") return fallback;
300
+ return object.customMetadata.active === "true" ? ACTIVE_MULTIPART_MARKER_MAX_AGE_MS : EMPTY_MULTIPART_MARKER_MAX_AGE_MS;
301
+ }
302
+ async function markerUsage(bucket, prefix, maxAgeMs, budget, excludedKey) {
303
+ let total = 0;
304
+ let cursor;
305
+ const now = Date.now();
306
+ do {
307
+ const options = {
308
+ prefix,
309
+ cursor,
310
+ limit: 1e3,
311
+ include: ["customMetadata"]
312
+ };
313
+ const page = await bucket.list(options);
314
+ countScan(budget, page.objects.length);
315
+ for (const object of page.objects) {
316
+ if (now - object.uploaded.getTime() > markerMaxAge(object, maxAgeMs)) {
317
+ if (budget.staleKeys.length < 1e3) budget.staleKeys.push(object.key);
318
+ } else if (object.key !== excludedKey) {
319
+ total += markerBytes(object);
320
+ }
321
+ }
322
+ cursor = page.truncated ? page.cursor : void 0;
323
+ } while (cursor !== void 0);
324
+ return total;
325
+ }
326
+ async function measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey) {
327
+ const budget = { lists: 0, objects: 0, staleKeys: [] };
328
+ let total = 0;
329
+ for (const prefix of prefixes) total += await prefixUsage(bucket, prefix, budget);
330
+ total += await markerUsage(
331
+ bucket,
332
+ pendingMarkerPrefix(storage),
333
+ PENDING_MARKER_MAX_AGE_MS,
334
+ budget,
335
+ excludedMarkerKey
336
+ );
337
+ total += await markerUsage(
338
+ bucket,
339
+ multipartMarkerPrefix(storage),
340
+ ACTIVE_MULTIPART_MARKER_MAX_AGE_MS,
341
+ budget,
342
+ excludedMarkerKey
343
+ );
344
+ if (budget.staleKeys.length > 0) {
345
+ await bucket.delete(budget.staleKeys).catch((error) => {
346
+ console.error("[files:quota] stale marker cleanup:", error);
347
+ });
348
+ }
349
+ return total;
350
+ }
351
+ function isQuotaSummary(value) {
352
+ if (!value || typeof value !== "object") return false;
353
+ const summary = value;
354
+ return summary.version === QUOTA_SUMMARY_VERSION && Array.isArray(summary.prefixes) && summary.prefixes.every((prefix) => typeof prefix === "string") && Number.isSafeInteger(summary.usedBytes) && (summary.usedBytes ?? -1) >= 0 && Number.isSafeInteger(summary.measuredAt) && (summary.measuredAt ?? -1) >= 0 && typeof summary.dirty === "boolean";
355
+ }
356
+ async function readQuotaSummary(bucket, key) {
357
+ const object = await bucket.get(key);
358
+ if (!object) return { etag: null, summary: null };
359
+ try {
360
+ const parsed = await object.json();
361
+ return { etag: object.etag, summary: isQuotaSummary(parsed) ? parsed : null };
362
+ } catch {
363
+ return { etag: object.etag, summary: null };
364
+ }
365
+ }
366
+ async function writeQuotaSummary(bucket, key, previousEtag, summary) {
367
+ const onlyIf = previousEtag ? { etagMatches: previousEtag } : new Headers({ "If-None-Match": "*" });
368
+ const object = await bucket.put(key, JSON.stringify(summary), {
369
+ onlyIf,
370
+ httpMetadata: { contentType: "application/json" }
371
+ });
372
+ return object ? { etag: object.etag, summary } : null;
373
+ }
374
+ async function ensureQuotaSummary(bucket, storage, prefixes, forceMeasure = false, excludedMarkerKey) {
375
+ for (let attempt = 0; attempt < 4; attempt++) {
376
+ const current = await readQuotaSummary(bucket, storage.quotaKey);
377
+ if (!forceMeasure && current.summary && accountPrefixesEqual(current.summary.prefixes, prefixes)) {
378
+ return current;
379
+ }
380
+ const usedBytes = await measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey);
381
+ const repaired = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
382
+ version: QUOTA_SUMMARY_VERSION,
383
+ prefixes,
384
+ usedBytes,
385
+ measuredAt: Date.now(),
386
+ dirty: false
387
+ });
388
+ if (repaired) return repaired;
389
+ }
390
+ return null;
391
+ }
392
+ async function markQuotaDirty(bucket, storage) {
393
+ if (!storage) return;
394
+ try {
395
+ for (let attempt = 0; attempt < 4; attempt++) {
396
+ const current = await readQuotaSummary(bucket, storage.quotaKey);
397
+ if (!current.summary || current.summary.dirty) return;
398
+ const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
399
+ ...current.summary,
400
+ dirty: true
401
+ });
402
+ if (written) return;
403
+ }
404
+ console.error("[files:quota] could not mark summary reclaimable after concurrent writes");
405
+ } catch (error) {
406
+ console.error("[files:quota] could not mark summary reclaimable:", error);
407
+ }
408
+ }
409
+ async function neutralizeMarker(bucket, markerKey) {
410
+ for (let attempt = 0; attempt < 4; attempt++) {
411
+ const marker = await bucket.get(markerKey);
412
+ if (!marker) return true;
413
+ if (markerBytes(marker) === 0) return true;
414
+ const body = await marker.arrayBuffer();
415
+ const object = await bucket.put(markerKey, body, {
416
+ onlyIf: { etagMatches: marker.etag },
417
+ customMetadata: {
418
+ ...marker.customMetadata,
419
+ reservedBytes: "0",
420
+ releasedAt: String(Date.now())
421
+ }
422
+ });
423
+ if (object) return true;
424
+ }
425
+ return false;
426
+ }
427
+ async function releaseQuotaMarker(bucket, storage, markerKey, summaryMayBeStale) {
428
+ try {
429
+ if (markerKey && !await neutralizeMarker(bucket, markerKey)) {
430
+ console.error("[files:quota] could not neutralize reservation marker");
431
+ return false;
432
+ }
433
+ if (summaryMayBeStale) await markQuotaDirty(bucket, storage);
434
+ if (markerKey) {
435
+ for (let attempt = 0; attempt < 4; attempt++) {
436
+ try {
437
+ await bucket.delete(markerKey);
438
+ break;
439
+ } catch (error) {
440
+ if (attempt === 3) throw error;
441
+ }
442
+ }
443
+ }
444
+ return true;
445
+ } catch (error) {
446
+ console.error("[files:quota] reservation cleanup:", error);
447
+ return false;
448
+ }
449
+ }
450
+ function parseMultipartMarker(value) {
451
+ if (!value || typeof value !== "object") return null;
452
+ const marker = value;
453
+ if (marker.version !== 1 || typeof marker.reservationId !== "string" || !marker.reservationId || typeof marker.uploadId !== "string" || !marker.uploadId || typeof marker.objectKey !== "string" || !marker.objectKey || typeof marker.uploadKey !== "string" || !marker.uploadKey || typeof marker.admitted !== "boolean" || !Number.isSafeInteger(marker.declaredBytes) || (marker.declaredBytes ?? -1) < 0) {
454
+ return null;
455
+ }
456
+ return marker;
457
+ }
458
+ function multipartMarkerBody(reservation) {
459
+ return JSON.stringify({
460
+ version: 1,
461
+ reservationId: reservation.reservationId,
462
+ uploadId: reservation.uploadId,
463
+ objectKey: reservation.objectKey,
464
+ uploadKey: reservation.uploadKey,
465
+ admitted: reservation.admitted,
466
+ declaredBytes: reservation.declaredBytes
467
+ });
468
+ }
469
+ async function readMultipartReservation(bucket, storage, reservationId) {
470
+ if (!storage) return null;
471
+ const key = multipartMarkerKey(storage, reservationId);
472
+ let object;
473
+ try {
474
+ object = await bucket.get(key);
475
+ } catch (error) {
476
+ console.error("[files:quota] reservation read:", error);
477
+ return "invalid";
478
+ }
479
+ if (!object) return null;
480
+ let marker = null;
481
+ try {
482
+ marker = parseMultipartMarker(await object.json());
483
+ } catch {
484
+ return "invalid";
485
+ }
486
+ if (!marker || marker.reservationId !== reservationId) return "invalid";
487
+ let reservedBytes;
488
+ try {
489
+ reservedBytes = markerBytes(object);
490
+ } catch {
491
+ return "invalid";
492
+ }
493
+ return {
494
+ key,
495
+ etag: object.etag,
496
+ reservationId,
497
+ uploadId: marker.uploadId,
498
+ objectKey: marker.objectKey,
499
+ uploadKey: marker.uploadKey,
500
+ admitted: marker.admitted,
501
+ declaredBytes: marker.declaredBytes,
502
+ reservedBytes
503
+ };
504
+ }
505
+ async function refreshMultipartReservation(bucket, storage, reservationId, uploadId, objectKey) {
506
+ if (!storage) return null;
507
+ for (let attempt = 0; attempt < 4; attempt++) {
508
+ const reservation = await readMultipartReservation(bucket, storage, reservationId);
509
+ if (reservation === null || reservation === "invalid") return reservation ?? "invalid";
510
+ if (!reservation.admitted || reservation.uploadId !== uploadId || reservation.objectKey !== objectKey) {
511
+ return "invalid";
512
+ }
513
+ const object = await bucket.put(reservation.key, multipartMarkerBody(reservation), {
514
+ onlyIf: { etagMatches: reservation.etag },
515
+ customMetadata: {
516
+ quotaKind: "multipart",
517
+ reservedBytes: String(reservation.reservedBytes),
518
+ active: "true"
519
+ }
520
+ });
521
+ if (object) return { ...reservation, etag: object.etag };
522
+ }
523
+ return "busy";
524
+ }
525
+ async function activateMultipartReservation(bucket, storage, reservationId) {
526
+ if (!storage) return null;
527
+ for (let attempt = 0; attempt < 4; attempt++) {
528
+ const reservation = await readMultipartReservation(bucket, storage, reservationId);
529
+ if (!reservation || reservation === "invalid") return null;
530
+ if (reservation.admitted) return reservation;
531
+ const next = { ...reservation, admitted: true };
532
+ const object = await bucket.put(reservation.key, multipartMarkerBody(next), {
533
+ onlyIf: { etagMatches: reservation.etag },
534
+ customMetadata: {
535
+ quotaKind: "multipart",
536
+ reservedBytes: String(reservation.reservedBytes),
537
+ active: "false"
538
+ }
539
+ });
540
+ if (object) {
541
+ return { ...next, etag: object.etag };
542
+ }
543
+ }
544
+ return null;
545
+ }
546
+ function quotaExceeded(incomingBytes, summaryBytes, replacedBytes, limitBytes) {
547
+ const usedBytes = Math.max(0, summaryBytes - replacedBytes);
548
+ return fail(
549
+ 409,
550
+ storageQuotaMessage(incomingBytes, usedBytes, limitBytes),
551
+ "storage_quota_exceeded",
552
+ { usedBytes, limitBytes }
553
+ );
554
+ }
555
+ async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeBytes, incomingBytes, replacedBytes, limitBytes, markerKey) {
556
+ if (chargeBytes === 0) return null;
557
+ let current = initial;
558
+ let repaired = false;
559
+ for (let attempt = 0; attempt < 8; attempt++) {
560
+ if (!current.summary || !accountPrefixesEqual(current.summary.prefixes, prefixes)) {
561
+ const ensured = await ensureQuotaSummary(
562
+ bucket,
563
+ storage,
564
+ prefixes,
565
+ false,
566
+ markerKey ?? void 0
567
+ );
568
+ if (!ensured) {
569
+ return quotaUnavailable(
570
+ "The storage used by this account could not be verified. Retry shortly."
571
+ );
572
+ }
573
+ current = ensured;
574
+ }
575
+ const summary = current.summary;
576
+ if (!summary) {
577
+ return quotaUnavailable(
578
+ "The storage used by this account could not be verified. Retry shortly."
579
+ );
580
+ }
581
+ if (summary.usedBytes + chargeBytes > limitBytes) {
582
+ const recentlyMeasured = Date.now() - summary.measuredAt < QUOTA_REPAIR_COOLDOWN_MS;
583
+ if (repaired || !summary.dirty && recentlyMeasured) {
584
+ return quotaExceeded(incomingBytes, summary.usedBytes, replacedBytes, limitBytes);
585
+ }
586
+ const refreshed = await ensureQuotaSummary(
587
+ bucket,
588
+ storage,
589
+ prefixes,
590
+ true,
591
+ markerKey ?? void 0
592
+ );
593
+ if (!refreshed?.summary) {
594
+ return quotaUnavailable(
595
+ "The storage used by this account could not be verified. Retry shortly."
596
+ );
597
+ }
598
+ current = refreshed;
599
+ repaired = true;
600
+ continue;
601
+ }
602
+ const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
603
+ ...summary,
604
+ usedBytes: summary.usedBytes + chargeBytes
605
+ });
606
+ if (written) return null;
607
+ current = await readQuotaSummary(bucket, storage.quotaKey);
608
+ }
609
+ return quotaUnavailable("Storage is busy with other uploads. Retry shortly.");
610
+ }
611
+ async function admitStorage(bucket, storage, incomingBytes, replacedBytes, marker) {
612
+ if (!storage) return { markerKey: null };
613
+ let markerKey = null;
614
+ try {
615
+ const limit = await storage.limitBytes();
616
+ if (limit === null) {
617
+ return quotaUnavailable(
618
+ "The storage limit for this app could not be verified; nothing was uploaded. Retry shortly."
619
+ );
620
+ }
621
+ const prefixes = await allocationPrefixes(storage);
622
+ if (prefixes === null) {
623
+ return quotaUnavailable(
624
+ "The apps in this storage allocation could not be enumerated; nothing was uploaded. Retry shortly."
625
+ );
626
+ }
627
+ const initial = await ensureQuotaSummary(bucket, storage, prefixes);
628
+ if (!initial) {
629
+ return quotaUnavailable(
630
+ "The storage already used by this account could not be measured; nothing was uploaded. Retry shortly."
631
+ );
632
+ }
633
+ const chargeBytes = Math.max(0, incomingBytes - replacedBytes);
634
+ const effectiveMarker = marker ?? (chargeBytes > 0 ? {
635
+ key: `${pendingMarkerPrefix(storage)}${crypto.randomUUID()}`,
636
+ metadata: { quotaKind: "pending" }
637
+ } : null);
638
+ if (effectiveMarker) {
639
+ const object = await bucket.put(effectiveMarker.key, effectiveMarker.body ?? "", {
640
+ onlyIf: new Headers({ "If-None-Match": "*" }),
641
+ customMetadata: {
642
+ ...effectiveMarker.metadata,
643
+ reservedBytes: String(chargeBytes)
644
+ }
645
+ });
646
+ if (!object) {
647
+ return fail(
648
+ 409,
649
+ "An upload with this request id is already in progress.",
650
+ "reservation_conflict"
651
+ );
652
+ }
653
+ markerKey = effectiveMarker.key;
654
+ }
655
+ const refusal = await reserveQuotaSummary(
656
+ bucket,
657
+ storage,
658
+ prefixes,
659
+ initial,
660
+ chargeBytes,
661
+ incomingBytes,
662
+ replacedBytes,
663
+ limit,
664
+ effectiveMarker?.key ?? null
665
+ );
666
+ if (refusal) {
667
+ await releaseQuotaMarker(bucket, storage, effectiveMarker?.key ?? null, false);
668
+ return refusal;
669
+ }
670
+ return { markerKey: effectiveMarker?.key ?? null };
671
+ } catch (error) {
672
+ console.error("[files:quota] admission:", error);
673
+ await releaseQuotaMarker(bucket, storage, markerKey, true);
674
+ return quotaUnavailable(
675
+ "The storage used by this account could not be verified. Retry shortly."
676
+ );
677
+ }
678
+ }
679
+ async function storageUsage(bucket, storage) {
680
+ try {
681
+ const prefixes = await allocationPrefixes(storage);
682
+ if (prefixes === null) return null;
683
+ const current = await ensureQuotaSummary(bucket, storage, prefixes);
684
+ return current?.summary?.usedBytes ?? null;
685
+ } catch (error) {
686
+ console.error("[files:quota] usage:", error);
687
+ return null;
688
+ }
689
+ }
690
+
691
+ // src/server/utils/scoped-r2-files.ts
692
+ var CORS_HEADERS2 = { "Access-Control-Allow-Origin": "*" };
693
+ function fail2(status, error, code, extra) {
694
+ return Response.json(
695
+ { error, ...code ? { code } : {}, ...extra },
696
+ { status, headers: CORS_HEADERS2 }
697
+ );
698
+ }
699
+ function contentDisposition(originalName, disposition) {
245
700
  const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
246
701
  const encoded = encodeURIComponent(originalName).replace(
247
702
  /[!'()*]/g,
248
703
  (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
249
704
  );
250
- return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
705
+ return `${disposition}; filename="${safe}"; filename*=UTF-8''${encoded}`;
251
706
  }
252
707
  function sanitizeSubpath(raw) {
253
708
  if (!raw) return "";
@@ -257,24 +712,24 @@ function sanitizeSubpath(raw) {
257
712
  }
258
713
  function overRequestBound(bytes, slack = 0) {
259
714
  if (bytes <= MAX_UPLOAD_REQUEST_BYTES + slack) return null;
260
- return fail(413, oversizeRequestMessage(bytes), "too_large", {
715
+ return fail2(413, oversizeRequestMessage(bytes), "too_large", {
261
716
  maxBytes: MAX_UPLOAD_REQUEST_BYTES,
262
717
  partSize: UPLOAD_PART_BYTES
263
718
  });
264
719
  }
265
720
  function overCeiling(bytes) {
266
721
  if (bytes <= MAX_APP_FILE_BYTES) return null;
267
- return fail(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
722
+ return fail2(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
268
723
  }
269
724
  function overPartBound(bytes) {
270
725
  if (bytes <= UPLOAD_PART_BYTES) return null;
271
- return fail(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
726
+ return fail2(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
272
727
  }
273
728
  function declaredLength(request, what) {
274
729
  const raw = request.headers.get("content-length");
275
730
  const declared = raw === null ? NaN : Number(raw);
276
731
  if (!Number.isSafeInteger(declared) || declared < 0) {
277
- return fail(
732
+ return fail2(
278
733
  411,
279
734
  `${what} must declare a Content-Length. A chunked or length-less body is refused: R2 cannot size a write it has no length for.`,
280
735
  "length_required"
@@ -282,78 +737,18 @@ function declaredLength(request, what) {
282
737
  }
283
738
  return declared;
284
739
  }
285
- function isKeyWithinPrefix(key, prefix) {
286
- return key.startsWith(prefix);
287
- }
288
- async function prefixUsage(bucket, prefix) {
289
- let total = 0;
290
- let cursor;
291
- do {
292
- const page = await bucket.list({ prefix, cursor, limit: 1e3 });
293
- for (const object of page.objects) total += object.size;
294
- cursor = page.truncated ? page.cursor : void 0;
295
- } while (cursor !== void 0);
296
- return total;
740
+ function isKeyWithinScope(key, prefix, excludedPrefixes) {
741
+ return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
297
742
  }
298
- async function allocationUsage(bucket, storage) {
299
- const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
300
- if (siblings === null) return null;
301
- const prefixes = [storage.prefix, ...siblings.filter((p) => p !== storage.prefix)];
302
- const totals = await Promise.all(prefixes.map((prefix) => prefixUsage(bucket, prefix)));
303
- return totals.reduce((sum, bytes) => sum + bytes, 0);
743
+ function scopeRelativeKey(key, prefix) {
744
+ return key.slice(prefix.length);
304
745
  }
305
- async function admitStorage(bucket, storage, incomingBytes, replacesKey) {
306
- if (!storage) return null;
307
- const limit = await storage.limitBytes();
308
- if (limit === null) {
309
- return fail(
310
- 503,
311
- "The storage limit for this app could not be verified; nothing was uploaded. Retry shortly.",
312
- "storage_limit_unavailable"
313
- );
314
- }
315
- const stored = await allocationUsage(bucket, storage);
316
- if (stored === null) {
317
- return fail(
318
- 503,
319
- "The storage already used by this account could not be measured; nothing was uploaded. Retry shortly.",
320
- "storage_limit_unavailable"
321
- );
322
- }
323
- const replaced = replacesKey === null ? 0 : (await bucket.head(replacesKey))?.size ?? 0;
324
- const used = stored - replaced;
325
- if (used + incomingBytes <= limit) return null;
326
- return fail(409, storageQuotaMessage(incomingBytes, used, limit), "storage_quota_exceeded", {
327
- usedBytes: used,
328
- limitBytes: limit
329
- });
330
- }
331
- async function admitAssembledObject(bucket, storage, key, objectBytes) {
332
- if (!storage) return null;
333
- const limit = await storage.limitBytes();
334
- if (limit === null) {
335
- console.error("[files:quota] limit unavailable at complete; keeping", key);
336
- return null;
337
- }
338
- const stored = await allocationUsage(bucket, storage);
339
- if (stored === null) {
340
- console.error("[files:quota] account usage unmeasurable at complete; keeping", key);
341
- return null;
342
- }
343
- if (stored <= limit) return null;
344
- await bucket.delete(key).catch(() => void 0);
345
- return fail(
346
- 409,
347
- `${storageQuotaMessage(objectBytes, stored - objectBytes, limit)} The assembled upload was not kept.`,
348
- "storage_quota_exceeded",
349
- { usedBytes: stored - objectBytes, limitBytes: limit }
350
- );
351
- }
352
- function anchorKey(raw, prefix) {
746
+ function anchorKey(raw, prefix, excludedPrefixes) {
353
747
  const safe = sanitizeSubpath(raw);
354
- if (safe === null) return fail(400, "Invalid key: traversal not allowed", "invalid_key");
355
- if (!safe) return fail(400, "Invalid key: empty", "invalid_key");
356
- return { key: `${prefix}${safe}` };
748
+ if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
749
+ if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
750
+ const key = `${prefix}${safe}`;
751
+ return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
357
752
  }
358
753
  var VERB_ROUTES = /* @__PURE__ */ new Set([
359
754
  "POST upload",
@@ -367,7 +762,7 @@ function isFilesVerbPath(method, subpath) {
367
762
  }
368
763
  function resolveMimeType(raw) {
369
764
  const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
370
- return DANGEROUS_MIME_TYPES.has(mimeType) ? fail(415, activeContentMessage(mimeType), "active_content") : { mimeType };
765
+ return mimeType;
371
766
  }
372
767
  function r2ErrorCode(error) {
373
768
  const message = error instanceof Error ? error.message : String(error);
@@ -377,7 +772,7 @@ function r2ErrorCode(error) {
377
772
  function multipartFailure(error) {
378
773
  switch (r2ErrorCode(error)) {
379
774
  case 10024:
380
- return fail(
775
+ return fail2(
381
776
  404,
382
777
  "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
383
778
  "unknown_upload"
@@ -387,7 +782,7 @@ function multipartFailure(error) {
387
782
  case 10025:
388
783
  // InvalidPart — a named part was never uploaded
389
784
  case 10048:
390
- return fail(
785
+ return fail2(
391
786
  400,
392
787
  "The parts do not assemble: every part except the last must be exactly the size the upload advertised, and each must be uploaded before it is named.",
393
788
  "bad_parts"
@@ -395,11 +790,15 @@ function multipartFailure(error) {
395
790
  case 10012:
396
791
  // MetadataTooLarge
397
792
  case 10020:
398
- return fail(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
793
+ return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
399
794
  default: {
400
795
  const message = error instanceof Error ? error.message : String(error);
401
796
  console.error("[files:multipart] R2 error:", message);
402
- return fail(503, "The files service could not complete that upload. Retry shortly.", "r2_error");
797
+ return fail2(
798
+ 503,
799
+ "The files service could not complete that upload. Retry shortly.",
800
+ "r2_error"
801
+ );
403
802
  }
404
803
  }
405
804
  }
@@ -412,73 +811,79 @@ function createScopedR2Handler(config) {
412
811
  } catch {
413
812
  return Response.json(
414
813
  { error: "Invalid path encoding" },
415
- { status: 400, headers: CORS_HEADERS }
814
+ { status: 400, headers: CORS_HEADERS2 }
416
815
  );
417
816
  }
418
817
  const subpath = sanitizeSubpath(subpathRaw);
419
818
  if (subpath === null) {
420
819
  return Response.json(
421
820
  { error: "Invalid path: traversal not allowed" },
422
- { status: 400, headers: CORS_HEADERS }
821
+ { status: 400, headers: CORS_HEADERS2 }
423
822
  );
424
823
  }
425
824
  const scope = url.searchParams.get("scope") || "self";
426
825
  if (scope !== "self" && scope !== "app") {
427
- return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS });
826
+ return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS2 });
428
827
  }
429
828
  const result = config.resolvePrefix(scope, { userId: auth.userId, url });
430
829
  if (!result.prefix) {
431
830
  const errorMsg = result.error ?? "Invalid scope";
432
831
  const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
433
- return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS });
832
+ return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS2 });
434
833
  }
435
834
  const prefix = result.prefix;
835
+ const excludedPrefixes = result.excludedPrefixes ?? [];
436
836
  if (isFilesVerbPath(request.method, subpath)) {
437
837
  if (requireAuthForMutations && !auth.userId) {
438
838
  await request.body?.cancel().catch(() => void 0);
439
- return fail(401, "Authentication required", "unauthorized");
839
+ return fail2(401, "Authentication required", "unauthorized");
440
840
  }
441
- return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
841
+ return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
442
842
  }
443
843
  if (!subpath && request.method === "GET") {
444
- return handleList(bucket, prefix, url, scope, auth);
844
+ return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
445
845
  }
446
846
  if (subpath && request.method === "GET") {
447
- if (!isKeyWithinPrefix(subpath, prefix)) {
847
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
448
848
  return Response.json(
449
849
  { error: "Access denied: key outside scope" },
450
- { status: 403, headers: CORS_HEADERS }
850
+ { status: 403, headers: CORS_HEADERS2 }
451
851
  );
452
852
  }
453
- return handleDownload(bucket, subpath, scope);
853
+ return handleDownload(request, bucket, subpath, scope);
454
854
  }
455
855
  if (subpath && request.method === "DELETE") {
456
856
  if (requireAuthForMutations && !auth.userId) {
457
857
  return Response.json(
458
858
  { error: "Authentication required" },
459
- { status: 401, headers: CORS_HEADERS }
859
+ { status: 401, headers: CORS_HEADERS2 }
460
860
  );
461
861
  }
462
- if (!isKeyWithinPrefix(subpath, prefix)) {
862
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
463
863
  return Response.json(
464
864
  { error: "Access denied: key outside scope" },
465
- { status: 403, headers: CORS_HEADERS }
865
+ { status: 403, headers: CORS_HEADERS2 }
466
866
  );
467
867
  }
468
868
  const existed = await bucket.head(subpath) !== null;
469
- if (existed) await bucket.delete(subpath);
869
+ if (existed) {
870
+ await bucket.delete(subpath);
871
+ await markQuotaDirty(bucket, auth.storage);
872
+ }
470
873
  return Response.json(
471
874
  { success: true, deleted: existed ? subpath : null, existed },
472
- { headers: CORS_HEADERS }
875
+ { headers: CORS_HEADERS2 }
473
876
  );
474
877
  }
475
- return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS });
878
+ return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS2 });
476
879
  };
477
880
  }
478
- async function handleUpload(request, url, bucket, prefix, auth, scope) {
881
+ async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
479
882
  try {
480
- const declared = overRequestBound(Number(request.headers.get("content-length")), 8 * 1024);
481
- if (declared) return declared;
883
+ const declaredBytes = declaredLength(request, "File uploads");
884
+ if (declaredBytes instanceof Response) return declaredBytes;
885
+ const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
886
+ if (tooLargeRequest) return tooLargeRequest;
482
887
  const contentType = request.headers.get("content-type") || "";
483
888
  let fileData;
484
889
  let fileName;
@@ -486,7 +891,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
486
891
  if (contentType.includes("multipart/form-data")) {
487
892
  const formData = await request.formData();
488
893
  const file = formData.get("file");
489
- if (!file) return fail(400, "No file provided", "bad_request");
894
+ if (!file) return fail2(400, "No file provided", "bad_request");
490
895
  const tooBig = overRequestBound(file.size);
491
896
  if (tooBig) return tooBig;
492
897
  fileData = await file.arrayBuffer();
@@ -494,7 +899,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
494
899
  rawMimeType = file.type || "application/octet-stream";
495
900
  } else {
496
901
  const body = await request.json();
497
- if (!body.data || !body.name) return fail(400, "Missing data or name", "bad_request");
902
+ if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
498
903
  const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
499
904
  const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
500
905
  if (tooBig) return tooBig;
@@ -502,43 +907,75 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
502
907
  fileName = body.name;
503
908
  rawMimeType = body.mimeType || "application/octet-stream";
504
909
  }
505
- const mime = resolveMimeType(rawMimeType);
506
- if (mime instanceof Response) return mime;
507
- const located = locateUpload(url, prefix, fileName);
910
+ const mimeType = resolveMimeType(rawMimeType);
911
+ const located = locateUpload(url, prefix, excludedPrefixes, fileName);
508
912
  if (located instanceof Response) return located;
509
913
  const replacesKey = url.searchParams.get("key") === null ? null : located.key;
510
- const overQuota = await admitStorage(bucket, auth.storage, fileData.byteLength, replacesKey);
511
- if (overQuota) return overQuota;
512
- await bucket.put(located.key, fileData, {
513
- httpMetadata: { contentType: mime.mimeType },
514
- customMetadata: fileMetadata(fileName, auth.userId)
515
- });
516
- return storedFileJson(located.key, fileName, url, scope);
914
+ const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
915
+ const replacedBytes = replaced?.size ?? 0;
916
+ const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
917
+ if (admission instanceof Response) return admission;
918
+ try {
919
+ const written = await bucket.put(located.key, fileData, {
920
+ // Replacement credit is valid only while the exact object that earned
921
+ // it still exists. A concurrent delete or overwrite loses this CAS and
922
+ // retries from a fresh head instead of landing uncounted bytes.
923
+ ...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
924
+ httpMetadata: { contentType: mimeType },
925
+ customMetadata: fileMetadata(fileName, auth.userId)
926
+ });
927
+ if (!written) {
928
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
929
+ return fail2(
930
+ 409,
931
+ "The file changed while this upload was being admitted. Retry the upload.",
932
+ "write_conflict"
933
+ );
934
+ }
935
+ } catch (error) {
936
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
937
+ throw error;
938
+ }
939
+ await releaseQuotaMarker(
940
+ bucket,
941
+ auth.storage,
942
+ admission.markerKey,
943
+ admission.markerKey !== null || fileData.byteLength < replacedBytes
944
+ );
945
+ return storedFileJson(located.key, prefix, fileName, url, scope);
517
946
  } catch (err) {
518
947
  const msg = err instanceof Error ? err.message : "Upload failed";
519
948
  console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
520
- return fail(500, msg, "upload_failed");
949
+ return fail2(500, msg, "upload_failed");
521
950
  }
522
951
  }
523
- function locateUpload(url, prefix, fileName) {
952
+ function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
524
953
  const requested = url.searchParams.get("key");
525
- const subkey = requested ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`;
526
- const anchored = anchorKey(subkey, prefix);
954
+ const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
955
+ const anchored = anchorKey(subkey, prefix, excludedPrefixes);
527
956
  return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
528
957
  }
529
- function fileMetadata(fileName, userId) {
958
+ function fileMetadata(fileName, userId, reservationId, declaredBytes) {
530
959
  return {
531
960
  originalName: fileName,
532
961
  ...userId ? { uploadedBy: userId } : {},
962
+ ...reservationId ? { reservationId } : {},
963
+ ...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
533
964
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
534
965
  };
535
966
  }
536
- function storedFileJson(key, name, url, scope) {
967
+ function storedFileJson(key, prefix, name, url, scope) {
537
968
  const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
538
969
  fileUrl.searchParams.set("scope", scope);
539
970
  return Response.json(
540
- { success: true, key, url: fileUrl.toString(), name },
541
- { headers: CORS_HEADERS }
971
+ {
972
+ success: true,
973
+ key,
974
+ relativeKey: scopeRelativeKey(key, prefix),
975
+ url: fileUrl.toString(),
976
+ name
977
+ },
978
+ { headers: CORS_HEADERS2 }
542
979
  );
543
980
  }
544
981
  var MAX_CONTROL_BODY_BYTES = 64 * 1024;
@@ -546,24 +983,61 @@ async function readControlJson(request) {
546
983
  const declared = declaredLength(request, "This request");
547
984
  if (declared instanceof Response) return declared;
548
985
  if (declared > MAX_CONTROL_BODY_BYTES) {
549
- return fail(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
986
+ return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
550
987
  }
551
988
  let parsed;
552
989
  try {
553
990
  parsed = await request.json();
554
991
  } catch {
555
- return fail(400, "Body must be JSON", "bad_request");
992
+ return fail2(400, "Body must be JSON", "bad_request");
556
993
  }
557
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail(400, "Body must be a JSON object", "bad_request");
994
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
558
995
  }
559
- function resolveSession(url, prefix) {
996
+ var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
997
+ function resolveSession(url, prefix, excludedPrefixes) {
560
998
  const uploadKey = url.searchParams.get("uploadKey");
561
999
  const uploadId = url.searchParams.get("uploadId");
562
- if (!uploadKey || !uploadId) {
563
- return fail(400, "uploadKey and uploadId are required", "bad_request");
1000
+ const reservationId = url.searchParams.get("reservationId");
1001
+ if (!uploadKey || !uploadId || !reservationId) {
1002
+ return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
1003
+ }
1004
+ if (!REQUEST_ID_RE.test(reservationId)) {
1005
+ return fail2(400, "reservationId is invalid", "bad_request");
564
1006
  }
565
- const anchored = anchorKey(uploadKey, prefix);
566
- return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
1007
+ const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
1008
+ return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
1009
+ }
1010
+ function reservationMatches(reservation, session) {
1011
+ return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
1012
+ }
1013
+ function multipartSessionJson(uploadId, uploadKey, reservationId) {
1014
+ return Response.json(
1015
+ {
1016
+ uploadId,
1017
+ uploadKey,
1018
+ reservationId,
1019
+ partSize: UPLOAD_PART_BYTES,
1020
+ maxParts: MAX_UPLOAD_PARTS,
1021
+ maxBytes: MAX_APP_FILE_BYTES
1022
+ },
1023
+ { headers: CORS_HEADERS2 }
1024
+ );
1025
+ }
1026
+ function multipartPartCount(declaredBytes) {
1027
+ return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
1028
+ }
1029
+ function expectedMultipartPartBytes(declaredBytes, partNumber) {
1030
+ const partCount = multipartPartCount(declaredBytes);
1031
+ if (partNumber < 1 || partNumber > partCount) return null;
1032
+ return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
1033
+ }
1034
+ function multipartLayoutError(declaredBytes) {
1035
+ return fail2(
1036
+ 400,
1037
+ `This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
1038
+ "bad_part_layout",
1039
+ { partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
1040
+ );
567
1041
  }
568
1042
  async function streamPart(upload, partNumber, body, expectedBytes) {
569
1043
  let observedBytes = 0;
@@ -586,57 +1060,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
586
1060
  }
587
1061
  return { ok: true, part: uploaded.value };
588
1062
  }
589
- async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
1063
+ async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
590
1064
  if (subpath === "multipart" && request.method === "POST") {
591
- return multipartInit(request, url, bucket, prefix, auth);
1065
+ return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
1066
+ }
1067
+ if (subpath === "multipart/part") {
1068
+ return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
592
1069
  }
593
- if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
594
1070
  if (subpath === "multipart/complete") {
595
- return multipartComplete(request, url, bucket, prefix, auth, scope);
1071
+ return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
596
1072
  }
597
- return multipartAbort(url, bucket, prefix);
1073
+ return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
598
1074
  }
599
- async function multipartInit(request, url, bucket, prefix, auth) {
1075
+ async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
600
1076
  const body = await readControlJson(request);
601
1077
  if (body instanceof Response) return body;
602
1078
  const size = body.size;
603
- if (!Number.isSafeInteger(size) || size < 0) {
604
- return fail(400, "size must be a non-negative integer number of bytes", "bad_request");
1079
+ if (!Number.isSafeInteger(size) || size <= 0) {
1080
+ return fail2(400, "size must be a positive integer number of bytes", "bad_request");
605
1081
  }
606
1082
  const tooBig = overCeiling(size);
607
1083
  if (tooBig) return tooBig;
608
1084
  const name = typeof body.name === "string" ? body.name.trim() : "";
609
- if (!name) return fail(400, "name is required", "bad_request");
610
- const mime = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
611
- if (mime instanceof Response) return mime;
612
- const located = locateUpload(url, prefix, name);
1085
+ if (!name) return fail2(400, "name is required", "bad_request");
1086
+ const requestId = typeof body.requestId === "string" ? body.requestId : "";
1087
+ if (!REQUEST_ID_RE.test(requestId)) {
1088
+ return fail2(
1089
+ 400,
1090
+ "requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
1091
+ "bad_request"
1092
+ );
1093
+ }
1094
+ const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
1095
+ const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
613
1096
  if (located instanceof Response) return located;
614
- const replacesKey = url.searchParams.get("key") === null ? null : located.key;
615
- const overQuota = await admitStorage(bucket, auth.storage, size, replacesKey);
616
- if (overQuota) return overQuota;
1097
+ const completed = await bucket.head(located.key);
1098
+ if (completed?.customMetadata?.reservationId === requestId) {
1099
+ return fail2(
1100
+ 409,
1101
+ "requestId already completed an upload; start the new upload with a fresh requestId.",
1102
+ "request_id_completed"
1103
+ );
1104
+ }
1105
+ const existing = await readMultipartReservation(bucket, auth.storage, requestId);
1106
+ if (existing === "invalid") {
1107
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
1108
+ }
1109
+ if (existing) {
1110
+ const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
1111
+ return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
1112
+ 409,
1113
+ "requestId already belongs to a different multipart upload.",
1114
+ "request_id_conflict"
1115
+ );
1116
+ }
617
1117
  let upload;
618
1118
  try {
619
1119
  upload = await bucket.createMultipartUpload(located.key, {
620
- httpMetadata: { contentType: mime.mimeType },
621
- customMetadata: fileMetadata(name, auth.userId)
1120
+ httpMetadata: { contentType: mimeType },
1121
+ customMetadata: fileMetadata(name, auth.userId, requestId, size)
622
1122
  });
623
1123
  } catch (error) {
624
1124
  return multipartFailure(error);
625
1125
  }
626
- return Response.json(
627
- {
1126
+ const marker = auth.storage ? {
1127
+ key: multipartMarkerKey(auth.storage, requestId),
1128
+ body: JSON.stringify({
1129
+ version: 1,
1130
+ reservationId: requestId,
628
1131
  uploadId: upload.uploadId,
629
- // Echoed back on every later call. Relative to the app, exactly like
630
- // `?key=`, so the mount that anchors keys anchors this one too.
1132
+ objectKey: located.key,
631
1133
  uploadKey: located.subkey,
632
- partSize: UPLOAD_PART_BYTES,
633
- maxParts: MAX_UPLOAD_PARTS,
634
- maxBytes: MAX_APP_FILE_BYTES
635
- },
636
- { headers: CORS_HEADERS }
637
- );
1134
+ admitted: false,
1135
+ declaredBytes: size
1136
+ }),
1137
+ metadata: {
1138
+ quotaKind: "multipart",
1139
+ active: "false"
1140
+ }
1141
+ } : void 0;
1142
+ const reservedBytes = Math.max(size, 5 * 1024 * 1024);
1143
+ const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
1144
+ if (admission instanceof Response) {
1145
+ await upload.abort().catch(() => void 0);
1146
+ if (admission.status === 409 && auth.storage) {
1147
+ const winner = await readMultipartReservation(bucket, auth.storage, requestId);
1148
+ if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
1149
+ return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
1150
+ }
1151
+ }
1152
+ return admission;
1153
+ }
1154
+ if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
1155
+ await upload.abort().catch(() => void 0);
1156
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
1157
+ return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
1158
+ }
1159
+ return multipartSessionJson(upload.uploadId, located.subkey, requestId);
638
1160
  }
639
- async function multipartPart(request, url, bucket, prefix) {
1161
+ async function abandonMultipart(bucket, session, auth, reservation) {
1162
+ let result = "aborted";
1163
+ try {
1164
+ await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
1165
+ } catch (error) {
1166
+ if (r2ErrorCode(error) !== 10024) {
1167
+ console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
1168
+ return "failed";
1169
+ }
1170
+ result = "absent";
1171
+ }
1172
+ if (reservation) {
1173
+ if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
1174
+ }
1175
+ return result;
1176
+ }
1177
+ async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
640
1178
  const refuse = async (response) => {
641
1179
  await request.body?.cancel().catch(() => void 0);
642
1180
  return response;
@@ -648,16 +1186,60 @@ async function multipartPart(request, url, bucket, prefix) {
648
1186
  const partNumber = Number(url.searchParams.get("partNumber"));
649
1187
  if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
650
1188
  return refuse(
651
- fail(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
1189
+ fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
652
1190
  );
653
1191
  }
654
- const session = resolveSession(url, prefix);
1192
+ const session = resolveSession(url, prefix, excludedPrefixes);
655
1193
  if (session instanceof Response) return refuse(session);
656
- if (!request.body) return fail(400, "A part body is required", "bad_request");
1194
+ if (!request.body) return fail2(400, "A part body is required", "bad_request");
1195
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1196
+ if (reservation === "invalid") {
1197
+ return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
1198
+ }
1199
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1200
+ return refuse(
1201
+ fail2(
1202
+ 404,
1203
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
1204
+ "unknown_upload"
1205
+ )
1206
+ );
1207
+ }
1208
+ if (auth.storage && !reservation) {
1209
+ return refuse(
1210
+ fail2(
1211
+ 404,
1212
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
1213
+ "unknown_upload"
1214
+ )
1215
+ );
1216
+ }
1217
+ if (reservation) {
1218
+ const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
1219
+ if (expectedBytes === null || declaredBytes !== expectedBytes) {
1220
+ return refuse(multipartLayoutError(reservation.declaredBytes));
1221
+ }
1222
+ }
1223
+ const refreshed = await refreshMultipartReservation(
1224
+ bucket,
1225
+ auth.storage,
1226
+ session.reservationId,
1227
+ session.uploadId,
1228
+ session.key
1229
+ );
1230
+ if (auth.storage && (refreshed === null || refreshed === "invalid")) {
1231
+ return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
1232
+ }
1233
+ if (refreshed === "busy") {
1234
+ return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
1235
+ }
657
1236
  const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
658
1237
  const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
659
1238
  if (!stored.ok) {
660
- return stored.lengthMismatch ? fail(
1239
+ if (r2ErrorCode(stored.error) === 10024 && reservation) {
1240
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1241
+ }
1242
+ return stored.lengthMismatch ? fail2(
661
1243
  400,
662
1244
  `Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
663
1245
  "bad_request"
@@ -665,29 +1247,98 @@ async function multipartPart(request, url, bucket, prefix) {
665
1247
  }
666
1248
  return Response.json(
667
1249
  { partNumber: stored.part.partNumber, etag: stored.part.etag },
668
- { headers: CORS_HEADERS }
1250
+ { headers: CORS_HEADERS2 }
669
1251
  );
670
1252
  }
671
- async function multipartComplete(request, url, bucket, prefix, auth, scope) {
1253
+ async function completedReservationObject(bucket, session) {
1254
+ const object = await bucket.head(session.key);
1255
+ return object?.customMetadata?.reservationId === session.reservationId ? object : null;
1256
+ }
1257
+ async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix) {
1258
+ const declaredMetadata = Number(object.customMetadata?.declaredBytes);
1259
+ const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
1260
+ const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
1261
+ 400,
1262
+ `The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
1263
+ "declared_size_exceeded"
1264
+ ) : null);
1265
+ if (refusal) {
1266
+ let remaining;
1267
+ try {
1268
+ const current = await bucket.head(session.key);
1269
+ if (current?.etag === object.etag) await bucket.delete(session.key);
1270
+ remaining = await bucket.head(session.key);
1271
+ } catch (error) {
1272
+ console.error("[files:multipart] invalid object cleanup:", error);
1273
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
1274
+ }
1275
+ if (remaining?.etag === object.etag) {
1276
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
1277
+ }
1278
+ if (reservation) {
1279
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1280
+ if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
1281
+ }
1282
+ return refusal;
1283
+ }
1284
+ if (reservation) {
1285
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1286
+ if (!cleaned) {
1287
+ return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
1288
+ }
1289
+ }
1290
+ const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
1291
+ return storedFileJson(session.key, prefix, name, url, scope);
1292
+ }
1293
+ async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
672
1294
  const body = await readControlJson(request);
673
1295
  if (body instanceof Response) return body;
674
- const session = resolveSession(url, prefix);
1296
+ const session = resolveSession(url, prefix, excludedPrefixes);
675
1297
  if (session instanceof Response) return session;
1298
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1299
+ if (reservation === "invalid") {
1300
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
1301
+ }
1302
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1303
+ return fail2(
1304
+ 404,
1305
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
1306
+ "unknown_upload"
1307
+ );
1308
+ }
1309
+ if (auth.storage && !reservation) {
1310
+ const completed = await completedReservationObject(bucket, session);
1311
+ if (completed) {
1312
+ return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope, prefix);
1313
+ }
1314
+ await abandonMultipart(bucket, session, auth, null);
1315
+ return quotaUnavailable("This upload reservation is missing or does not match.");
1316
+ }
676
1317
  const raw = body.parts;
677
1318
  if (!Array.isArray(raw) || raw.length === 0) {
678
- return fail(400, "parts must be a non-empty array", "bad_request");
1319
+ return fail2(400, "parts must be a non-empty array", "bad_request");
679
1320
  }
680
1321
  if (raw.length > MAX_UPLOAD_PARTS) {
681
- return fail(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
1322
+ return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
1323
+ }
1324
+ if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
1325
+ return multipartLayoutError(reservation.declaredBytes);
682
1326
  }
683
1327
  const parts = [];
684
1328
  for (let index = 0; index < raw.length; index++) {
685
1329
  const { partNumber, etag } = raw[index] ?? {};
686
- if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
687
- return fail(400, `parts[${index}].partNumber must be a positive integer`, "bad_request");
1330
+ if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
1331
+ return fail2(
1332
+ 400,
1333
+ `parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
1334
+ "bad_request"
1335
+ );
1336
+ }
1337
+ if (reservation && partNumber !== index + 1) {
1338
+ return multipartLayoutError(reservation.declaredBytes);
688
1339
  }
689
1340
  if (typeof etag !== "string" || !etag) {
690
- return fail(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
1341
+ return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
691
1342
  }
692
1343
  parts.push({ partNumber, etag });
693
1344
  }
@@ -695,39 +1346,87 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
695
1346
  try {
696
1347
  object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
697
1348
  } catch (error) {
1349
+ const completed = await completedReservationObject(bucket, session);
1350
+ if (completed) {
1351
+ return finishCompletedMultipart(
1352
+ bucket,
1353
+ session,
1354
+ reservation,
1355
+ completed,
1356
+ auth,
1357
+ url,
1358
+ scope,
1359
+ prefix
1360
+ );
1361
+ }
1362
+ if (r2ErrorCode(error) === 10024 && reservation) {
1363
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1364
+ }
698
1365
  return multipartFailure(error);
699
1366
  }
700
- const tooBig = overCeiling(object.size);
701
- if (tooBig) {
702
- await bucket.delete(session.key).catch(() => void 0);
703
- return tooBig;
704
- }
705
- const overQuota = await admitAssembledObject(bucket, auth.storage, session.key, object.size);
706
- if (overQuota) return overQuota;
707
- const name = object.customMetadata?.originalName ?? session.key.split("/").pop() ?? "file";
708
- return storedFileJson(session.key, name, url, scope);
1367
+ return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix);
709
1368
  }
710
- async function multipartAbort(url, bucket, prefix) {
711
- const session = resolveSession(url, prefix);
1369
+ async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
1370
+ const session = resolveSession(url, prefix, excludedPrefixes);
712
1371
  if (session instanceof Response) return session;
713
- try {
714
- await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
715
- } catch (error) {
716
- console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
717
- return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS });
1372
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1373
+ if (reservation === "invalid") {
1374
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
718
1375
  }
719
- return Response.json({ success: true, aborted: true }, { headers: CORS_HEADERS });
1376
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1377
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
1378
+ }
1379
+ const result = await abandonMultipart(bucket, session, auth, reservation || null);
1380
+ return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS2 });
720
1381
  }
721
- async function handleList(bucket, prefix, url, scope, auth) {
1382
+ async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
722
1383
  const userPrefix = url.searchParams.get("prefix") || "";
723
1384
  const listPrefix = `${prefix}${userPrefix}`;
724
- const limit = parseInt(url.searchParams.get("limit") || "100", 10);
725
- const listed = await bucket.list({ prefix: listPrefix, limit });
726
- const files = listed.objects.map((obj) => {
1385
+ const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
1386
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
1387
+ const requestCursor = url.searchParams.get("cursor");
1388
+ if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
1389
+ return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS2 });
1390
+ }
1391
+ const withMetadata = (options) => ({ ...options, include: ["customMetadata"] });
1392
+ const listed = await bucket.list(
1393
+ withMetadata({
1394
+ prefix: listPrefix,
1395
+ limit,
1396
+ ...requestCursor ? { cursor: requestCursor } : {}
1397
+ })
1398
+ );
1399
+ const visible = listed.objects.filter(
1400
+ (object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
1401
+ );
1402
+ const seen = new Set(visible.map((object) => object.key));
1403
+ let frontier = listed;
1404
+ let frontierKey = listed.objects.at(-1)?.key;
1405
+ for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix)).sort()) {
1406
+ if (!frontier.truncated || visible.length >= limit) break;
1407
+ const excludedEnd = `${excluded}\u{10FFFF}`;
1408
+ const after = await bucket.list(
1409
+ withMetadata({
1410
+ prefix: listPrefix,
1411
+ startAfter: frontierKey && frontierKey > excludedEnd ? frontierKey : excludedEnd,
1412
+ limit: limit - visible.length
1413
+ })
1414
+ );
1415
+ for (const object of after.objects) {
1416
+ if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
1417
+ seen.add(object.key);
1418
+ visible.push(object);
1419
+ }
1420
+ }
1421
+ frontierKey = after.objects.at(-1)?.key ?? frontierKey;
1422
+ frontier = after;
1423
+ }
1424
+ const files = visible.slice(0, limit).map((obj) => {
727
1425
  const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
728
1426
  fileUrl.searchParams.set("scope", scope);
729
1427
  return {
730
1428
  key: obj.key,
1429
+ relativeKey: scopeRelativeKey(obj.key, prefix),
731
1430
  size: obj.size,
732
1431
  uploaded: obj.uploaded.toISOString(),
733
1432
  url: fileUrl.toString(),
@@ -735,32 +1434,46 @@ async function handleList(bucket, prefix, url, scope, auth) {
735
1434
  };
736
1435
  });
737
1436
  let storage;
738
- if (auth.storage) {
739
- const usedBytes = await allocationUsage(bucket, auth.storage);
1437
+ if (auth.storage && auth.includeStorageUsage) {
1438
+ const usedBytes = await storageUsage(bucket, auth.storage);
740
1439
  if (usedBytes !== null) storage = { usedBytes };
741
1440
  const limitBytes = await auth.storage.limitBytes();
742
1441
  if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
743
1442
  }
744
1443
  return Response.json(
745
- { files, truncated: listed.truncated, ...storage ? { storage } : {} },
746
- { headers: CORS_HEADERS }
1444
+ {
1445
+ files,
1446
+ truncated: frontier.truncated,
1447
+ ...frontier.truncated ? { cursor: frontier.cursor } : {},
1448
+ ...storage ? { storage } : {}
1449
+ },
1450
+ { headers: CORS_HEADERS2 }
747
1451
  );
748
1452
  }
749
- async function handleDownload(bucket, key, scope) {
1453
+ async function handleDownload(request, bucket, key, scope) {
750
1454
  const object = await bucket.get(key);
751
1455
  if (!object) {
752
- return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS });
1456
+ return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS2 });
753
1457
  }
754
1458
  const headers = new Headers();
755
- headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
1459
+ const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
1460
+ const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
1461
+ headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
756
1462
  headers.set("X-Content-Type-Options", "nosniff");
1463
+ headers.set("ETag", object.httpEtag);
757
1464
  headers.set(
758
1465
  "Cache-Control",
759
- scope === "app" ? "public, max-age=31536000, immutable" : "private, no-store"
1466
+ scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
760
1467
  );
761
1468
  headers.set("Access-Control-Allow-Origin", "*");
762
- if (object.customMetadata?.originalName) {
763
- headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
1469
+ const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
1470
+ headers.set(
1471
+ "Content-Disposition",
1472
+ contentDisposition(originalName, mustDownload ? "attachment" : "inline")
1473
+ );
1474
+ if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
1475
+ if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
1476
+ return new Response(null, { status: 304, headers });
764
1477
  }
765
1478
  return new Response(object.body, { headers });
766
1479
  }
@@ -2274,6 +2987,9 @@ var MSG = {
2274
2987
  CRON_PAUSE: "cron.pause",
2275
2988
  CRON_RESUME: "cron.resume",
2276
2989
  CRON_STATUS: "cron.status",
2990
+ // Per-mutation receipt, sent only to the socket whose trigger/pause/
2991
+ // resume carried a `requestId` — frames without one stay fire-and-forget.
2992
+ CRON_ACK: "cron.ack",
2277
2993
  // ---- JobRoom ---------------------------------------------------------
2278
2994
  // Background-job queue with durable, alarm-driven execution. See
2279
2995
  // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
@@ -2775,6 +3491,14 @@ var serverBuild = {
2775
3491
  cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
2776
3492
  cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
2777
3493
  cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
3494
+ cronAckSuccess: (requestId, taskName) => ({
3495
+ type: MSG.CRON_ACK,
3496
+ payload: { requestId, taskName, ok: true }
3497
+ }),
3498
+ cronAckFailure: (requestId, reason, error, taskName) => ({
3499
+ type: MSG.CRON_ACK,
3500
+ payload: { requestId, taskName, ok: false, reason, error }
3501
+ }),
2778
3502
  // Jobs
2779
3503
  jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
2780
3504
  jobEnqueued: (job, requestId) => ({
@@ -3756,7 +4480,7 @@ async function handleSetRole(ctx, ws, attachment, payload) {
3756
4480
 
3757
4481
  // src/server/handlers/tools-api.ts
3758
4482
  import * as Y3 from "yjs";
3759
- var CORS_HEADERS2 = {
4483
+ var CORS_HEADERS3 = {
3760
4484
  "Access-Control-Allow-Origin": "*",
3761
4485
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3762
4486
  "Access-Control-Allow-Headers": "Content-Type, X-User-Id, X-App-Action",
@@ -3764,23 +4488,23 @@ var CORS_HEADERS2 = {
3764
4488
  };
3765
4489
  async function handleToolsRequest(ctx, request, path) {
3766
4490
  if (request.method === "OPTIONS") {
3767
- return new Response(null, { headers: CORS_HEADERS2 });
4491
+ return new Response(null, { headers: CORS_HEADERS3 });
3768
4492
  }
3769
4493
  if (path === "tools/list") {
3770
- return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS2 });
4494
+ return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS3 });
3771
4495
  }
3772
4496
  if (path.startsWith("tools/describe/")) {
3773
4497
  const toolName = path.replace("tools/describe/", "");
3774
4498
  const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
3775
4499
  if (!tool2) {
3776
- return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS2 });
4500
+ return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS3 });
3777
4501
  }
3778
- return Response.json({ tool: tool2 }, { headers: CORS_HEADERS2 });
4502
+ return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
3779
4503
  }
3780
4504
  if (path.startsWith("tools/execute") && request.method === "POST") {
3781
4505
  return handleToolExecute(ctx, request);
3782
4506
  }
3783
- return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS2 });
4507
+ return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
3784
4508
  }
3785
4509
  async function handleToolExecute(ctx, request) {
3786
4510
  let body;
@@ -3789,14 +4513,14 @@ async function handleToolExecute(ctx, request) {
3789
4513
  } catch {
3790
4514
  return Response.json(
3791
4515
  { success: false, error: "Invalid JSON body" },
3792
- { status: 400, headers: CORS_HEADERS2 }
4516
+ { status: 400, headers: CORS_HEADERS3 }
3793
4517
  );
3794
4518
  }
3795
4519
  const { tool: tool2, params = {} } = body;
3796
4520
  if (!tool2) {
3797
4521
  return Response.json(
3798
4522
  { success: false, error: 'Missing "tool" in request body' },
3799
- { status: 400, headers: CORS_HEADERS2 }
4523
+ { status: 400, headers: CORS_HEADERS3 }
3800
4524
  );
3801
4525
  }
3802
4526
  const userId = request.headers.get("x-user-id") ?? "";
@@ -3813,7 +4537,7 @@ async function handleToolExecute(ctx, request) {
3813
4537
  if (tool2.startsWith("yjs.")) {
3814
4538
  const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
3815
4539
  const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
3816
- return Response.json(result2, { status: status2, headers: CORS_HEADERS2 });
4540
+ return Response.json(result2, { status: status2, headers: CORS_HEADERS3 });
3817
4541
  }
3818
4542
  const recordCtx = {
3819
4543
  sql: ctx.sql,
@@ -3824,7 +4548,7 @@ async function handleToolExecute(ctx, request) {
3824
4548
  };
3825
4549
  const result = await executeTool2(recordCtx, tool2, params, userId, userRole, isAppAction);
3826
4550
  const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
3827
- return Response.json(result, { status, headers: CORS_HEADERS2 });
4551
+ return Response.json(result, { status, headers: CORS_HEADERS3 });
3828
4552
  }
3829
4553
  async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRbac) {
3830
4554
  switch (toolName) {
@@ -4060,7 +4784,7 @@ function generateId() {
4060
4784
  }
4061
4785
 
4062
4786
  // src/server/handlers/debug-api.ts
4063
- var CORS_HEADERS3 = {
4787
+ var CORS_HEADERS4 = {
4064
4788
  "Access-Control-Allow-Origin": "*",
4065
4789
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
4066
4790
  "Access-Control-Allow-Headers": "Content-Type",
@@ -4068,7 +4792,7 @@ var CORS_HEADERS3 = {
4068
4792
  };
4069
4793
  async function handleApiRequest(ctx, request, url) {
4070
4794
  if (request.method === "OPTIONS") {
4071
- return new Response(null, { headers: CORS_HEADERS3 });
4795
+ return new Response(null, { headers: CORS_HEADERS4 });
4072
4796
  }
4073
4797
  const path = url.pathname.replace("/api/", "");
4074
4798
  if (path.startsWith("tools/")) {
@@ -4092,13 +4816,13 @@ async function handleApiRequest(ctx, request, url) {
4092
4816
  if (path === "debug/query") {
4093
4817
  return handleDebugQuery(ctx, url);
4094
4818
  }
4095
- return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
4819
+ return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS4 });
4096
4820
  }
4097
4821
  function handleDebugUserRole(ctx, url) {
4098
4822
  const email = url.searchParams.get("email");
4099
4823
  const userId = url.searchParams.get("userId");
4100
4824
  if (!email && !userId) {
4101
- return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4825
+ return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4102
4826
  }
4103
4827
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
4104
4828
  const user = userId ? users.find((u) => u.id === userId) : users.find((u) => u.email === email);
@@ -4106,22 +4830,22 @@ function handleDebugUserRole(ctx, url) {
4106
4830
  return Response.json({
4107
4831
  error: `User not found`,
4108
4832
  registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
4109
- }, { status: 404, headers: CORS_HEADERS3 });
4833
+ }, { status: 404, headers: CORS_HEADERS4 });
4110
4834
  }
4111
4835
  return Response.json({
4112
4836
  userId: user.id,
4113
4837
  email: user.email,
4114
4838
  name: user.name,
4115
4839
  role: user.role
4116
- }, { headers: CORS_HEADERS3 });
4840
+ }, { headers: CORS_HEADERS4 });
4117
4841
  }
4118
4842
  async function handleDebugSetRole(ctx, request) {
4119
4843
  const body = await request.json();
4120
4844
  if (!body.role) {
4121
- return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS3 });
4845
+ return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS4 });
4122
4846
  }
4123
4847
  if (!body.email && !body.userId) {
4124
- return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS3 });
4848
+ return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS4 });
4125
4849
  }
4126
4850
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
4127
4851
  const user = body.userId ? users.find((u) => u.id === body.userId) : users.find((u) => u.email === body.email);
@@ -4129,7 +4853,7 @@ async function handleDebugSetRole(ctx, request) {
4129
4853
  return Response.json({
4130
4854
  error: "User not found",
4131
4855
  registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
4132
- }, { status: 404, headers: CORS_HEADERS3 });
4856
+ }, { status: 404, headers: CORS_HEADERS4 });
4133
4857
  }
4134
4858
  const now = (/* @__PURE__ */ new Date()).toISOString();
4135
4859
  ctx.sql.exec(
@@ -4143,7 +4867,7 @@ async function handleDebugSetRole(ctx, request) {
4143
4867
  email: user.email,
4144
4868
  previousRole: user.role,
4145
4869
  newRole: body.role
4146
- }, { headers: CORS_HEADERS3 });
4870
+ }, { headers: CORS_HEADERS4 });
4147
4871
  }
4148
4872
  function handleDebugStatus(ctx) {
4149
4873
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
@@ -4161,7 +4885,7 @@ function handleDebugStatus(ctx) {
4161
4885
  }
4162
4886
  return Response.json(
4163
4887
  { users, schemas: schemas.map((s) => s.name), connections },
4164
- { headers: CORS_HEADERS3 }
4888
+ { headers: CORS_HEADERS4 }
4165
4889
  );
4166
4890
  }
4167
4891
  function handleDebugRecords(ctx, path) {
@@ -4180,12 +4904,12 @@ function handleDebugRecords(ctx, path) {
4180
4904
  createdAt: r._created_at
4181
4905
  };
4182
4906
  });
4183
- return Response.json({ collection, records }, { headers: CORS_HEADERS3 });
4907
+ return Response.json({ collection, records }, { headers: CORS_HEADERS4 });
4184
4908
  } catch (error) {
4185
4909
  return Response.json({
4186
4910
  error: error instanceof Error ? error.message : "Query failed",
4187
4911
  collection
4188
- }, { status: 400, headers: CORS_HEADERS3 });
4912
+ }, { status: 400, headers: CORS_HEADERS4 });
4189
4913
  }
4190
4914
  }
4191
4915
  function handleDebugQuery(ctx, url) {
@@ -4193,17 +4917,17 @@ function handleDebugQuery(ctx, url) {
4193
4917
  const userId = url.searchParams.get("userId");
4194
4918
  const role = url.searchParams.get("role");
4195
4919
  if (!collection) {
4196
- return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4920
+ return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4197
4921
  }
4198
4922
  if (!userId) {
4199
- return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4923
+ return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4200
4924
  }
4201
4925
  if (!role) {
4202
- return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4926
+ return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4203
4927
  }
4204
4928
  const user = getUser(ctx.sql, userId, ctx.schemaRegistry);
4205
4929
  if (!user) {
4206
- return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS3 });
4930
+ return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS4 });
4207
4931
  }
4208
4932
  const query = { collection };
4209
4933
  const where = {};
@@ -4222,7 +4946,7 @@ function handleDebugQuery(ctx, url) {
4222
4946
  asUser: { id: userId, name: user.name, actualRole: user.role, testingAsRole: role },
4223
4947
  recordCount: records.length,
4224
4948
  records
4225
- }, { headers: CORS_HEADERS3 });
4949
+ }, { headers: CORS_HEADERS4 });
4226
4950
  }
4227
4951
  async function handleDebugSql(ctx, request, url) {
4228
4952
  let sql;
@@ -4245,7 +4969,7 @@ async function handleDebugSql(ctx, request, url) {
4245
4969
  "DESCRIBE <table> - show table schema"
4246
4970
  ]
4247
4971
  }
4248
- }, { status: 400, headers: CORS_HEADERS3 });
4972
+ }, { status: 400, headers: CORS_HEADERS4 });
4249
4973
  }
4250
4974
  const sqlUpper = sql.trim().toUpperCase();
4251
4975
  try {
@@ -4254,7 +4978,7 @@ async function handleDebugSql(ctx, request, url) {
4254
4978
  `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
4255
4979
  );
4256
4980
  const tables = cursor2.toArray().map((r) => r.name);
4257
- return Response.json({ tables }, { headers: CORS_HEADERS3 });
4981
+ return Response.json({ tables }, { headers: CORS_HEADERS4 });
4258
4982
  }
4259
4983
  if (sqlUpper.startsWith("DESCRIBE ") || sqlUpper.startsWith(".SCHEMA ")) {
4260
4984
  const tableName = sql.trim().split(/\s+/)[1];
@@ -4269,7 +4993,7 @@ async function handleDebugSql(ctx, request, url) {
4269
4993
  primaryKey: col.pk === 1
4270
4994
  };
4271
4995
  });
4272
- return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS3 });
4996
+ return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS4 });
4273
4997
  }
4274
4998
  const cursor = ctx.sql.exec(sql, ...params);
4275
4999
  const rows = cursor.toArray();
@@ -4277,12 +5001,12 @@ async function handleDebugSql(ctx, request, url) {
4277
5001
  sql,
4278
5002
  rowCount: rows.length,
4279
5003
  rows
4280
- }, { headers: CORS_HEADERS3 });
5004
+ }, { headers: CORS_HEADERS4 });
4281
5005
  } catch (error) {
4282
5006
  return Response.json({
4283
5007
  error: error instanceof Error ? error.message : "Query failed",
4284
5008
  sql
4285
- }, { status: 400, headers: CORS_HEADERS3 });
5009
+ }, { status: 400, headers: CORS_HEADERS4 });
4286
5010
  }
4287
5011
  }
4288
5012
 
@@ -5518,40 +6242,88 @@ var CronRoom = class extends BaseRoom {
5518
6242
  }
5519
6243
  async onMessage(ws, user, message) {
5520
6244
  this.ensureInitialized();
5521
- const { type, payload } = message;
6245
+ const { type, payload = {} } = message;
5522
6246
  if (CRON_WRITE_TYPES.has(type) && !user.canWrite) {
5523
- this.sendTo(ws, {
5524
- type: MSG.ERROR,
5525
- payload: { error: "Write access denied: viewer role cannot modify cron tasks" }
6247
+ const error = "Write access denied: viewer role cannot modify cron tasks";
6248
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6249
+ this.ackMutation(ws, payload.requestId, {
6250
+ ok: false,
6251
+ taskName: payload.taskName,
6252
+ reason: "read_only",
6253
+ error
5526
6254
  });
5527
6255
  return;
5528
6256
  }
6257
+ try {
6258
+ await this.dispatchMessage(ws, type, payload);
6259
+ } catch (error) {
6260
+ try {
6261
+ this.ackMutation(ws, payload.requestId, {
6262
+ ok: false,
6263
+ taskName: payload.taskName,
6264
+ reason: "failed",
6265
+ error: error instanceof Error ? error.message : String(error)
6266
+ });
6267
+ } catch {
6268
+ }
6269
+ throw error;
6270
+ }
6271
+ }
6272
+ async dispatchMessage(ws, type, payload) {
5529
6273
  switch (type) {
5530
6274
  case MSG.CRON_TRIGGER: {
5531
6275
  const taskName = payload.taskName;
5532
6276
  if (!taskName) {
5533
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: "Missing taskName" } });
6277
+ const error = "Missing taskName";
6278
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6279
+ this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
5534
6280
  return;
5535
6281
  }
5536
- if (!await this.executeTask(taskName)) {
5537
- this.sendTo(ws, {
5538
- type: MSG.ERROR,
5539
- payload: { error: `Unknown cron task: ${taskName}` }
6282
+ const execution = await this.executeTask(taskName);
6283
+ if (!execution) {
6284
+ const error = `Unknown cron task: ${taskName}`;
6285
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6286
+ this.ackMutation(ws, payload.requestId, {
6287
+ ok: false,
6288
+ taskName,
6289
+ reason: "unknown_task",
6290
+ error
5540
6291
  });
6292
+ break;
5541
6293
  }
6294
+ this.ackMutation(
6295
+ ws,
6296
+ payload.requestId,
6297
+ execution.success ? { ok: true, taskName } : { ok: false, taskName, reason: "failed", error: execution.error }
6298
+ );
5542
6299
  break;
5543
6300
  }
5544
- case MSG.CRON_PAUSE: {
5545
- const taskName = payload.taskName;
5546
- this.sql.exec(`UPDATE cron_tasks SET paused = 1 WHERE name = ?`, taskName);
5547
- this.broadcastStatus();
5548
- break;
5549
- }
6301
+ case MSG.CRON_PAUSE:
5550
6302
  case MSG.CRON_RESUME: {
5551
6303
  const taskName = payload.taskName;
5552
- this.sql.exec(`UPDATE cron_tasks SET paused = 0 WHERE name = ?`, taskName);
5553
- this.scheduleNextAlarm();
6304
+ if (!taskName) {
6305
+ const error = "Missing taskName";
6306
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6307
+ this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
6308
+ return;
6309
+ }
6310
+ const known = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, taskName).toArray().length > 0;
6311
+ if (!known) {
6312
+ const error = `Unknown cron task: ${taskName}`;
6313
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6314
+ this.ackMutation(ws, payload.requestId, {
6315
+ ok: false,
6316
+ taskName,
6317
+ reason: "unknown_task",
6318
+ error
6319
+ });
6320
+ break;
6321
+ }
6322
+ const paused = type === MSG.CRON_PAUSE;
6323
+ this.sql.exec(`UPDATE cron_tasks SET paused = ? WHERE name = ?`, paused ? 1 : 0, taskName);
6324
+ if (!paused) this.scheduleNextAlarm();
5554
6325
  this.broadcastStatus();
6326
+ this.ackMutation(ws, payload.requestId, { ok: true, taskName });
5555
6327
  break;
5556
6328
  }
5557
6329
  case MSG.CRON_TASKS: {
@@ -5594,7 +6366,7 @@ var CronRoom = class extends BaseRoom {
5594
6366
  // ==========================================================================
5595
6367
  async executeTask(taskName) {
5596
6368
  const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
5597
- if (!taskRow) return false;
6369
+ if (!taskRow) return null;
5598
6370
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
5599
6371
  const start = Date.now();
5600
6372
  let success = true;
@@ -5630,7 +6402,7 @@ var CronRoom = class extends BaseRoom {
5630
6402
  `DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
5631
6403
  );
5632
6404
  this.broadcastStatus();
5633
- return true;
6405
+ return { taskName, startedAt, completedAt, success, durationMs, error };
5634
6406
  }
5635
6407
  // ==========================================================================
5636
6408
  // Scheduling
@@ -5700,6 +6472,21 @@ var CronRoom = class extends BaseRoom {
5700
6472
  }
5701
6473
  });
5702
6474
  }
6475
+ /**
6476
+ * Receipt for a mutation frame, addressed only to its sender. Receipts
6477
+ * are opt-in by correlation id: frames without a `requestId` keep the
6478
+ * fire-and-forget contract, so the (untyped) `requestId` from the wire
6479
+ * is the single gate here rather than a check at every call site.
6480
+ * Frames go through the shared `serverBuild` constructors so the wire
6481
+ * shape is enforced by the protocol types, not re-spelled here.
6482
+ */
6483
+ ackMutation(ws, requestId, result) {
6484
+ if (typeof requestId !== "string") return;
6485
+ this.sendTo(
6486
+ ws,
6487
+ result.ok ? serverBuild.cronAckSuccess(requestId, result.taskName) : serverBuild.cronAckFailure(requestId, result.reason, result.error, result.taskName)
6488
+ );
6489
+ }
5703
6490
  };
5704
6491
  function computeNextRunAt(task, from) {
5705
6492
  if (task.interval_minutes) {