@edge-base/server 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BSIzoJ5I.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → BeYswbvA.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BkrC_6Ss.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{DBniE7GN.js → BunyH6GE.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{C5nMidnK.js → C-zXt-cq.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → C1Mycuvd.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{0M5txo3l.js → Cek51lkE.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/{_xa30BOD.js → D0mNYcxN.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{39YMyjjq.js → DF40xjpj.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → DOOhHO-v.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{DllVADtt.js → Dvlxm2Iq.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Xm1dHMTE.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → aPulNooa.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DBkor_jM.js → dZsKGnWZ.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.j1WHBlZX.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.Oo7ZpbR-.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.zgMnKHns.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.u5mkRiUe.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.9ynAgaxV.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.M_EqzJ6s.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.D_NQ9q60.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.-vENIJ2w.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.DoMCZhGv.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.6BmnzScq.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.q6QQ9N_J.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.DUhJ6dKj.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.CvkvPlGP.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.cACSbRIl.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.BsQ6xeKr.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.CNW3mLz5.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.CReqtEOy.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.BuxVhqlb.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.C-LzPf62.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.Dl5RL_tB.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.CWfSQgjH.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.wUSfh2eQ.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.D_laBXeK.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.DcMhZyQ0.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.CSwhNsNi.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.Cq28vPI2.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.BTT9B3oI.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.iCmmzXZp.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.qY5v7qqJ.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.C6p4RvgK.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.f548N7zh.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +305 -3
  53. package/package.json +3 -3
  54. package/src/__tests__/auth-token.test.ts +142 -0
  55. package/src/__tests__/email-provider.test.ts +82 -0
  56. package/src/__tests__/functions-context.test.ts +87 -0
  57. package/src/__tests__/functions-d1-proxy.test.ts +70 -0
  58. package/src/__tests__/functions-route.test.ts +15 -0
  59. package/src/__tests__/room-access-policy.test.ts +26 -0
  60. package/src/__tests__/room-handler-context.test.ts +2 -0
  61. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  62. package/src/durable-objects/room-runtime-base.ts +16 -5
  63. package/src/durable-objects/rooms-do.ts +4 -1
  64. package/src/lib/auth-d1-service.ts +37 -7
  65. package/src/lib/auth-token.ts +66 -0
  66. package/src/lib/email-provider.ts +115 -5
  67. package/src/lib/functions.ts +91 -1
  68. package/src/lib/internal-transport.ts +15 -1
  69. package/src/lib/jwt.ts +2 -0
  70. package/src/routes/admin.ts +8 -1
  71. package/src/routes/auth.ts +111 -19
  72. package/src/routes/room.ts +36 -2
  73. package/src/routes/storage.ts +446 -62
  74. package/src/types.ts +10 -0
  75. package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
  76. package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
  77. package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
  78. package/admin-build/_app/immutable/nodes/21.CzGYxjpu.js +0 -1
@@ -305,8 +305,34 @@ function checkServiceKey(env: Env, header: string | undefined, scope: string, re
305
305
  type SignedTokenClaims = {
306
306
  expiresAt: number;
307
307
  maxBytes: number | null;
308
+ file?: SignedTokenFileMetadata | null;
308
309
  };
309
310
 
311
+ type SignedTokenFileMetadata = {
312
+ size: number;
313
+ contentType?: string;
314
+ etag?: string;
315
+ uploadedAt?: string;
316
+ uploadedBy?: string | null;
317
+ customMetadata?: Record<string, string>;
318
+ };
319
+
320
+ type SignedTokenOptions = {
321
+ maxBytes?: number | null;
322
+ file?: SignedTokenFileMetadata | null;
323
+ };
324
+
325
+ type TrackedMultipartPart = {
326
+ partNumber: number;
327
+ etag: string;
328
+ size?: number;
329
+ };
330
+
331
+ type ParsedDownloadRange =
332
+ | { kind: 'full' }
333
+ | { kind: 'partial'; offset: number; end: number; length: number }
334
+ | { kind: 'unsatisfiable' };
335
+
310
336
  function parseByteSize(value?: string): number | null {
311
337
  if (!value) return null;
312
338
  const trimmed = value.trim();
@@ -328,18 +354,98 @@ function parseByteSize(value?: string): number | null {
328
354
  return amount * multiplier;
329
355
  }
330
356
 
357
+ function emptySignedTokenClaims(expiresAt = 0, maxBytes: number | null = null): SignedTokenClaims {
358
+ return { expiresAt, maxBytes, file: null };
359
+ }
360
+
361
+ function normalizeSignedTokenOptions(options?: number | null | SignedTokenOptions): Required<Pick<SignedTokenOptions, 'maxBytes'>> & Pick<SignedTokenOptions, 'file'> {
362
+ const rawMaxBytes = typeof options === 'object' && options !== null
363
+ ? options.maxBytes
364
+ : options;
365
+ const maxBytes = typeof rawMaxBytes === 'number' && Number.isFinite(rawMaxBytes)
366
+ ? Math.max(0, Math.trunc(rawMaxBytes))
367
+ : null;
368
+ const file = typeof options === 'object' && options !== null
369
+ ? normalizeSignedFileMetadata(options.file)
370
+ : null;
371
+ return { maxBytes, file };
372
+ }
373
+
374
+ function normalizeSignedFileMetadata(file?: SignedTokenFileMetadata | null): SignedTokenFileMetadata | null {
375
+ if (!file || typeof file.size !== 'number' || !Number.isFinite(file.size) || file.size < 0) {
376
+ return null;
377
+ }
378
+
379
+ const customMetadata: Record<string, string> = {};
380
+ if (file.customMetadata && typeof file.customMetadata === 'object') {
381
+ for (const [key, value] of Object.entries(file.customMetadata)) {
382
+ if (typeof value === 'string') customMetadata[key] = value;
383
+ }
384
+ }
385
+
386
+ const normalized: SignedTokenFileMetadata = {
387
+ size: Math.trunc(file.size),
388
+ };
389
+ if (typeof file.contentType === 'string' && file.contentType) normalized.contentType = file.contentType;
390
+ if (typeof file.etag === 'string' && file.etag) normalized.etag = file.etag;
391
+ if (typeof file.uploadedAt === 'string' && file.uploadedAt) normalized.uploadedAt = file.uploadedAt;
392
+ if (typeof file.uploadedBy === 'string' || file.uploadedBy === null) normalized.uploadedBy = file.uploadedBy;
393
+ if (Object.keys(customMetadata).length > 0) normalized.customMetadata = customMetadata;
394
+ return normalized;
395
+ }
396
+
397
+ function encodeBase64Url(value: string): string {
398
+ const bytes = new TextEncoder().encode(value);
399
+ let binary = '';
400
+ for (const byte of bytes) binary += String.fromCharCode(byte);
401
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
402
+ }
403
+
404
+ function decodeBase64Url(value: string): string {
405
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
406
+ const binary = atob(padded);
407
+ const bytes = new Uint8Array(binary.length);
408
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
409
+ return new TextDecoder().decode(bytes);
410
+ }
411
+
412
+ function signedFileMetadataFromObject(obj: R2Object): SignedTokenFileMetadata {
413
+ return {
414
+ size: obj.size,
415
+ contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
416
+ etag: obj.etag,
417
+ uploadedAt: obj.uploaded?.toISOString(),
418
+ };
419
+ }
420
+
331
421
  /** Create HMAC-based signed URL token. */
332
422
  export async function createSignedToken(
333
423
  key: string,
334
424
  bucket: string,
335
425
  expiresAt: number,
336
426
  secret: string,
337
- maxBytes?: number | null,
427
+ options?: number | null | SignedTokenOptions,
338
428
  ): Promise<string> {
339
429
  const encoder = new TextEncoder();
340
- const normalizedMaxBytes = typeof maxBytes === 'number' && Number.isFinite(maxBytes)
341
- ? Math.max(0, Math.trunc(maxBytes))
342
- : null;
430
+ const { maxBytes: normalizedMaxBytes, file } = normalizeSignedTokenOptions(options);
431
+ if (file) {
432
+ const payload = encodeBase64Url(JSON.stringify({
433
+ v: 2,
434
+ bucket,
435
+ key,
436
+ exp: Math.trunc(expiresAt),
437
+ max: normalizedMaxBytes,
438
+ file,
439
+ }));
440
+ const data = `v2.${payload}`;
441
+ const cryptoKey = await crypto.subtle.importKey(
442
+ 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
443
+ );
444
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
445
+ const sigHex = Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
446
+ return `${data}.${sigHex}`;
447
+ }
448
+
343
449
  const data = `${bucket}:${key}:${expiresAt}:${normalizedMaxBytes ?? ''}`;
344
450
  const cryptoKey = await crypto.subtle.importKey(
345
451
  'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
@@ -359,18 +465,63 @@ async function verifySignedToken(
359
465
  secret: string,
360
466
  ): Promise<{ valid: boolean; claims: SignedTokenClaims }> {
361
467
  const parts = token.split('.');
468
+ if (parts.length === 3 && parts[0] === 'v2') {
469
+ const payload = parts[1]!;
470
+ const signature = parts[2]!;
471
+ let claims: SignedTokenClaims = emptySignedTokenClaims();
472
+ try {
473
+ const parsed = JSON.parse(decodeBase64Url(payload)) as {
474
+ v?: unknown;
475
+ bucket?: unknown;
476
+ key?: unknown;
477
+ exp?: unknown;
478
+ max?: unknown;
479
+ file?: unknown;
480
+ };
481
+ const expiresAt = typeof parsed.exp === 'number' ? Math.trunc(parsed.exp) : NaN;
482
+ const maxBytes = typeof parsed.max === 'number' && Number.isFinite(parsed.max)
483
+ ? Math.max(0, Math.trunc(parsed.max))
484
+ : null;
485
+ const file = normalizeSignedFileMetadata(parsed.file as SignedTokenFileMetadata | null | undefined);
486
+ claims = { expiresAt, maxBytes, file };
487
+ if (
488
+ parsed.v !== 2
489
+ || parsed.bucket !== bucket
490
+ || parsed.key !== key
491
+ || !Number.isFinite(expiresAt)
492
+ || Date.now() > expiresAt
493
+ ) {
494
+ return { valid: false, claims };
495
+ }
496
+ } catch {
497
+ return { valid: false, claims: emptySignedTokenClaims() };
498
+ }
499
+
500
+ const encoder = new TextEncoder();
501
+ const data = `v2.${payload}`;
502
+ const cryptoKey = await crypto.subtle.importKey(
503
+ 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
504
+ );
505
+ const expected = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
506
+ const expectedHex = Array.from(new Uint8Array(expected)).map(b => b.toString(16).padStart(2, '0')).join('');
507
+ return {
508
+ valid: timingSafeEqual(signature, expectedHex),
509
+ claims,
510
+ };
511
+ }
512
+
362
513
  if (parts.length !== 2 && parts.length !== 3) {
363
- return { valid: false, claims: { expiresAt: 0, maxBytes: null } };
514
+ return { valid: false, claims: emptySignedTokenClaims() };
364
515
  }
365
516
  const expiresAt = parseInt(parts[0]!, 10);
366
517
  const maxBytes = parts.length === 3 ? parseInt(parts[1]!, 10) : null;
367
518
  const signature = parts[parts.length - 1]!;
368
519
 
369
520
  if (isNaN(expiresAt) || Date.now() > expiresAt) {
370
- return { valid: false, claims: { expiresAt, maxBytes: Number.isFinite(maxBytes ?? NaN) ? maxBytes : null } };
521
+ return { valid: false, claims: emptySignedTokenClaims(expiresAt, Number.isFinite(maxBytes ?? NaN) ? maxBytes : null) };
371
522
  }
372
523
  if (parts.length === 3 && !Number.isFinite(maxBytes)) {
373
- return { valid: false, claims: { expiresAt, maxBytes: null } };
524
+ return { valid: false, claims: emptySignedTokenClaims(expiresAt) };
374
525
  }
375
526
 
376
527
  const encoder = new TextEncoder();
@@ -385,10 +536,66 @@ async function verifySignedToken(
385
536
  claims: {
386
537
  expiresAt,
387
538
  maxBytes: parts.length === 3 ? maxBytes : null,
539
+ file: null,
388
540
  },
389
541
  };
390
542
  }
391
543
 
544
+ async function verifySignedUploadQuery(
545
+ c: Context<HonoEnv>,
546
+ bucketName: string,
547
+ key: string,
548
+ ): Promise<SignedTokenClaims | null> {
549
+ const token = c.req.query('token');
550
+ const tokenKey = c.req.query('key');
551
+ if (!token) return null;
552
+ if (tokenKey && tokenKey !== key) {
553
+ throw new EdgeBaseError(400, 'Signed upload key mismatch.', undefined, 'validation-failed');
554
+ }
555
+ const secret = c.env.JWT_USER_SECRET;
556
+ if (!secret) {
557
+ throw new EdgeBaseError(403, 'Signed upload tokens require JWT_USER_SECRET to be configured.', undefined, 'access-denied');
558
+ }
559
+ const verified = await verifySignedToken(token, key, bucketName, secret);
560
+ if (!verified.valid) {
561
+ throw new EdgeBaseError(403, 'Signed upload token is invalid or expired.', undefined, 'access-denied');
562
+ }
563
+ return verified.claims;
564
+ }
565
+
566
+ function requestContentLength(c: Context<HonoEnv>): number | null {
567
+ const header = c.req.header('content-length');
568
+ if (!header) return null;
569
+ const size = Number(header);
570
+ return Number.isFinite(size) && size >= 0 ? Math.floor(size) : null;
571
+ }
572
+
573
+ async function assertSignedMultipartSizeWithinLimit(
574
+ c: Context<HonoEnv>,
575
+ bucketName: string,
576
+ key: string,
577
+ uploadId: string,
578
+ parts: Array<{ partNumber: number; etag: string }>,
579
+ maxBytes: number,
580
+ ) {
581
+ const kvKey = partTrackingKey(bucketName, key, uploadId);
582
+ const existing = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
583
+ const tracked = new Map(
584
+ (existing ?? []).map((part) => [`${part.partNumber}:${part.etag}`, part] as const),
585
+ );
586
+ let total = 0;
587
+ for (const part of parts) {
588
+ const trackedPart = tracked.get(`${part.partNumber}:${part.etag}`);
589
+ if (!trackedPart || typeof trackedPart.size !== 'number' || !Number.isFinite(trackedPart.size)) {
590
+ throw new EdgeBaseError(400, 'Signed multipart upload is missing tracked part size metadata.', undefined, 'validation-failed');
591
+ }
592
+ total += trackedPart.size;
593
+ if (total > maxBytes) {
594
+ throw new EdgeBaseError(413, `Signed upload exceeds maxFileSize of ${maxBytes} bytes.`, undefined, 'payload-too-large');
595
+ }
596
+ }
597
+ }
598
+
392
599
  /** Parse duration string (e.g. '1h', '30m') to milliseconds. Max 7 days. */
393
600
  export function parseDuration(str: string): number {
394
601
  const match = str.match(/^(\d+)(s|m|h|d)$/);
@@ -447,6 +654,85 @@ function buildMetadata(obj: R2Object): R2FileMeta {
447
654
  } as R2FileMeta;
448
655
  }
449
656
 
657
+ function buildMetadataFromSignedFile(key: string, file: SignedTokenFileMetadata): R2FileMeta {
658
+ return {
659
+ key,
660
+ size: file.size,
661
+ contentType: file.contentType || 'application/octet-stream',
662
+ etag: file.etag || '',
663
+ uploadedAt: file.uploadedAt,
664
+ uploadedBy: file.uploadedBy ?? file.customMetadata?.uploadedBy ?? null,
665
+ customMetadata: file.customMetadata || {},
666
+ } as R2FileMeta;
667
+ }
668
+
669
+ function hasBlockingBeforeDownloadHooks(env: Env, bucketName: string): boolean {
670
+ const pluginHooks = getFunctionsByTrigger('storage', { type: 'storage', event: 'beforeDownload' } as unknown as StorageTrigger);
671
+ if (pluginHooks.length > 0) return true;
672
+ return !!getStorageHooks(env, bucketName)?.beforeDownload;
673
+ }
674
+
675
+ function parseDownloadRange(header: string | undefined, size: number): ParsedDownloadRange {
676
+ if (!header) return { kind: 'full' };
677
+ const match = header.trim().match(/^bytes=(.+)$/i);
678
+ if (!match) return { kind: 'full' };
679
+ const range = match[1]?.trim() ?? '';
680
+ if (!range || range.includes(',')) return { kind: 'unsatisfiable' };
681
+
682
+ const [rawStart, rawEnd] = range.split('-', 2);
683
+ if (rawStart === undefined || rawEnd === undefined) return { kind: 'unsatisfiable' };
684
+
685
+ let offset: number;
686
+ let end: number;
687
+
688
+ if (rawStart === '') {
689
+ const suffixLength = Number(rawEnd);
690
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0 || size <= 0) {
691
+ return { kind: 'unsatisfiable' };
692
+ }
693
+ offset = Math.max(size - suffixLength, 0);
694
+ end = size - 1;
695
+ } else {
696
+ const start = Number(rawStart);
697
+ const requestedEnd = rawEnd === '' ? size - 1 : Number(rawEnd);
698
+ if (
699
+ !Number.isSafeInteger(start)
700
+ || !Number.isSafeInteger(requestedEnd)
701
+ || start < 0
702
+ || requestedEnd < start
703
+ || start >= size
704
+ ) {
705
+ return { kind: 'unsatisfiable' };
706
+ }
707
+ offset = start;
708
+ end = Math.min(requestedEnd, size - 1);
709
+ }
710
+
711
+ return { kind: 'partial', offset, end, length: end - offset + 1 };
712
+ }
713
+
714
+ function createDownloadHeaders(meta: R2FileMeta, signedClaims?: SignedTokenClaims | null): Headers {
715
+ const headers = new Headers();
716
+ headers.set('Content-Type', meta.contentType || 'application/octet-stream');
717
+ if (meta.etag) {
718
+ headers.set('ETag', meta.etag);
719
+ }
720
+ headers.set('Accept-Ranges', 'bytes');
721
+ if (meta.uploadedAt) {
722
+ const uploadedAt = new Date(meta.uploadedAt);
723
+ if (!Number.isNaN(uploadedAt.getTime())) {
724
+ headers.set('Last-Modified', uploadedAt.toUTCString());
725
+ }
726
+ }
727
+ if (signedClaims) {
728
+ const maxAge = Math.max(0, Math.floor((signedClaims.expiresAt - Date.now()) / 1000));
729
+ if (maxAge > 0) {
730
+ headers.set('Cache-Control', `private, max-age=${Math.min(maxAge, 3600)}`);
731
+ }
732
+ }
733
+ return headers;
734
+ }
735
+
450
736
  const STORAGE_OFFSET_CURSOR_PREFIX = 'offset:';
451
737
 
452
738
  function parseStorageListInteger(raw: string | undefined, name: string, fallback: number): number {
@@ -616,12 +902,17 @@ const uploadFile = createRoute({
616
902
  summary: 'Upload file',
617
903
  request: {
618
904
  params: z.object({ bucket: z.string() }),
905
+ query: z.object({
906
+ key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
907
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
908
+ }),
619
909
  body: { content: { 'multipart/form-data': { schema: z.object({}).passthrough() } }, required: true },
620
910
  },
621
911
  responses: {
622
912
  201: { description: 'File uploaded', content: { 'application/json': { schema: jsonResponseSchema } } },
623
913
  400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
624
914
  403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
915
+ 413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
625
916
  },
626
917
  });
627
918
 
@@ -908,7 +1199,10 @@ const getUploadParts = createRoute({
908
1199
  summary: 'Get uploaded parts',
909
1200
  request: {
910
1201
  params: z.object({ bucket: z.string(), uploadId: z.string() }),
911
- query: z.object({ key: z.string() }),
1202
+ query: z.object({
1203
+ key: z.string(),
1204
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
1205
+ }),
912
1206
  },
913
1207
  responses: {
914
1208
  200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
@@ -928,14 +1222,17 @@ storage.openapi(getUploadParts, async (c) => {
928
1222
  }
929
1223
 
930
1224
  // Security: check write rule (resume upload = write operation)
931
- const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
932
- if (!serviceKeyBypass) {
1225
+ const signedClaims = await verifySignedUploadQuery(c, bucketName, key);
1226
+ const serviceKeyBypass = signedClaims
1227
+ ? false
1228
+ : checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1229
+ if (!signedClaims && !serviceKeyBypass) {
933
1230
  const auth = c.get('auth') as AuthContext | null;
934
1231
  checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
935
1232
  }
936
1233
 
937
1234
  const kvKey = partTrackingKey(bucketName, key, uploadId);
938
- const parts = await c.env.KV.get(kvKey, 'json') as Array<{ partNumber: number; etag: string }> | null;
1235
+ const parts = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
939
1236
 
940
1237
  return c.json({
941
1238
  uploadId,
@@ -955,8 +1252,10 @@ const downloadFile = createRoute({
955
1252
  },
956
1253
  responses: {
957
1254
  200: { description: 'File content' },
1255
+ 206: { description: 'Partial file content' },
958
1256
  403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
959
1257
  404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1258
+ 416: { description: 'Range not satisfiable' },
960
1259
  },
961
1260
  });
962
1261
 
@@ -968,6 +1267,7 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
968
1267
  // Check for signed URL token
969
1268
  const token = c.req.query('token');
970
1269
  let skipRules = false;
1270
+ let signedClaims: SignedTokenClaims | null = null;
971
1271
 
972
1272
  if (token) {
973
1273
  // Asymmetric fail-closed (#99): secret 미설정 시 토큰 무시 → 보안 규칙으로 fallback
@@ -976,14 +1276,31 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
976
1276
  const verified = await verifySignedToken(token, key, bucketName, secret);
977
1277
  if (verified.valid) {
978
1278
  skipRules = true;
1279
+ signedClaims = verified.claims;
979
1280
  }
980
1281
  }
981
1282
  }
982
1283
 
983
1284
  const fullKey = r2Key(bucketName, key);
984
- const obj = await c.env.STORAGE.get(fullKey);
985
- if (!obj) {
986
- throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
1285
+ const rangeHeader = c.req.header('Range') ?? c.req.header('range');
1286
+ const isByteRangeRequest = !!rangeHeader && /^bytes=/i.test(rangeHeader.trim());
1287
+ const signedMetadataAllowed = skipRules && isByteRangeRequest && !!signedClaims?.file && !hasBlockingBeforeDownloadHooks(c.env, bucketName);
1288
+ let fileMeta: R2FileMeta | null = signedMetadataAllowed && signedClaims?.file
1289
+ ? buildMetadataFromSignedFile(key, signedClaims.file)
1290
+ : null;
1291
+ let bodyObj: R2ObjectBody | null = null;
1292
+
1293
+ if (!fileMeta) {
1294
+ const file = rangeHeader
1295
+ ? await c.env.STORAGE.head(fullKey)
1296
+ : await c.env.STORAGE.get(fullKey);
1297
+ if (!file) {
1298
+ throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
1299
+ }
1300
+ fileMeta = buildMetadata(file);
1301
+ if (!rangeHeader) {
1302
+ bodyObj = file as R2ObjectBody;
1303
+ }
987
1304
  }
988
1305
 
989
1306
  // Security: check read rule
@@ -991,41 +1308,57 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
991
1308
  const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:read`, c.req);
992
1309
  if (!serviceKeyBypass) {
993
1310
  const auth = c.get('auth') as AuthContext | null;
994
- const resource = buildMetadata(obj);
995
- checkStorageRule(bucketConfig.access?.read, auth, resource, 'read', bucketName, release);
1311
+ checkStorageRule(bucketConfig.access?.read, auth, fileMeta, 'read', bucketName, release);
996
1312
  }
997
1313
  }
998
1314
 
999
1315
  // Plugin blocking storage hooks (beforeDownload)
1000
1316
  {
1001
1317
  const dlAuth = c.get('auth') as AuthContext | null;
1002
- const dlMeta = buildMetadata(obj);
1003
- await executeBlockingStorageHooks('beforeDownload', { ...dlMeta, bucket: bucketName }, dlAuth, c.env, getWorkerUrl(c.req.url, c.env));
1318
+ await executeBlockingStorageHooks('beforeDownload', { ...fileMeta, bucket: bucketName }, dlAuth, c.env, getWorkerUrl(c.req.url, c.env));
1004
1319
  }
1005
1320
 
1006
1321
  // beforeDownload hook — blocking, throw to reject
1007
1322
  const hooks = getStorageHooks(c.env, bucketName);
1008
1323
  if (hooks?.beforeDownload) {
1009
1324
  const auth = c.get('auth') as AuthContext | null;
1010
- const meta = buildMetadata(obj);
1011
1325
  const hookCtx = buildStorageHookCtx(c.env, c.executionCtx, getWorkerUrl(c.req.url, c.env));
1012
1326
  try {
1013
- await hooks.beforeDownload(auth, meta, hookCtx);
1327
+ await hooks.beforeDownload(auth, fileMeta, hookCtx);
1014
1328
  } catch (error) {
1015
1329
  throw normalizeStorageHookError(error, 'beforeDownload');
1016
1330
  }
1017
1331
  }
1018
1332
 
1019
- // Stream response
1020
- const headers = new Headers();
1021
- headers.set('Content-Type', obj.httpMetadata?.contentType || 'application/octet-stream');
1022
- headers.set('Content-Length', String(obj.size));
1023
- headers.set('ETag', obj.etag);
1024
- if (obj.uploaded) {
1025
- headers.set('Last-Modified', obj.uploaded.toUTCString());
1333
+ const parsedRange = parseDownloadRange(rangeHeader, fileMeta.size);
1334
+ const headers = createDownloadHeaders(fileMeta, skipRules ? signedClaims : null);
1335
+
1336
+ if (parsedRange.kind === 'unsatisfiable') {
1337
+ headers.set('Content-Range', `bytes */${fileMeta.size}`);
1338
+ headers.set('Content-Length', '0');
1339
+ return new Response(null, { status: 416, headers });
1340
+ }
1341
+
1342
+ if (parsedRange.kind === 'partial') {
1343
+ const rangedObj = await c.env.STORAGE.get(fullKey, {
1344
+ range: { offset: parsedRange.offset, length: parsedRange.length },
1345
+ });
1346
+ if (!rangedObj) {
1347
+ throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
1348
+ }
1349
+ headers.set('Content-Length', String(parsedRange.length));
1350
+ headers.set('Content-Range', `bytes ${parsedRange.offset}-${parsedRange.end}/${fileMeta.size}`);
1351
+ return new Response(rangedObj.body, { status: 206, headers });
1026
1352
  }
1027
1353
 
1028
- return new Response(obj.body, { headers });
1354
+ if (!bodyObj) {
1355
+ bodyObj = await c.env.STORAGE.get(fullKey);
1356
+ if (!bodyObj) {
1357
+ throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
1358
+ }
1359
+ }
1360
+ headers.set('Content-Length', String(fileMeta.size));
1361
+ return new Response(bodyObj.body, { headers });
1029
1362
  };
1030
1363
  storage.openapi(downloadFile, handleDownloadFile);
1031
1364
 
@@ -1298,7 +1631,9 @@ storage.openapi(createSignedDownloadUrl, async (c) => {
1298
1631
  if (!secret) {
1299
1632
  throw new EdgeBaseError(500, 'Signed URLs require JWT_USER_SECRET to be configured.', undefined, 'internal-error');
1300
1633
  }
1301
- const token = await createSignedToken(body.key, bucketName, expiresAt, secret);
1634
+ const token = await createSignedToken(body.key, bucketName, expiresAt, secret, {
1635
+ file: signedFileMetadataFromObject(obj),
1636
+ });
1302
1637
 
1303
1638
  // Build signed URL
1304
1639
  const url = new URL(c.req.url);
@@ -1362,7 +1697,9 @@ storage.openapi(createSignedDownloadUrls, async (c) => {
1362
1697
  const obj = await c.env.STORAGE.head(fullKey);
1363
1698
  if (!obj) continue; // skip non-existent files
1364
1699
 
1365
- const token = await createSignedToken(key, bucketName, expiresAt, secret);
1700
+ const token = await createSignedToken(key, bucketName, expiresAt, secret, {
1701
+ file: signedFileMetadataFromObject(obj),
1702
+ });
1366
1703
  urls.push({
1367
1704
  key,
1368
1705
  url: `${url.protocol}//${url.host}/api/storage/${encodeURIComponent(bucketName)}/${encodeURIComponent(key)}?token=${token}`,
@@ -1445,6 +1782,10 @@ const createMultipartUpload = createRoute({
1445
1782
  summary: 'Start multipart upload',
1446
1783
  request: {
1447
1784
  params: z.object({ bucket: z.string() }),
1785
+ query: z.object({
1786
+ key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
1787
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
1788
+ }),
1448
1789
  body: { content: { 'application/json': { schema: z.object({ key: z.string(), contentType: z.string().optional(), customMetadata: z.record(z.string(), z.string()).optional() }) } }, required: true },
1449
1790
  },
1450
1791
  responses: {
@@ -1458,19 +1799,22 @@ storage.openapi(createMultipartUpload, async (c) => {
1458
1799
  const bucketName = c.req.param('bucket')!;
1459
1800
  const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
1460
1801
 
1461
- // Security: check write rule
1462
- const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1463
- if (!serviceKeyBypass) {
1464
- const auth = c.get('auth') as AuthContext | null;
1465
- checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1466
- }
1467
-
1468
1802
  const body = await c.req.json<{ key: string; contentType?: string; customMetadata?: Record<string, string> }>();
1469
1803
  if (!body.key) {
1470
1804
  throw new EdgeBaseError(400, 'Missing required field: key.', undefined, 'validation-failed');
1471
1805
  }
1472
1806
  validateStorageKey(body.key);
1473
1807
 
1808
+ // Security: check signed upload token or write rule
1809
+ const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
1810
+ const serviceKeyBypass = signedClaims
1811
+ ? false
1812
+ : checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1813
+ if (!signedClaims && !serviceKeyBypass) {
1814
+ const auth = c.get('auth') as AuthContext | null;
1815
+ checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1816
+ }
1817
+
1474
1818
  const auth = c.get('auth') as AuthContext | null;
1475
1819
  const customMetadata = body.customMetadata || {};
1476
1820
  if (auth?.id) {
@@ -1497,12 +1841,19 @@ const uploadPart = createRoute({
1497
1841
  summary: 'Upload a part',
1498
1842
  request: {
1499
1843
  params: z.object({ bucket: z.string() }),
1500
- body: { content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } }, required: true },
1844
+ query: z.object({
1845
+ uploadId: z.string(),
1846
+ partNumber: z.string(),
1847
+ key: z.string(),
1848
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
1849
+ }),
1850
+ body: { content: { 'application/octet-stream': { schema: z.string().openapi({ format: 'binary' }) } }, required: true },
1501
1851
  },
1502
1852
  responses: {
1503
1853
  200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1504
1854
  400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1505
1855
  403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
1856
+ 413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
1506
1857
  },
1507
1858
  });
1508
1859
 
@@ -1510,13 +1861,6 @@ storage.openapi(uploadPart, async (c) => {
1510
1861
  const bucketName = c.req.param('bucket')!;
1511
1862
  const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
1512
1863
 
1513
- // Security: check write rule
1514
- const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1515
- if (!serviceKeyBypass) {
1516
- const auth = c.get('auth') as AuthContext | null;
1517
- checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1518
- }
1519
-
1520
1864
  const uploadId = c.req.query('uploadId');
1521
1865
  const partNumber = parseInt(c.req.query('partNumber') || '0', 10);
1522
1866
  const key = c.req.query('key');
@@ -1524,6 +1868,24 @@ storage.openapi(uploadPart, async (c) => {
1524
1868
  if (!uploadId || !partNumber || !key) {
1525
1869
  throw new EdgeBaseError(400, 'Missing required query params: uploadId, partNumber, key.', undefined, 'validation-failed');
1526
1870
  }
1871
+ validateStorageKey(key);
1872
+
1873
+ // Security: check signed upload token or write rule
1874
+ const signedClaims = await verifySignedUploadQuery(c, bucketName, key);
1875
+ const serviceKeyBypass = signedClaims
1876
+ ? false
1877
+ : checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1878
+ if (!signedClaims && !serviceKeyBypass) {
1879
+ const auth = c.get('auth') as AuthContext | null;
1880
+ checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1881
+ }
1882
+ const partSize = requestContentLength(c);
1883
+ if (signedClaims?.maxBytes != null && partSize == null) {
1884
+ throw new EdgeBaseError(400, 'Signed multipart upload parts require Content-Length.', undefined, 'validation-failed');
1885
+ }
1886
+ if (signedClaims?.maxBytes != null && partSize !== null && partSize > signedClaims.maxBytes) {
1887
+ throw new EdgeBaseError(413, `Signed upload exceeds maxFileSize of ${signedClaims.maxBytes} bytes.`, undefined, 'payload-too-large');
1888
+ }
1527
1889
 
1528
1890
  const fullKey = r2Key(bucketName, key);
1529
1891
  const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, uploadId);
@@ -1532,14 +1894,16 @@ storage.openapi(uploadPart, async (c) => {
1532
1894
 
1533
1895
  // M17: Save part info to KV for resume tracking
1534
1896
  const kvKey = partTrackingKey(bucketName, key, uploadId);
1535
- const existing = await c.env.KV.get(kvKey, 'json') as Array<{ partNumber: number; etag: string }> | null;
1897
+ const existing = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
1536
1898
  const parts = existing || [];
1537
1899
  // Replace if same partNumber exists (re-upload), otherwise append
1538
1900
  const idx = parts.findIndex(p => p.partNumber === part.partNumber);
1901
+ const trackedPart: TrackedMultipartPart = { partNumber: part.partNumber, etag: part.etag };
1902
+ if (partSize !== null) trackedPart.size = partSize;
1539
1903
  if (idx >= 0) {
1540
- parts[idx] = { partNumber: part.partNumber, etag: part.etag };
1904
+ parts[idx] = trackedPart;
1541
1905
  } else {
1542
- parts.push({ partNumber: part.partNumber, etag: part.etag });
1906
+ parts.push(trackedPart);
1543
1907
  }
1544
1908
  await c.env.KV.put(kvKey, JSON.stringify(parts), { expirationTtl: PART_TRACKING_TTL });
1545
1909
 
@@ -1557,12 +1921,17 @@ const completeMultipartUpload = createRoute({
1557
1921
  summary: 'Complete multipart upload',
1558
1922
  request: {
1559
1923
  params: z.object({ bucket: z.string() }),
1924
+ query: z.object({
1925
+ key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
1926
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
1927
+ }),
1560
1928
  body: { content: { 'application/json': { schema: z.object({ uploadId: z.string(), key: z.string(), parts: z.array(z.object({ partNumber: z.number(), etag: z.string() })) }) } }, required: true },
1561
1929
  },
1562
1930
  responses: {
1563
1931
  200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1564
1932
  400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1565
1933
  403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
1934
+ 413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
1566
1935
  },
1567
1936
  });
1568
1937
 
@@ -1570,13 +1939,6 @@ storage.openapi(completeMultipartUpload, async (c) => {
1570
1939
  const bucketName = c.req.param('bucket')!;
1571
1940
  const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
1572
1941
 
1573
- // Security: check write rule
1574
- const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1575
- if (!serviceKeyBypass) {
1576
- const auth = c.get('auth') as AuthContext | null;
1577
- checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1578
- }
1579
-
1580
1942
  const body = await c.req.json<{
1581
1943
  uploadId: string;
1582
1944
  key: string;
@@ -1586,6 +1948,20 @@ storage.openapi(completeMultipartUpload, async (c) => {
1586
1948
  if (!body.uploadId || !body.key || !body.parts?.length) {
1587
1949
  throw new EdgeBaseError(400, 'Missing required fields: uploadId, key, parts.', undefined, 'validation-failed');
1588
1950
  }
1951
+ validateStorageKey(body.key);
1952
+
1953
+ // Security: check signed upload token or write rule
1954
+ const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
1955
+ const serviceKeyBypass = signedClaims
1956
+ ? false
1957
+ : checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1958
+ if (!signedClaims && !serviceKeyBypass) {
1959
+ const auth = c.get('auth') as AuthContext | null;
1960
+ checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1961
+ }
1962
+ if (signedClaims?.maxBytes != null) {
1963
+ await assertSignedMultipartSizeWithinLimit(c, bucketName, body.key, body.uploadId, body.parts, signedClaims.maxBytes);
1964
+ }
1589
1965
 
1590
1966
  const fullKey = r2Key(bucketName, body.key);
1591
1967
  const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, body.uploadId);
@@ -1610,6 +1986,10 @@ const abortMultipartUpload = createRoute({
1610
1986
  summary: 'Abort multipart upload',
1611
1987
  request: {
1612
1988
  params: z.object({ bucket: z.string() }),
1989
+ query: z.object({
1990
+ key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
1991
+ token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
1992
+ }),
1613
1993
  body: { content: { 'application/json': { schema: z.object({ uploadId: z.string(), key: z.string() }) } }, required: true },
1614
1994
  },
1615
1995
  responses: {
@@ -1623,17 +2003,21 @@ storage.openapi(abortMultipartUpload, async (c) => {
1623
2003
  const bucketName = c.req.param('bucket')!;
1624
2004
  const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
1625
2005
 
1626
- // Security: check write rule
1627
- const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
1628
- if (!serviceKeyBypass) {
1629
- const auth = c.get('auth') as AuthContext | null;
1630
- checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
1631
- }
1632
-
1633
2006
  const body = await c.req.json<{ uploadId: string; key: string }>();
1634
2007
  if (!body.uploadId || !body.key) {
1635
2008
  throw new EdgeBaseError(400, 'Missing required fields: uploadId, key.', undefined, 'validation-failed');
1636
2009
  }
2010
+ validateStorageKey(body.key);
2011
+
2012
+ // Security: check signed upload token or write rule
2013
+ const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
2014
+ const serviceKeyBypass = signedClaims
2015
+ ? false
2016
+ : checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
2017
+ if (!signedClaims && !serviceKeyBypass) {
2018
+ const auth = c.get('auth') as AuthContext | null;
2019
+ checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
2020
+ }
1637
2021
 
1638
2022
  const fullKey = r2Key(bucketName, body.key);
1639
2023
  const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, body.uploadId);