deepspace 0.19.4 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,15 @@ function declaredLength(request, what) {
282
737
  }
283
738
  return declared;
284
739
  }
285
- function isKeyWithinPrefix(key, prefix) {
286
- return key.startsWith(prefix);
740
+ function isKeyWithinScope(key, prefix, excludedPrefixes) {
741
+ return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
287
742
  }
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;
297
- }
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);
304
- }
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) {
743
+ function anchorKey(raw, prefix, excludedPrefixes) {
353
744
  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}` };
745
+ if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
746
+ if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
747
+ const key = `${prefix}${safe}`;
748
+ return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
357
749
  }
358
750
  var VERB_ROUTES = /* @__PURE__ */ new Set([
359
751
  "POST upload",
@@ -367,7 +759,7 @@ function isFilesVerbPath(method, subpath) {
367
759
  }
368
760
  function resolveMimeType(raw) {
369
761
  const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
370
- return DANGEROUS_MIME_TYPES.has(mimeType) ? fail(415, activeContentMessage(mimeType), "active_content") : { mimeType };
762
+ return mimeType;
371
763
  }
372
764
  function r2ErrorCode(error) {
373
765
  const message = error instanceof Error ? error.message : String(error);
@@ -377,7 +769,7 @@ function r2ErrorCode(error) {
377
769
  function multipartFailure(error) {
378
770
  switch (r2ErrorCode(error)) {
379
771
  case 10024:
380
- return fail(
772
+ return fail2(
381
773
  404,
382
774
  "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
383
775
  "unknown_upload"
@@ -387,7 +779,7 @@ function multipartFailure(error) {
387
779
  case 10025:
388
780
  // InvalidPart — a named part was never uploaded
389
781
  case 10048:
390
- return fail(
782
+ return fail2(
391
783
  400,
392
784
  "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
785
  "bad_parts"
@@ -395,11 +787,15 @@ function multipartFailure(error) {
395
787
  case 10012:
396
788
  // MetadataTooLarge
397
789
  case 10020:
398
- return fail(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
790
+ return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
399
791
  default: {
400
792
  const message = error instanceof Error ? error.message : String(error);
401
793
  console.error("[files:multipart] R2 error:", message);
402
- return fail(503, "The files service could not complete that upload. Retry shortly.", "r2_error");
794
+ return fail2(
795
+ 503,
796
+ "The files service could not complete that upload. Retry shortly.",
797
+ "r2_error"
798
+ );
403
799
  }
404
800
  }
405
801
  }
@@ -412,73 +808,79 @@ function createScopedR2Handler(config) {
412
808
  } catch {
413
809
  return Response.json(
414
810
  { error: "Invalid path encoding" },
415
- { status: 400, headers: CORS_HEADERS }
811
+ { status: 400, headers: CORS_HEADERS2 }
416
812
  );
417
813
  }
418
814
  const subpath = sanitizeSubpath(subpathRaw);
419
815
  if (subpath === null) {
420
816
  return Response.json(
421
817
  { error: "Invalid path: traversal not allowed" },
422
- { status: 400, headers: CORS_HEADERS }
818
+ { status: 400, headers: CORS_HEADERS2 }
423
819
  );
424
820
  }
425
821
  const scope = url.searchParams.get("scope") || "self";
426
822
  if (scope !== "self" && scope !== "app") {
427
- return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS });
823
+ return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS2 });
428
824
  }
429
825
  const result = config.resolvePrefix(scope, { userId: auth.userId, url });
430
826
  if (!result.prefix) {
431
827
  const errorMsg = result.error ?? "Invalid scope";
432
828
  const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
433
- return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS });
829
+ return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS2 });
434
830
  }
435
831
  const prefix = result.prefix;
832
+ const excludedPrefixes = result.excludedPrefixes ?? [];
436
833
  if (isFilesVerbPath(request.method, subpath)) {
437
834
  if (requireAuthForMutations && !auth.userId) {
438
835
  await request.body?.cancel().catch(() => void 0);
439
- return fail(401, "Authentication required", "unauthorized");
836
+ return fail2(401, "Authentication required", "unauthorized");
440
837
  }
441
- return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
838
+ return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
442
839
  }
443
840
  if (!subpath && request.method === "GET") {
444
- return handleList(bucket, prefix, url, scope, auth);
841
+ return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
445
842
  }
446
843
  if (subpath && request.method === "GET") {
447
- if (!isKeyWithinPrefix(subpath, prefix)) {
844
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
448
845
  return Response.json(
449
846
  { error: "Access denied: key outside scope" },
450
- { status: 403, headers: CORS_HEADERS }
847
+ { status: 403, headers: CORS_HEADERS2 }
451
848
  );
452
849
  }
453
- return handleDownload(bucket, subpath, scope);
850
+ return handleDownload(request, bucket, subpath, scope);
454
851
  }
455
852
  if (subpath && request.method === "DELETE") {
456
853
  if (requireAuthForMutations && !auth.userId) {
457
854
  return Response.json(
458
855
  { error: "Authentication required" },
459
- { status: 401, headers: CORS_HEADERS }
856
+ { status: 401, headers: CORS_HEADERS2 }
460
857
  );
461
858
  }
462
- if (!isKeyWithinPrefix(subpath, prefix)) {
859
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
463
860
  return Response.json(
464
861
  { error: "Access denied: key outside scope" },
465
- { status: 403, headers: CORS_HEADERS }
862
+ { status: 403, headers: CORS_HEADERS2 }
466
863
  );
467
864
  }
468
865
  const existed = await bucket.head(subpath) !== null;
469
- if (existed) await bucket.delete(subpath);
866
+ if (existed) {
867
+ await bucket.delete(subpath);
868
+ await markQuotaDirty(bucket, auth.storage);
869
+ }
470
870
  return Response.json(
471
871
  { success: true, deleted: existed ? subpath : null, existed },
472
- { headers: CORS_HEADERS }
872
+ { headers: CORS_HEADERS2 }
473
873
  );
474
874
  }
475
- return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS });
875
+ return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS2 });
476
876
  };
477
877
  }
478
- async function handleUpload(request, url, bucket, prefix, auth, scope) {
878
+ async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
479
879
  try {
480
- const declared = overRequestBound(Number(request.headers.get("content-length")), 8 * 1024);
481
- if (declared) return declared;
880
+ const declaredBytes = declaredLength(request, "File uploads");
881
+ if (declaredBytes instanceof Response) return declaredBytes;
882
+ const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
883
+ if (tooLargeRequest) return tooLargeRequest;
482
884
  const contentType = request.headers.get("content-type") || "";
483
885
  let fileData;
484
886
  let fileName;
@@ -486,7 +888,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
486
888
  if (contentType.includes("multipart/form-data")) {
487
889
  const formData = await request.formData();
488
890
  const file = formData.get("file");
489
- if (!file) return fail(400, "No file provided", "bad_request");
891
+ if (!file) return fail2(400, "No file provided", "bad_request");
490
892
  const tooBig = overRequestBound(file.size);
491
893
  if (tooBig) return tooBig;
492
894
  fileData = await file.arrayBuffer();
@@ -494,7 +896,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
494
896
  rawMimeType = file.type || "application/octet-stream";
495
897
  } else {
496
898
  const body = await request.json();
497
- if (!body.data || !body.name) return fail(400, "Missing data or name", "bad_request");
899
+ if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
498
900
  const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
499
901
  const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
500
902
  if (tooBig) return tooBig;
@@ -502,34 +904,60 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
502
904
  fileName = body.name;
503
905
  rawMimeType = body.mimeType || "application/octet-stream";
504
906
  }
505
- const mime = resolveMimeType(rawMimeType);
506
- if (mime instanceof Response) return mime;
507
- const located = locateUpload(url, prefix, fileName);
907
+ const mimeType = resolveMimeType(rawMimeType);
908
+ const located = locateUpload(url, prefix, excludedPrefixes, fileName);
508
909
  if (located instanceof Response) return located;
509
910
  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
- });
911
+ const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
912
+ const replacedBytes = replaced?.size ?? 0;
913
+ const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
914
+ if (admission instanceof Response) return admission;
915
+ try {
916
+ const written = await bucket.put(located.key, fileData, {
917
+ // Replacement credit is valid only while the exact object that earned
918
+ // it still exists. A concurrent delete or overwrite loses this CAS and
919
+ // retries from a fresh head instead of landing uncounted bytes.
920
+ ...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
921
+ httpMetadata: { contentType: mimeType },
922
+ customMetadata: fileMetadata(fileName, auth.userId)
923
+ });
924
+ if (!written) {
925
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
926
+ return fail2(
927
+ 409,
928
+ "The file changed while this upload was being admitted. Retry the upload.",
929
+ "write_conflict"
930
+ );
931
+ }
932
+ } catch (error) {
933
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
934
+ throw error;
935
+ }
936
+ await releaseQuotaMarker(
937
+ bucket,
938
+ auth.storage,
939
+ admission.markerKey,
940
+ admission.markerKey !== null || fileData.byteLength < replacedBytes
941
+ );
516
942
  return storedFileJson(located.key, fileName, url, scope);
517
943
  } catch (err) {
518
944
  const msg = err instanceof Error ? err.message : "Upload failed";
519
945
  console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
520
- return fail(500, msg, "upload_failed");
946
+ return fail2(500, msg, "upload_failed");
521
947
  }
522
948
  }
523
- function locateUpload(url, prefix, fileName) {
949
+ function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
524
950
  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);
951
+ const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
952
+ const anchored = anchorKey(subkey, prefix, excludedPrefixes);
527
953
  return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
528
954
  }
529
- function fileMetadata(fileName, userId) {
955
+ function fileMetadata(fileName, userId, reservationId, declaredBytes) {
530
956
  return {
531
957
  originalName: fileName,
532
958
  ...userId ? { uploadedBy: userId } : {},
959
+ ...reservationId ? { reservationId } : {},
960
+ ...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
533
961
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
534
962
  };
535
963
  }
@@ -538,7 +966,7 @@ function storedFileJson(key, name, url, scope) {
538
966
  fileUrl.searchParams.set("scope", scope);
539
967
  return Response.json(
540
968
  { success: true, key, url: fileUrl.toString(), name },
541
- { headers: CORS_HEADERS }
969
+ { headers: CORS_HEADERS2 }
542
970
  );
543
971
  }
544
972
  var MAX_CONTROL_BODY_BYTES = 64 * 1024;
@@ -546,24 +974,61 @@ async function readControlJson(request) {
546
974
  const declared = declaredLength(request, "This request");
547
975
  if (declared instanceof Response) return declared;
548
976
  if (declared > MAX_CONTROL_BODY_BYTES) {
549
- return fail(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
977
+ return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
550
978
  }
551
979
  let parsed;
552
980
  try {
553
981
  parsed = await request.json();
554
982
  } catch {
555
- return fail(400, "Body must be JSON", "bad_request");
983
+ return fail2(400, "Body must be JSON", "bad_request");
556
984
  }
557
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail(400, "Body must be a JSON object", "bad_request");
985
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
558
986
  }
559
- function resolveSession(url, prefix) {
987
+ var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
988
+ function resolveSession(url, prefix, excludedPrefixes) {
560
989
  const uploadKey = url.searchParams.get("uploadKey");
561
990
  const uploadId = url.searchParams.get("uploadId");
562
- if (!uploadKey || !uploadId) {
563
- return fail(400, "uploadKey and uploadId are required", "bad_request");
991
+ const reservationId = url.searchParams.get("reservationId");
992
+ if (!uploadKey || !uploadId || !reservationId) {
993
+ return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
564
994
  }
565
- const anchored = anchorKey(uploadKey, prefix);
566
- return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
995
+ if (!REQUEST_ID_RE.test(reservationId)) {
996
+ return fail2(400, "reservationId is invalid", "bad_request");
997
+ }
998
+ const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
999
+ return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
1000
+ }
1001
+ function reservationMatches(reservation, session) {
1002
+ return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
1003
+ }
1004
+ function multipartSessionJson(uploadId, uploadKey, reservationId) {
1005
+ return Response.json(
1006
+ {
1007
+ uploadId,
1008
+ uploadKey,
1009
+ reservationId,
1010
+ partSize: UPLOAD_PART_BYTES,
1011
+ maxParts: MAX_UPLOAD_PARTS,
1012
+ maxBytes: MAX_APP_FILE_BYTES
1013
+ },
1014
+ { headers: CORS_HEADERS2 }
1015
+ );
1016
+ }
1017
+ function multipartPartCount(declaredBytes) {
1018
+ return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
1019
+ }
1020
+ function expectedMultipartPartBytes(declaredBytes, partNumber) {
1021
+ const partCount = multipartPartCount(declaredBytes);
1022
+ if (partNumber < 1 || partNumber > partCount) return null;
1023
+ return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
1024
+ }
1025
+ function multipartLayoutError(declaredBytes) {
1026
+ return fail2(
1027
+ 400,
1028
+ `This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
1029
+ "bad_part_layout",
1030
+ { partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
1031
+ );
567
1032
  }
568
1033
  async function streamPart(upload, partNumber, body, expectedBytes) {
569
1034
  let observedBytes = 0;
@@ -586,57 +1051,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
586
1051
  }
587
1052
  return { ok: true, part: uploaded.value };
588
1053
  }
589
- async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
1054
+ async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
590
1055
  if (subpath === "multipart" && request.method === "POST") {
591
- return multipartInit(request, url, bucket, prefix, auth);
1056
+ return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
1057
+ }
1058
+ if (subpath === "multipart/part") {
1059
+ return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
592
1060
  }
593
- if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
594
1061
  if (subpath === "multipart/complete") {
595
- return multipartComplete(request, url, bucket, prefix, auth, scope);
1062
+ return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
596
1063
  }
597
- return multipartAbort(url, bucket, prefix);
1064
+ return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
598
1065
  }
599
- async function multipartInit(request, url, bucket, prefix, auth) {
1066
+ async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
600
1067
  const body = await readControlJson(request);
601
1068
  if (body instanceof Response) return body;
602
1069
  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");
1070
+ if (!Number.isSafeInteger(size) || size <= 0) {
1071
+ return fail2(400, "size must be a positive integer number of bytes", "bad_request");
605
1072
  }
606
1073
  const tooBig = overCeiling(size);
607
1074
  if (tooBig) return tooBig;
608
1075
  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);
1076
+ if (!name) return fail2(400, "name is required", "bad_request");
1077
+ const requestId = typeof body.requestId === "string" ? body.requestId : "";
1078
+ if (!REQUEST_ID_RE.test(requestId)) {
1079
+ return fail2(
1080
+ 400,
1081
+ "requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
1082
+ "bad_request"
1083
+ );
1084
+ }
1085
+ const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
1086
+ const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
613
1087
  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;
1088
+ const completed = await bucket.head(located.key);
1089
+ if (completed?.customMetadata?.reservationId === requestId) {
1090
+ return fail2(
1091
+ 409,
1092
+ "requestId already completed an upload; start the new upload with a fresh requestId.",
1093
+ "request_id_completed"
1094
+ );
1095
+ }
1096
+ const existing = await readMultipartReservation(bucket, auth.storage, requestId);
1097
+ if (existing === "invalid") {
1098
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
1099
+ }
1100
+ if (existing) {
1101
+ const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
1102
+ return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
1103
+ 409,
1104
+ "requestId already belongs to a different multipart upload.",
1105
+ "request_id_conflict"
1106
+ );
1107
+ }
617
1108
  let upload;
618
1109
  try {
619
1110
  upload = await bucket.createMultipartUpload(located.key, {
620
- httpMetadata: { contentType: mime.mimeType },
621
- customMetadata: fileMetadata(name, auth.userId)
1111
+ httpMetadata: { contentType: mimeType },
1112
+ customMetadata: fileMetadata(name, auth.userId, requestId, size)
622
1113
  });
623
1114
  } catch (error) {
624
1115
  return multipartFailure(error);
625
1116
  }
626
- return Response.json(
627
- {
1117
+ const marker = auth.storage ? {
1118
+ key: multipartMarkerKey(auth.storage, requestId),
1119
+ body: JSON.stringify({
1120
+ version: 1,
1121
+ reservationId: requestId,
628
1122
  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.
1123
+ objectKey: located.key,
631
1124
  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
- );
1125
+ admitted: false,
1126
+ declaredBytes: size
1127
+ }),
1128
+ metadata: {
1129
+ quotaKind: "multipart",
1130
+ active: "false"
1131
+ }
1132
+ } : void 0;
1133
+ const reservedBytes = Math.max(size, 5 * 1024 * 1024);
1134
+ const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
1135
+ if (admission instanceof Response) {
1136
+ await upload.abort().catch(() => void 0);
1137
+ if (admission.status === 409 && auth.storage) {
1138
+ const winner = await readMultipartReservation(bucket, auth.storage, requestId);
1139
+ if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
1140
+ return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
1141
+ }
1142
+ }
1143
+ return admission;
1144
+ }
1145
+ if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
1146
+ await upload.abort().catch(() => void 0);
1147
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
1148
+ return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
1149
+ }
1150
+ return multipartSessionJson(upload.uploadId, located.subkey, requestId);
638
1151
  }
639
- async function multipartPart(request, url, bucket, prefix) {
1152
+ async function abandonMultipart(bucket, session, auth, reservation) {
1153
+ let result = "aborted";
1154
+ try {
1155
+ await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
1156
+ } catch (error) {
1157
+ if (r2ErrorCode(error) !== 10024) {
1158
+ console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
1159
+ return "failed";
1160
+ }
1161
+ result = "absent";
1162
+ }
1163
+ if (reservation) {
1164
+ if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
1165
+ }
1166
+ return result;
1167
+ }
1168
+ async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
640
1169
  const refuse = async (response) => {
641
1170
  await request.body?.cancel().catch(() => void 0);
642
1171
  return response;
@@ -648,16 +1177,60 @@ async function multipartPart(request, url, bucket, prefix) {
648
1177
  const partNumber = Number(url.searchParams.get("partNumber"));
649
1178
  if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
650
1179
  return refuse(
651
- fail(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
1180
+ fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
652
1181
  );
653
1182
  }
654
- const session = resolveSession(url, prefix);
1183
+ const session = resolveSession(url, prefix, excludedPrefixes);
655
1184
  if (session instanceof Response) return refuse(session);
656
- if (!request.body) return fail(400, "A part body is required", "bad_request");
1185
+ if (!request.body) return fail2(400, "A part body is required", "bad_request");
1186
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1187
+ if (reservation === "invalid") {
1188
+ return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
1189
+ }
1190
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1191
+ return refuse(
1192
+ fail2(
1193
+ 404,
1194
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
1195
+ "unknown_upload"
1196
+ )
1197
+ );
1198
+ }
1199
+ if (auth.storage && !reservation) {
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 (reservation) {
1209
+ const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
1210
+ if (expectedBytes === null || declaredBytes !== expectedBytes) {
1211
+ return refuse(multipartLayoutError(reservation.declaredBytes));
1212
+ }
1213
+ }
1214
+ const refreshed = await refreshMultipartReservation(
1215
+ bucket,
1216
+ auth.storage,
1217
+ session.reservationId,
1218
+ session.uploadId,
1219
+ session.key
1220
+ );
1221
+ if (auth.storage && (refreshed === null || refreshed === "invalid")) {
1222
+ return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
1223
+ }
1224
+ if (refreshed === "busy") {
1225
+ return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
1226
+ }
657
1227
  const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
658
1228
  const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
659
1229
  if (!stored.ok) {
660
- return stored.lengthMismatch ? fail(
1230
+ if (r2ErrorCode(stored.error) === 10024 && reservation) {
1231
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1232
+ }
1233
+ return stored.lengthMismatch ? fail2(
661
1234
  400,
662
1235
  `Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
663
1236
  "bad_request"
@@ -665,29 +1238,98 @@ async function multipartPart(request, url, bucket, prefix) {
665
1238
  }
666
1239
  return Response.json(
667
1240
  { partNumber: stored.part.partNumber, etag: stored.part.etag },
668
- { headers: CORS_HEADERS }
1241
+ { headers: CORS_HEADERS2 }
669
1242
  );
670
1243
  }
671
- async function multipartComplete(request, url, bucket, prefix, auth, scope) {
1244
+ async function completedReservationObject(bucket, session) {
1245
+ const object = await bucket.head(session.key);
1246
+ return object?.customMetadata?.reservationId === session.reservationId ? object : null;
1247
+ }
1248
+ async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope) {
1249
+ const declaredMetadata = Number(object.customMetadata?.declaredBytes);
1250
+ const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
1251
+ const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
1252
+ 400,
1253
+ `The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
1254
+ "declared_size_exceeded"
1255
+ ) : null);
1256
+ if (refusal) {
1257
+ let remaining;
1258
+ try {
1259
+ const current = await bucket.head(session.key);
1260
+ if (current?.etag === object.etag) await bucket.delete(session.key);
1261
+ remaining = await bucket.head(session.key);
1262
+ } catch (error) {
1263
+ console.error("[files:multipart] invalid object cleanup:", error);
1264
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
1265
+ }
1266
+ if (remaining?.etag === object.etag) {
1267
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
1268
+ }
1269
+ if (reservation) {
1270
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1271
+ if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
1272
+ }
1273
+ return refusal;
1274
+ }
1275
+ if (reservation) {
1276
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1277
+ if (!cleaned) {
1278
+ return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
1279
+ }
1280
+ }
1281
+ const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
1282
+ return storedFileJson(session.key, name, url, scope);
1283
+ }
1284
+ async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
672
1285
  const body = await readControlJson(request);
673
1286
  if (body instanceof Response) return body;
674
- const session = resolveSession(url, prefix);
1287
+ const session = resolveSession(url, prefix, excludedPrefixes);
675
1288
  if (session instanceof Response) return session;
1289
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1290
+ if (reservation === "invalid") {
1291
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
1292
+ }
1293
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1294
+ return fail2(
1295
+ 404,
1296
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
1297
+ "unknown_upload"
1298
+ );
1299
+ }
1300
+ if (auth.storage && !reservation) {
1301
+ const completed = await completedReservationObject(bucket, session);
1302
+ if (completed) {
1303
+ return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope);
1304
+ }
1305
+ await abandonMultipart(bucket, session, auth, null);
1306
+ return quotaUnavailable("This upload reservation is missing or does not match.");
1307
+ }
676
1308
  const raw = body.parts;
677
1309
  if (!Array.isArray(raw) || raw.length === 0) {
678
- return fail(400, "parts must be a non-empty array", "bad_request");
1310
+ return fail2(400, "parts must be a non-empty array", "bad_request");
679
1311
  }
680
1312
  if (raw.length > MAX_UPLOAD_PARTS) {
681
- return fail(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
1313
+ return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
1314
+ }
1315
+ if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
1316
+ return multipartLayoutError(reservation.declaredBytes);
682
1317
  }
683
1318
  const parts = [];
684
1319
  for (let index = 0; index < raw.length; index++) {
685
1320
  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");
1321
+ if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
1322
+ return fail2(
1323
+ 400,
1324
+ `parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
1325
+ "bad_request"
1326
+ );
1327
+ }
1328
+ if (reservation && partNumber !== index + 1) {
1329
+ return multipartLayoutError(reservation.declaredBytes);
688
1330
  }
689
1331
  if (typeof etag !== "string" || !etag) {
690
- return fail(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
1332
+ return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
691
1333
  }
692
1334
  parts.push({ partNumber, etag });
693
1335
  }
@@ -695,35 +1337,62 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
695
1337
  try {
696
1338
  object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
697
1339
  } catch (error) {
1340
+ const completed = await completedReservationObject(bucket, session);
1341
+ if (completed) {
1342
+ return finishCompletedMultipart(bucket, session, reservation, completed, auth, url, scope);
1343
+ }
1344
+ if (r2ErrorCode(error) === 10024 && reservation) {
1345
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1346
+ }
698
1347
  return multipartFailure(error);
699
1348
  }
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);
1349
+ return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope);
709
1350
  }
710
- async function multipartAbort(url, bucket, prefix) {
711
- const session = resolveSession(url, prefix);
1351
+ async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
1352
+ const session = resolveSession(url, prefix, excludedPrefixes);
712
1353
  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 });
1354
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
1355
+ if (reservation === "invalid") {
1356
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
718
1357
  }
719
- return Response.json({ success: true, aborted: true }, { headers: CORS_HEADERS });
1358
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
1359
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
1360
+ }
1361
+ const result = await abandonMultipart(bucket, session, auth, reservation || null);
1362
+ return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS2 });
720
1363
  }
721
- async function handleList(bucket, prefix, url, scope, auth) {
1364
+ async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
722
1365
  const userPrefix = url.searchParams.get("prefix") || "";
723
1366
  const listPrefix = `${prefix}${userPrefix}`;
724
- const limit = parseInt(url.searchParams.get("limit") || "100", 10);
1367
+ const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
1368
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
1369
+ if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
1370
+ return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS2 });
1371
+ }
725
1372
  const listed = await bucket.list({ prefix: listPrefix, limit });
726
- const files = listed.objects.map((obj) => {
1373
+ const visible = listed.objects.filter(
1374
+ (object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
1375
+ );
1376
+ const seen = new Set(visible.map((object) => object.key));
1377
+ let truncated = listed.truncated;
1378
+ for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix))) {
1379
+ if (visible.length >= limit) break;
1380
+ const excludedEnd = `${excluded}\u{10FFFF}`;
1381
+ const lastListedKey = listed.objects.at(-1)?.key;
1382
+ const after = await bucket.list({
1383
+ prefix: listPrefix,
1384
+ startAfter: lastListedKey && lastListedKey > excludedEnd ? lastListedKey : excludedEnd,
1385
+ limit: limit - visible.length
1386
+ });
1387
+ for (const object of after.objects) {
1388
+ if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
1389
+ seen.add(object.key);
1390
+ visible.push(object);
1391
+ }
1392
+ }
1393
+ truncated = after.truncated;
1394
+ }
1395
+ const files = visible.slice(0, limit).map((obj) => {
727
1396
  const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
728
1397
  fileUrl.searchParams.set("scope", scope);
729
1398
  return {
@@ -735,32 +1404,41 @@ async function handleList(bucket, prefix, url, scope, auth) {
735
1404
  };
736
1405
  });
737
1406
  let storage;
738
- if (auth.storage) {
739
- const usedBytes = await allocationUsage(bucket, auth.storage);
1407
+ if (auth.storage && auth.includeStorageUsage) {
1408
+ const usedBytes = await storageUsage(bucket, auth.storage);
740
1409
  if (usedBytes !== null) storage = { usedBytes };
741
1410
  const limitBytes = await auth.storage.limitBytes();
742
1411
  if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
743
1412
  }
744
1413
  return Response.json(
745
- { files, truncated: listed.truncated, ...storage ? { storage } : {} },
746
- { headers: CORS_HEADERS }
1414
+ { files, truncated, ...storage ? { storage } : {} },
1415
+ { headers: CORS_HEADERS2 }
747
1416
  );
748
1417
  }
749
- async function handleDownload(bucket, key, scope) {
1418
+ async function handleDownload(request, bucket, key, scope) {
750
1419
  const object = await bucket.get(key);
751
1420
  if (!object) {
752
- return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS });
1421
+ return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS2 });
753
1422
  }
754
1423
  const headers = new Headers();
755
- headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
1424
+ const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
1425
+ const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
1426
+ headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
756
1427
  headers.set("X-Content-Type-Options", "nosniff");
1428
+ headers.set("ETag", object.httpEtag);
757
1429
  headers.set(
758
1430
  "Cache-Control",
759
- scope === "app" ? "public, max-age=31536000, immutable" : "private, no-store"
1431
+ scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
760
1432
  );
761
1433
  headers.set("Access-Control-Allow-Origin", "*");
762
- if (object.customMetadata?.originalName) {
763
- headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
1434
+ const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
1435
+ headers.set(
1436
+ "Content-Disposition",
1437
+ contentDisposition(originalName, mustDownload ? "attachment" : "inline")
1438
+ );
1439
+ if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
1440
+ if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
1441
+ return new Response(null, { status: 304, headers });
764
1442
  }
765
1443
  return new Response(object.body, { headers });
766
1444
  }
@@ -3756,7 +4434,7 @@ async function handleSetRole(ctx, ws, attachment, payload) {
3756
4434
 
3757
4435
  // src/server/handlers/tools-api.ts
3758
4436
  import * as Y3 from "yjs";
3759
- var CORS_HEADERS2 = {
4437
+ var CORS_HEADERS3 = {
3760
4438
  "Access-Control-Allow-Origin": "*",
3761
4439
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3762
4440
  "Access-Control-Allow-Headers": "Content-Type, X-User-Id, X-App-Action",
@@ -3764,23 +4442,23 @@ var CORS_HEADERS2 = {
3764
4442
  };
3765
4443
  async function handleToolsRequest(ctx, request, path) {
3766
4444
  if (request.method === "OPTIONS") {
3767
- return new Response(null, { headers: CORS_HEADERS2 });
4445
+ return new Response(null, { headers: CORS_HEADERS3 });
3768
4446
  }
3769
4447
  if (path === "tools/list") {
3770
- return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS2 });
4448
+ return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS3 });
3771
4449
  }
3772
4450
  if (path.startsWith("tools/describe/")) {
3773
4451
  const toolName = path.replace("tools/describe/", "");
3774
4452
  const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
3775
4453
  if (!tool2) {
3776
- return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS2 });
4454
+ return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS3 });
3777
4455
  }
3778
- return Response.json({ tool: tool2 }, { headers: CORS_HEADERS2 });
4456
+ return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
3779
4457
  }
3780
4458
  if (path.startsWith("tools/execute") && request.method === "POST") {
3781
4459
  return handleToolExecute(ctx, request);
3782
4460
  }
3783
- return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS2 });
4461
+ return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
3784
4462
  }
3785
4463
  async function handleToolExecute(ctx, request) {
3786
4464
  let body;
@@ -3789,14 +4467,14 @@ async function handleToolExecute(ctx, request) {
3789
4467
  } catch {
3790
4468
  return Response.json(
3791
4469
  { success: false, error: "Invalid JSON body" },
3792
- { status: 400, headers: CORS_HEADERS2 }
4470
+ { status: 400, headers: CORS_HEADERS3 }
3793
4471
  );
3794
4472
  }
3795
4473
  const { tool: tool2, params = {} } = body;
3796
4474
  if (!tool2) {
3797
4475
  return Response.json(
3798
4476
  { success: false, error: 'Missing "tool" in request body' },
3799
- { status: 400, headers: CORS_HEADERS2 }
4477
+ { status: 400, headers: CORS_HEADERS3 }
3800
4478
  );
3801
4479
  }
3802
4480
  const userId = request.headers.get("x-user-id") ?? "";
@@ -3813,7 +4491,7 @@ async function handleToolExecute(ctx, request) {
3813
4491
  if (tool2.startsWith("yjs.")) {
3814
4492
  const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
3815
4493
  const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
3816
- return Response.json(result2, { status: status2, headers: CORS_HEADERS2 });
4494
+ return Response.json(result2, { status: status2, headers: CORS_HEADERS3 });
3817
4495
  }
3818
4496
  const recordCtx = {
3819
4497
  sql: ctx.sql,
@@ -3824,7 +4502,7 @@ async function handleToolExecute(ctx, request) {
3824
4502
  };
3825
4503
  const result = await executeTool2(recordCtx, tool2, params, userId, userRole, isAppAction);
3826
4504
  const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
3827
- return Response.json(result, { status, headers: CORS_HEADERS2 });
4505
+ return Response.json(result, { status, headers: CORS_HEADERS3 });
3828
4506
  }
3829
4507
  async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRbac) {
3830
4508
  switch (toolName) {
@@ -4060,7 +4738,7 @@ function generateId() {
4060
4738
  }
4061
4739
 
4062
4740
  // src/server/handlers/debug-api.ts
4063
- var CORS_HEADERS3 = {
4741
+ var CORS_HEADERS4 = {
4064
4742
  "Access-Control-Allow-Origin": "*",
4065
4743
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
4066
4744
  "Access-Control-Allow-Headers": "Content-Type",
@@ -4068,7 +4746,7 @@ var CORS_HEADERS3 = {
4068
4746
  };
4069
4747
  async function handleApiRequest(ctx, request, url) {
4070
4748
  if (request.method === "OPTIONS") {
4071
- return new Response(null, { headers: CORS_HEADERS3 });
4749
+ return new Response(null, { headers: CORS_HEADERS4 });
4072
4750
  }
4073
4751
  const path = url.pathname.replace("/api/", "");
4074
4752
  if (path.startsWith("tools/")) {
@@ -4092,13 +4770,13 @@ async function handleApiRequest(ctx, request, url) {
4092
4770
  if (path === "debug/query") {
4093
4771
  return handleDebugQuery(ctx, url);
4094
4772
  }
4095
- return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
4773
+ return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS4 });
4096
4774
  }
4097
4775
  function handleDebugUserRole(ctx, url) {
4098
4776
  const email = url.searchParams.get("email");
4099
4777
  const userId = url.searchParams.get("userId");
4100
4778
  if (!email && !userId) {
4101
- return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4779
+ return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4102
4780
  }
4103
4781
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
4104
4782
  const user = userId ? users.find((u) => u.id === userId) : users.find((u) => u.email === email);
@@ -4106,22 +4784,22 @@ function handleDebugUserRole(ctx, url) {
4106
4784
  return Response.json({
4107
4785
  error: `User not found`,
4108
4786
  registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
4109
- }, { status: 404, headers: CORS_HEADERS3 });
4787
+ }, { status: 404, headers: CORS_HEADERS4 });
4110
4788
  }
4111
4789
  return Response.json({
4112
4790
  userId: user.id,
4113
4791
  email: user.email,
4114
4792
  name: user.name,
4115
4793
  role: user.role
4116
- }, { headers: CORS_HEADERS3 });
4794
+ }, { headers: CORS_HEADERS4 });
4117
4795
  }
4118
4796
  async function handleDebugSetRole(ctx, request) {
4119
4797
  const body = await request.json();
4120
4798
  if (!body.role) {
4121
- return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS3 });
4799
+ return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS4 });
4122
4800
  }
4123
4801
  if (!body.email && !body.userId) {
4124
- return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS3 });
4802
+ return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS4 });
4125
4803
  }
4126
4804
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
4127
4805
  const user = body.userId ? users.find((u) => u.id === body.userId) : users.find((u) => u.email === body.email);
@@ -4129,7 +4807,7 @@ async function handleDebugSetRole(ctx, request) {
4129
4807
  return Response.json({
4130
4808
  error: "User not found",
4131
4809
  registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
4132
- }, { status: 404, headers: CORS_HEADERS3 });
4810
+ }, { status: 404, headers: CORS_HEADERS4 });
4133
4811
  }
4134
4812
  const now = (/* @__PURE__ */ new Date()).toISOString();
4135
4813
  ctx.sql.exec(
@@ -4143,7 +4821,7 @@ async function handleDebugSetRole(ctx, request) {
4143
4821
  email: user.email,
4144
4822
  previousRole: user.role,
4145
4823
  newRole: body.role
4146
- }, { headers: CORS_HEADERS3 });
4824
+ }, { headers: CORS_HEADERS4 });
4147
4825
  }
4148
4826
  function handleDebugStatus(ctx) {
4149
4827
  const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
@@ -4161,7 +4839,7 @@ function handleDebugStatus(ctx) {
4161
4839
  }
4162
4840
  return Response.json(
4163
4841
  { users, schemas: schemas.map((s) => s.name), connections },
4164
- { headers: CORS_HEADERS3 }
4842
+ { headers: CORS_HEADERS4 }
4165
4843
  );
4166
4844
  }
4167
4845
  function handleDebugRecords(ctx, path) {
@@ -4180,12 +4858,12 @@ function handleDebugRecords(ctx, path) {
4180
4858
  createdAt: r._created_at
4181
4859
  };
4182
4860
  });
4183
- return Response.json({ collection, records }, { headers: CORS_HEADERS3 });
4861
+ return Response.json({ collection, records }, { headers: CORS_HEADERS4 });
4184
4862
  } catch (error) {
4185
4863
  return Response.json({
4186
4864
  error: error instanceof Error ? error.message : "Query failed",
4187
4865
  collection
4188
- }, { status: 400, headers: CORS_HEADERS3 });
4866
+ }, { status: 400, headers: CORS_HEADERS4 });
4189
4867
  }
4190
4868
  }
4191
4869
  function handleDebugQuery(ctx, url) {
@@ -4193,17 +4871,17 @@ function handleDebugQuery(ctx, url) {
4193
4871
  const userId = url.searchParams.get("userId");
4194
4872
  const role = url.searchParams.get("role");
4195
4873
  if (!collection) {
4196
- return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4874
+ return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4197
4875
  }
4198
4876
  if (!userId) {
4199
- return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4877
+ return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4200
4878
  }
4201
4879
  if (!role) {
4202
- return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS3 });
4880
+ return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS4 });
4203
4881
  }
4204
4882
  const user = getUser(ctx.sql, userId, ctx.schemaRegistry);
4205
4883
  if (!user) {
4206
- return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS3 });
4884
+ return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS4 });
4207
4885
  }
4208
4886
  const query = { collection };
4209
4887
  const where = {};
@@ -4222,7 +4900,7 @@ function handleDebugQuery(ctx, url) {
4222
4900
  asUser: { id: userId, name: user.name, actualRole: user.role, testingAsRole: role },
4223
4901
  recordCount: records.length,
4224
4902
  records
4225
- }, { headers: CORS_HEADERS3 });
4903
+ }, { headers: CORS_HEADERS4 });
4226
4904
  }
4227
4905
  async function handleDebugSql(ctx, request, url) {
4228
4906
  let sql;
@@ -4245,7 +4923,7 @@ async function handleDebugSql(ctx, request, url) {
4245
4923
  "DESCRIBE <table> - show table schema"
4246
4924
  ]
4247
4925
  }
4248
- }, { status: 400, headers: CORS_HEADERS3 });
4926
+ }, { status: 400, headers: CORS_HEADERS4 });
4249
4927
  }
4250
4928
  const sqlUpper = sql.trim().toUpperCase();
4251
4929
  try {
@@ -4254,7 +4932,7 @@ async function handleDebugSql(ctx, request, url) {
4254
4932
  `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
4255
4933
  );
4256
4934
  const tables = cursor2.toArray().map((r) => r.name);
4257
- return Response.json({ tables }, { headers: CORS_HEADERS3 });
4935
+ return Response.json({ tables }, { headers: CORS_HEADERS4 });
4258
4936
  }
4259
4937
  if (sqlUpper.startsWith("DESCRIBE ") || sqlUpper.startsWith(".SCHEMA ")) {
4260
4938
  const tableName = sql.trim().split(/\s+/)[1];
@@ -4269,7 +4947,7 @@ async function handleDebugSql(ctx, request, url) {
4269
4947
  primaryKey: col.pk === 1
4270
4948
  };
4271
4949
  });
4272
- return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS3 });
4950
+ return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS4 });
4273
4951
  }
4274
4952
  const cursor = ctx.sql.exec(sql, ...params);
4275
4953
  const rows = cursor.toArray();
@@ -4277,12 +4955,12 @@ async function handleDebugSql(ctx, request, url) {
4277
4955
  sql,
4278
4956
  rowCount: rows.length,
4279
4957
  rows
4280
- }, { headers: CORS_HEADERS3 });
4958
+ }, { headers: CORS_HEADERS4 });
4281
4959
  } catch (error) {
4282
4960
  return Response.json({
4283
4961
  error: error instanceof Error ? error.message : "Query failed",
4284
4962
  sql
4285
- }, { status: 400, headers: CORS_HEADERS3 });
4963
+ }, { status: 400, headers: CORS_HEADERS4 });
4286
4964
  }
4287
4965
  }
4288
4966