@beignet/core 0.0.31 → 0.0.33

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 (156) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +346 -21
  3. package/dist/application/index.d.ts.map +1 -1
  4. package/dist/application/index.js +63 -47
  5. package/dist/application/index.js.map +1 -1
  6. package/dist/domain/entity.d.ts +37 -6
  7. package/dist/domain/entity.d.ts.map +1 -1
  8. package/dist/domain/entity.js +9 -7
  9. package/dist/domain/entity.js.map +1 -1
  10. package/dist/domain/index.d.ts +4 -4
  11. package/dist/domain/index.d.ts.map +1 -1
  12. package/dist/domain/index.js +4 -4
  13. package/dist/domain/index.js.map +1 -1
  14. package/dist/events/index.d.ts +6 -0
  15. package/dist/events/index.d.ts.map +1 -1
  16. package/dist/events/index.js +20 -10
  17. package/dist/events/index.js.map +1 -1
  18. package/dist/flags/index.d.ts +5 -4
  19. package/dist/flags/index.d.ts.map +1 -1
  20. package/dist/flags/index.js +10 -9
  21. package/dist/flags/index.js.map +1 -1
  22. package/dist/idempotency/index.d.ts +11 -1
  23. package/dist/idempotency/index.d.ts.map +1 -1
  24. package/dist/idempotency/index.js +4 -3
  25. package/dist/idempotency/index.js.map +1 -1
  26. package/dist/jobs/index.d.ts +449 -9
  27. package/dist/jobs/index.d.ts.map +1 -1
  28. package/dist/jobs/index.js +472 -12
  29. package/dist/jobs/index.js.map +1 -1
  30. package/dist/outbox/index.d.ts +170 -3
  31. package/dist/outbox/index.d.ts.map +1 -1
  32. package/dist/outbox/index.js +192 -8
  33. package/dist/outbox/index.js.map +1 -1
  34. package/dist/ports/events.d.ts +2 -2
  35. package/dist/ports/index.d.ts +6 -1
  36. package/dist/ports/index.d.ts.map +1 -1
  37. package/dist/ports/index.js +1 -0
  38. package/dist/ports/index.js.map +1 -1
  39. package/dist/providers/instrumentation.d.ts +4 -0
  40. package/dist/providers/instrumentation.d.ts.map +1 -1
  41. package/dist/providers/instrumentation.js.map +1 -1
  42. package/dist/providers/provider.d.ts +10 -2
  43. package/dist/providers/provider.d.ts.map +1 -1
  44. package/dist/providers/provider.js.map +1 -1
  45. package/dist/schedules/index.d.ts +8 -7
  46. package/dist/schedules/index.d.ts.map +1 -1
  47. package/dist/schedules/index.js +47 -16
  48. package/dist/schedules/index.js.map +1 -1
  49. package/dist/search/index.d.ts +2 -1
  50. package/dist/search/index.d.ts.map +1 -1
  51. package/dist/search/index.js +1 -0
  52. package/dist/search/index.js.map +1 -1
  53. package/dist/server/hooks/index.d.ts +1 -0
  54. package/dist/server/hooks/index.d.ts.map +1 -1
  55. package/dist/server/hooks/index.js +1 -0
  56. package/dist/server/hooks/index.js.map +1 -1
  57. package/dist/server/hooks/rate-limit.d.ts +26 -13
  58. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  59. package/dist/server/hooks/rate-limit.js +28 -34
  60. package/dist/server/hooks/rate-limit.js.map +1 -1
  61. package/dist/server/hooks/security.d.ts +179 -0
  62. package/dist/server/hooks/security.d.ts.map +1 -0
  63. package/dist/server/hooks/security.js +225 -0
  64. package/dist/server/hooks/security.js.map +1 -0
  65. package/dist/server/index.d.ts +8 -0
  66. package/dist/server/index.d.ts.map +1 -1
  67. package/dist/server/index.js +8 -0
  68. package/dist/server/index.js.map +1 -1
  69. package/dist/server/instrumentation.d.ts.map +1 -1
  70. package/dist/server/instrumentation.js +22 -6
  71. package/dist/server/instrumentation.js.map +1 -1
  72. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  73. package/dist/server/providers/loadProviderConfig.js +15 -2
  74. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  75. package/dist/server/request-context.d.ts +4 -0
  76. package/dist/server/request-context.d.ts.map +1 -1
  77. package/dist/server/request-context.js +3 -0
  78. package/dist/server/request-context.js.map +1 -1
  79. package/dist/server/runtime-integrity.d.ts +84 -0
  80. package/dist/server/runtime-integrity.d.ts.map +1 -0
  81. package/dist/server/runtime-integrity.js +160 -0
  82. package/dist/server/runtime-integrity.js.map +1 -0
  83. package/dist/server/server.d.ts +10 -0
  84. package/dist/server/server.d.ts.map +1 -1
  85. package/dist/server/server.js +55 -1
  86. package/dist/server/server.js.map +1 -1
  87. package/dist/server/trusted-proxy.d.ts +77 -0
  88. package/dist/server/trusted-proxy.d.ts.map +1 -0
  89. package/dist/server/trusted-proxy.js +129 -0
  90. package/dist/server/trusted-proxy.js.map +1 -0
  91. package/dist/tasks/index.d.ts +6 -7
  92. package/dist/tasks/index.d.ts.map +1 -1
  93. package/dist/tasks/index.js +22 -5
  94. package/dist/tasks/index.js.map +1 -1
  95. package/dist/tenancy/index.d.ts +41 -0
  96. package/dist/tenancy/index.d.ts.map +1 -0
  97. package/dist/tenancy/index.js +34 -0
  98. package/dist/tenancy/index.js.map +1 -0
  99. package/dist/testing/index.d.ts +6 -0
  100. package/dist/testing/index.d.ts.map +1 -1
  101. package/dist/testing/index.js +12 -3
  102. package/dist/testing/index.js.map +1 -1
  103. package/dist/tracing/execution.d.ts +14 -0
  104. package/dist/tracing/execution.d.ts.map +1 -0
  105. package/dist/tracing/execution.js +17 -0
  106. package/dist/tracing/execution.js.map +1 -0
  107. package/dist/tracing/index.d.ts +49 -0
  108. package/dist/tracing/index.d.ts.map +1 -1
  109. package/dist/tracing/index.js +59 -0
  110. package/dist/tracing/index.js.map +1 -1
  111. package/dist/uploads/client.d.ts.map +1 -1
  112. package/dist/uploads/client.js +44 -6
  113. package/dist/uploads/client.js.map +1 -1
  114. package/dist/uploads/index.d.ts +182 -2
  115. package/dist/uploads/index.d.ts.map +1 -1
  116. package/dist/uploads/index.js +468 -76
  117. package/dist/uploads/index.js.map +1 -1
  118. package/dist/webhooks/index.d.ts +56 -4
  119. package/dist/webhooks/index.d.ts.map +1 -1
  120. package/dist/webhooks/index.js +107 -2
  121. package/dist/webhooks/index.js.map +1 -1
  122. package/package.json +5 -1
  123. package/skills/app-architecture/SKILL.md +23 -2
  124. package/src/application/index.ts +88 -56
  125. package/src/domain/entity.ts +61 -13
  126. package/src/domain/index.ts +8 -7
  127. package/src/events/index.ts +27 -14
  128. package/src/flags/index.ts +23 -19
  129. package/src/idempotency/index.ts +17 -3
  130. package/src/jobs/index.ts +1022 -19
  131. package/src/outbox/index.ts +409 -9
  132. package/src/ports/events.ts +2 -2
  133. package/src/ports/index.ts +17 -0
  134. package/src/providers/instrumentation.ts +4 -0
  135. package/src/providers/provider.ts +11 -2
  136. package/src/schedules/index.ts +60 -26
  137. package/src/search/index.ts +3 -1
  138. package/src/server/hooks/index.ts +10 -0
  139. package/src/server/hooks/rate-limit.ts +52 -46
  140. package/src/server/hooks/security.ts +503 -0
  141. package/src/server/index.ts +8 -0
  142. package/src/server/instrumentation.ts +25 -5
  143. package/src/server/providers/loadProviderConfig.ts +19 -2
  144. package/src/server/request-context.ts +7 -0
  145. package/src/server/runtime-integrity.ts +322 -0
  146. package/src/server/server.ts +86 -1
  147. package/src/server/trusted-proxy.ts +249 -0
  148. package/src/tasks/index.ts +29 -12
  149. package/src/tenancy/index.ts +55 -0
  150. package/src/testing/index.ts +19 -3
  151. package/src/tracing/execution.ts +37 -0
  152. package/src/tracing/index.ts +137 -0
  153. package/src/uploads/client.ts +65 -6
  154. package/src/uploads/index.ts +713 -76
  155. package/src/webhooks/index.ts +240 -9
  156. package/src/domain/events.ts +0 -59
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type {
3
3
  StorageMetadata,
4
4
  StorageObject,
5
+ StorageObjectBody,
5
6
  StoragePort,
6
7
  } from "../ports/index.js";
7
8
  import type { ProviderInstrumentationTarget } from "../providers/index.js";
@@ -39,6 +40,46 @@ export interface UploadFileIntent {
39
40
  * File size in bytes.
40
41
  */
41
42
  size: number;
43
+ /**
44
+ * Optional expected checksum for this file. Direct uploads use this to
45
+ * verify the object bytes during completion.
46
+ */
47
+ checksum?: UploadFileChecksum;
48
+ }
49
+
50
+ /**
51
+ * Checksum algorithms supported by Beignet upload verification.
52
+ */
53
+ export type UploadChecksumAlgorithm = "sha256";
54
+
55
+ /**
56
+ * Expected or computed file checksum.
57
+ */
58
+ export interface UploadFileChecksum {
59
+ /**
60
+ * Hash algorithm used for the checksum.
61
+ */
62
+ algorithm: UploadChecksumAlgorithm;
63
+ /**
64
+ * Lowercase hex digest.
65
+ */
66
+ value: string;
67
+ }
68
+
69
+ /**
70
+ * Checksum verification requested by an upload definition.
71
+ */
72
+ export interface UploadChecksumRequirement {
73
+ /**
74
+ * Hash algorithm to compute.
75
+ */
76
+ algorithm: UploadChecksumAlgorithm;
77
+ /**
78
+ * Whether direct upload preparation/completion must include a checksum.
79
+ *
80
+ * @default true
81
+ */
82
+ required?: boolean;
42
83
  }
43
84
 
44
85
  /**
@@ -69,6 +110,18 @@ export interface UploadFileConstraints {
69
110
  * Cache-Control value written to storage.
70
111
  */
71
112
  cacheControl?: string;
113
+ /**
114
+ * Verify supported MIME types against file signatures instead of trusting
115
+ * only the client-declared content type.
116
+ *
117
+ * @default "signature"
118
+ */
119
+ contentTypeVerification?: "signature" | false;
120
+ /**
121
+ * Request checksum verification for direct uploads and expose the requirement
122
+ * through the upload manifest so browser clients can send expected digests.
123
+ */
124
+ checksum?: UploadChecksumRequirement;
72
125
  }
73
126
 
74
127
  /**
@@ -223,6 +276,28 @@ export interface UploadCompleteHookArgs<Metadata, Ctx>
223
276
  files: CompletedUploadFile[];
224
277
  }
225
278
 
279
+ /**
280
+ * Arguments passed to `verifyFile(...)` after a file exists in storage and
281
+ * before `onComplete(...)` runs.
282
+ */
283
+ export interface UploadVerifyFileHookArgs<Metadata, Ctx>
284
+ extends UploadHookArgs<Metadata, Ctx> {
285
+ file: CompletedUploadFile;
286
+ storage: StoragePort;
287
+ }
288
+
289
+ /**
290
+ * Verification result accepted by `verifyFile(...)`.
291
+ */
292
+ export type UploadFileVerificationResult =
293
+ | boolean
294
+ | undefined
295
+ | {
296
+ valid: boolean;
297
+ reason?: string;
298
+ details?: unknown;
299
+ };
300
+
226
301
  /**
227
302
  * Upload definition options.
228
303
  */
@@ -267,6 +342,13 @@ export interface DefineUploadOptions<
267
342
  storageMetadata?(
268
343
  args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
269
344
  ): MaybePromise<StorageMetadata>;
345
+ /**
346
+ * Run after a file exists in storage and before `onComplete(...)`. Use this
347
+ * for app-owned scanning, moderation, or quarantine decisions.
348
+ */
349
+ verifyFile?(
350
+ args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
351
+ ): MaybePromise<UploadFileVerificationResult>;
270
352
  /**
271
353
  * Run after files exist in storage.
272
354
  */
@@ -299,6 +381,9 @@ export interface UploadDef<
299
381
  storageMetadata?(
300
382
  args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
301
383
  ): MaybePromise<StorageMetadata>;
384
+ verifyFile?(
385
+ args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
386
+ ): MaybePromise<UploadFileVerificationResult>;
302
387
  onComplete?(
303
388
  args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
304
389
  ): MaybePromise<Result>;
@@ -452,14 +537,19 @@ export type UploadErrorCode =
452
537
  | "INVALID_UPLOAD_BODY";
453
538
 
454
539
  /**
455
- * Error thrown for expected upload failures.
540
+ * Base error for expected upload failures.
541
+ *
542
+ * Upload workflows throw one of the specific subclasses, such as
543
+ * {@link UploadNotFoundError} or {@link UnauthorizedUploadError}. Catch this
544
+ * base class to handle any expected upload failure; the upload router maps it
545
+ * to an HTTP response using `code`, `status`, and `details`.
456
546
  */
457
547
  export class UploadError extends Error {
458
548
  readonly code: UploadErrorCode;
459
549
  readonly status: number;
460
550
  readonly details?: unknown;
461
551
 
462
- constructor(args: {
552
+ protected constructor(args: {
463
553
  code: UploadErrorCode;
464
554
  message: string;
465
555
  status?: number;
@@ -473,6 +563,111 @@ export class UploadError extends Error {
473
563
  }
474
564
  }
475
565
 
566
+ /**
567
+ * Error thrown when an upload name is not registered on the router.
568
+ */
569
+ export class UploadNotFoundError extends UploadError {
570
+ declare readonly code: "UPLOAD_NOT_FOUND";
571
+
572
+ constructor(args: { message: string; details?: unknown }) {
573
+ super({ code: "UPLOAD_NOT_FOUND", status: 404, ...args });
574
+ this.name = "UploadNotFoundError";
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Error thrown when an upload request targets an unknown upload action.
580
+ */
581
+ export class InvalidUploadActionError extends UploadError {
582
+ declare readonly code: "INVALID_UPLOAD_ACTION";
583
+
584
+ constructor(args: { message: string; details?: unknown }) {
585
+ super({ code: "INVALID_UPLOAD_ACTION", status: 400, ...args });
586
+ this.name = "InvalidUploadActionError";
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Error thrown when upload metadata fails schema validation.
592
+ */
593
+ export class InvalidUploadMetadataError extends UploadError {
594
+ declare readonly code: "INVALID_UPLOAD_METADATA";
595
+
596
+ constructor(args: { message: string; details?: unknown }) {
597
+ super({ code: "INVALID_UPLOAD_METADATA", status: 422, ...args });
598
+ this.name = "InvalidUploadMetadataError";
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Error thrown when a file fails upload validation, such as file count,
604
+ * content type (415), size limits (413), or prepared-file mismatches.
605
+ */
606
+ export class InvalidUploadFileError extends UploadError {
607
+ declare readonly code: "INVALID_UPLOAD_FILE";
608
+
609
+ constructor(args: { message: string; details?: unknown; status?: number }) {
610
+ super({
611
+ code: "INVALID_UPLOAD_FILE",
612
+ message: args.message,
613
+ status: args.status ?? 422,
614
+ details: args.details,
615
+ });
616
+ this.name = "InvalidUploadFileError";
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Error thrown when an upload is denied by authorize(...) or lacks
622
+ * authorization configuration.
623
+ */
624
+ export class UnauthorizedUploadError extends UploadError {
625
+ declare readonly code: "UNAUTHORIZED_UPLOAD";
626
+
627
+ constructor(args: { message: string; details?: unknown }) {
628
+ super({ code: "UNAUTHORIZED_UPLOAD", status: 403, ...args });
629
+ this.name = "UnauthorizedUploadError";
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Error thrown when a completed upload references a stored object that does
635
+ * not exist.
636
+ */
637
+ export class UploadObjectNotFoundError extends UploadError {
638
+ declare readonly code: "UPLOAD_OBJECT_NOT_FOUND";
639
+
640
+ constructor(args: { message: string; details?: unknown }) {
641
+ super({ code: "UPLOAD_OBJECT_NOT_FOUND", status: 404, ...args });
642
+ this.name = "UploadObjectNotFoundError";
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Error thrown when an upload request body exceeds the configured size limit.
648
+ */
649
+ export class UploadBodyTooLargeError extends UploadError {
650
+ declare readonly code: "UPLOAD_BODY_TOO_LARGE";
651
+
652
+ constructor(args: { message: string; details?: unknown }) {
653
+ super({ code: "UPLOAD_BODY_TOO_LARGE", status: 413, ...args });
654
+ this.name = "UploadBodyTooLargeError";
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Error thrown when an upload request body cannot be parsed or has an
660
+ * invalid shape.
661
+ */
662
+ export class InvalidUploadBodyError extends UploadError {
663
+ declare readonly code: "INVALID_UPLOAD_BODY";
664
+
665
+ constructor(args: { message: string; details?: unknown }) {
666
+ super({ code: "INVALID_UPLOAD_BODY", status: 400, ...args });
667
+ this.name = "InvalidUploadBodyError";
668
+ }
669
+ }
670
+
476
671
  /**
477
672
  * Define a typed upload workflow.
478
673
  */
@@ -503,10 +698,52 @@ export function defineUpload<
503
698
  ...(options.storageMetadata
504
699
  ? { storageMetadata: options.storageMetadata }
505
700
  : {}),
701
+ ...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
506
702
  ...(options.onComplete ? { onComplete: options.onComplete } : {}),
507
703
  };
508
704
  }
509
705
 
706
+ /**
707
+ * Context-bound upload helper factory.
708
+ */
709
+ export interface Uploads<Ctx> {
710
+ /**
711
+ * Define an upload workflow with the bound context type.
712
+ */
713
+ defineUpload<
714
+ const Name extends string,
715
+ MetadataSchema extends StandardSchema,
716
+ Result = unknown,
717
+ >(
718
+ name: Name,
719
+ options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
720
+ ): UploadDef<Name, MetadataSchema, Ctx, Result>;
721
+ }
722
+
723
+ /**
724
+ * Create upload helper methods bound to an application context type.
725
+ *
726
+ * Call it once in `lib/uploads.ts`:
727
+ *
728
+ * ```ts
729
+ * export const { defineUpload } = createUploads<AppContext>();
730
+ * ```
731
+ */
732
+ export function createUploads<Ctx>(): Uploads<Ctx> {
733
+ return {
734
+ defineUpload<
735
+ const Name extends string,
736
+ MetadataSchema extends StandardSchema,
737
+ Result = unknown,
738
+ >(
739
+ name: Name,
740
+ options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
741
+ ): UploadDef<Name, MetadataSchema, Ctx, Result> {
742
+ return defineUpload(name, options);
743
+ },
744
+ };
745
+ }
746
+
510
747
  /**
511
748
  * Define a nested upload registry while preserving upload names and metadata
512
749
  * types for client code.
@@ -612,9 +849,7 @@ export function createUploadRouter<Ctx>(
612
849
  const registered = [...uploads.keys()]
613
850
  .map((registeredName) => `"${registeredName}"`)
614
851
  .join(", ");
615
- throw new UploadError({
616
- code: "UPLOAD_NOT_FOUND",
617
- status: 404,
852
+ throw new UploadNotFoundError({
618
853
  message: `Upload "${name}" is not registered. Registered uploads: ${registered || "none"}. Upload routes resolve the defineUpload(...) name, not the defineUploads({...}) registry key.`,
619
854
  });
620
855
  }
@@ -634,7 +869,9 @@ export function createUploadRouter<Ctx>(
634
869
  const parsed = parsePrepareInput(uploadName, input);
635
870
  const ctx = await resolveCtx();
636
871
  const metadata = await parseMetadata(upload, parsed.metadata);
637
- assertFiles(upload, parsed.files);
872
+ assertFiles(upload, parsed.files, {
873
+ requireChecksum: Boolean(options.signer),
874
+ });
638
875
 
639
876
  const files: PreparedUploadResultFile[] = [];
640
877
  for (const file of parsed.files) {
@@ -706,7 +943,7 @@ export function createUploadRouter<Ctx>(
706
943
  const parsed = parseCompleteInput(uploadName, input);
707
944
  const ctx = await resolveCtx();
708
945
  const metadata = await parseMetadata(upload, parsed.metadata);
709
- assertFiles(upload, parsed.files);
946
+ assertFiles(upload, parsed.files, { requireChecksum: true });
710
947
 
711
948
  const files: CompletedUploadFile[] = [];
712
949
  for (const file of parsed.files) {
@@ -723,9 +960,7 @@ export function createUploadRouter<Ctx>(
723
960
  uploadId: file.uploadId,
724
961
  });
725
962
  if (file.key !== expectedKey) {
726
- throw new UploadError({
727
- code: "INVALID_UPLOAD_FILE",
728
- status: 422,
963
+ throw new InvalidUploadFileError({
729
964
  message: `Uploaded object key does not match upload "${upload.name}".`,
730
965
  details: {
731
966
  expectedKey,
@@ -733,16 +968,29 @@ export function createUploadRouter<Ctx>(
733
968
  },
734
969
  });
735
970
  }
736
- const object = await options.storage.stat(file.key);
971
+ const object = needsUploadBodyVerification(upload, file)
972
+ ? await options.storage.get(file.key)
973
+ : await options.storage.stat(file.key);
737
974
  if (!object) {
738
- throw new UploadError({
739
- code: "UPLOAD_OBJECT_NOT_FOUND",
740
- status: 404,
975
+ throw new UploadObjectNotFoundError({
741
976
  message: `Uploaded object "${file.key}" was not found.`,
742
977
  });
743
978
  }
744
979
  assertStoredObject(upload, file, object);
745
- files.push({ ...file, object });
980
+ const verified = await verifyStoredUploadFile(upload, file, object);
981
+ const completedObject = storageObjectMetadata(object);
982
+ const completedFile = {
983
+ ...file,
984
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
985
+ object: completedObject,
986
+ };
987
+ await assertVerifiedFile(upload, {
988
+ ctx,
989
+ metadata,
990
+ file: completedFile,
991
+ storage: options.storage,
992
+ });
993
+ files.push(completedFile);
746
994
  }
747
995
 
748
996
  const result = await upload.onComplete?.({ ctx, metadata, files });
@@ -785,46 +1033,58 @@ export function createUploadRouter<Ctx>(
785
1033
  );
786
1034
  const webFiles = filesFromFormData(input.formData);
787
1035
  const intents = webFiles.map(fileIntentFromFile);
788
- assertFiles(definition, intents);
1036
+ assertFiles(definition, intents, { requireChecksum: false });
789
1037
 
790
1038
  const completed: CompletedUploadFile[] = [];
791
1039
  for (const [index, file] of webFiles.entries()) {
792
1040
  const intent = intents[index];
793
1041
  if (!intent) continue;
1042
+ const verified = await verifyBlobUploadFile(definition, intent, file);
1043
+ const verifiedIntent = {
1044
+ ...intent,
1045
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
1046
+ };
794
1047
  const uploadId = id();
795
1048
  await assertAuthorized(definition, {
796
1049
  ctx,
797
1050
  metadata,
798
- file: intent,
1051
+ file: verifiedIntent,
799
1052
  uploadId,
800
1053
  });
801
1054
  const key = await definition.key({
802
1055
  ctx,
803
1056
  metadata,
804
- file: intent,
1057
+ file: verifiedIntent,
805
1058
  uploadId,
806
1059
  });
807
1060
  const storageMetadata =
808
1061
  (await definition.storageMetadata?.({
809
1062
  ctx,
810
1063
  metadata,
811
- file: intent,
1064
+ file: verifiedIntent,
812
1065
  uploadId,
813
1066
  })) ?? {};
814
1067
  const object = await options.storage.put(key, file, {
815
- contentType: intent.contentType,
1068
+ contentType: verifiedIntent.contentType,
816
1069
  ...(definition.file.cacheControl !== undefined
817
1070
  ? { cacheControl: definition.file.cacheControl }
818
1071
  : {}),
819
1072
  metadata: storageMetadata,
820
1073
  visibility: definition.file.visibility ?? "private",
821
1074
  });
822
- completed.push({
823
- ...intent,
1075
+ const completedFile = {
1076
+ ...verifiedIntent,
824
1077
  uploadId,
825
1078
  key,
826
1079
  object,
1080
+ };
1081
+ await assertVerifiedFile(definition, {
1082
+ ctx,
1083
+ metadata,
1084
+ file: completedFile,
1085
+ storage: options.storage,
827
1086
  });
1087
+ completed.push(completedFile);
828
1088
  }
829
1089
 
830
1090
  const result = await definition.onComplete?.({
@@ -970,9 +1230,7 @@ function assertUploadContentLengthWithinLimit(
970
1230
  const actualBytes = Number(contentLength);
971
1231
  if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
972
1232
  if (actualBytes > context.maxBytes) {
973
- throw new UploadError({
974
- code: "UPLOAD_BODY_TOO_LARGE",
975
- status: 413,
1233
+ throw new UploadBodyTooLargeError({
976
1234
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
977
1235
  details: {
978
1236
  maxBytes: context.maxBytes,
@@ -996,9 +1254,7 @@ async function readLimitedRequestText(
996
1254
  const text = await request.text();
997
1255
  const actualBytes = new TextEncoder().encode(text).byteLength;
998
1256
  if (actualBytes > context.maxBytes) {
999
- throw new UploadError({
1000
- code: "UPLOAD_BODY_TOO_LARGE",
1001
- status: 413,
1257
+ throw new UploadBodyTooLargeError({
1002
1258
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
1003
1259
  details: {
1004
1260
  maxBytes: context.maxBytes,
@@ -1020,9 +1276,7 @@ async function readLimitedRequestText(
1020
1276
  if (result.done) break;
1021
1277
  actualBytes += result.value.byteLength;
1022
1278
  if (actualBytes > context.maxBytes) {
1023
- throw new UploadError({
1024
- code: "UPLOAD_BODY_TOO_LARGE",
1025
- status: 413,
1279
+ throw new UploadBodyTooLargeError({
1026
1280
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
1027
1281
  details: {
1028
1282
  maxBytes: context.maxBytes,
@@ -1052,9 +1306,7 @@ async function readJsonBody(
1052
1306
  return JSON.parse(await readLimitedRequestText(request, context));
1053
1307
  } catch (error) {
1054
1308
  if (error instanceof UploadError) throw error;
1055
- throw new UploadError({
1056
- code: "INVALID_UPLOAD_BODY",
1057
- status: 400,
1309
+ throw new InvalidUploadBodyError({
1058
1310
  message: `Upload "${context.uploadName}" ${context.action} body must be valid JSON.`,
1059
1311
  });
1060
1312
  }
@@ -1115,9 +1367,7 @@ function parsePrepareInput(
1115
1367
  requireCompletedFileFields: false,
1116
1368
  });
1117
1369
  if (issues.length > 0) {
1118
- throw new UploadError({
1119
- code: "INVALID_UPLOAD_BODY",
1120
- status: 400,
1370
+ throw new InvalidUploadBodyError({
1121
1371
  message: `Upload "${uploadName}" prepare body is invalid.`,
1122
1372
  details: { issues },
1123
1373
  });
@@ -1133,9 +1383,7 @@ function parseCompleteInput(
1133
1383
  requireCompletedFileFields: true,
1134
1384
  });
1135
1385
  if (issues.length > 0) {
1136
- throw new UploadError({
1137
- code: "INVALID_UPLOAD_BODY",
1138
- status: 400,
1386
+ throw new InvalidUploadBodyError({
1139
1387
  message: `Upload "${uploadName}" complete body is invalid.`,
1140
1388
  details: { issues },
1141
1389
  });
@@ -1150,9 +1398,7 @@ async function parseMetadata<U extends UploadDef>(
1150
1398
  const result = await upload.metadata["~standard"].validate(input);
1151
1399
 
1152
1400
  if (result.issues?.length) {
1153
- throw new UploadError({
1154
- code: "INVALID_UPLOAD_METADATA",
1155
- status: 422,
1401
+ throw new InvalidUploadMetadataError({
1156
1402
  message: `Invalid metadata for upload "${upload.name}".`,
1157
1403
  details: { issues: result.issues },
1158
1404
  });
@@ -1168,12 +1414,11 @@ async function parseMetadata<U extends UploadDef>(
1168
1414
  function assertFiles(
1169
1415
  upload: UploadDef,
1170
1416
  files: readonly UploadFileIntent[],
1417
+ options: { requireChecksum?: boolean } = {},
1171
1418
  ): void {
1172
1419
  const maxFiles = upload.file.maxFiles ?? 1;
1173
1420
  if (files.length === 0 || files.length > maxFiles) {
1174
- throw new UploadError({
1175
- code: "INVALID_UPLOAD_FILE",
1176
- status: 422,
1421
+ throw new InvalidUploadFileError({
1177
1422
  message: `Upload "${upload.name}" requires between 1 and ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`,
1178
1423
  details: { fileCount: files.length, maxFiles },
1179
1424
  });
@@ -1181,20 +1426,21 @@ function assertFiles(
1181
1426
 
1182
1427
  for (const file of files) {
1183
1428
  if (!file.name || !file.contentType || !Number.isFinite(file.size)) {
1184
- throw new UploadError({
1185
- code: "INVALID_UPLOAD_FILE",
1186
- status: 422,
1429
+ throw new InvalidUploadFileError({
1187
1430
  message: `Upload "${upload.name}" received invalid file metadata.`,
1188
1431
  details: { file },
1189
1432
  });
1190
1433
  }
1191
1434
 
1435
+ assertFileChecksum(upload, file, options);
1436
+
1192
1437
  if (
1193
1438
  upload.file.contentTypes?.length &&
1194
- !upload.file.contentTypes.includes(file.contentType)
1439
+ !upload.file.contentTypes
1440
+ .map(normalizeContentType)
1441
+ .includes(normalizeContentType(file.contentType))
1195
1442
  ) {
1196
- throw new UploadError({
1197
- code: "INVALID_UPLOAD_FILE",
1443
+ throw new InvalidUploadFileError({
1198
1444
  status: 415,
1199
1445
  message: `Upload "${upload.name}" does not accept "${file.contentType}".`,
1200
1446
  details: {
@@ -1208,8 +1454,7 @@ function assertFiles(
1208
1454
  upload.file.maxSizeBytes !== undefined &&
1209
1455
  file.size > upload.file.maxSizeBytes
1210
1456
  ) {
1211
- throw new UploadError({
1212
- code: "INVALID_UPLOAD_FILE",
1457
+ throw new InvalidUploadFileError({
1213
1458
  status: 413,
1214
1459
  message: `Upload "${upload.name}" exceeds the maximum file size.`,
1215
1460
  details: {
@@ -1221,6 +1466,408 @@ function assertFiles(
1221
1466
  }
1222
1467
  }
1223
1468
 
1469
+ function assertFileChecksum(
1470
+ upload: UploadDef,
1471
+ file: UploadFileIntent,
1472
+ options: { requireChecksum?: boolean },
1473
+ ): void {
1474
+ const requirement = upload.file.checksum;
1475
+ if (!requirement && !file.checksum) return;
1476
+
1477
+ const configuredAlgorithm = requirement?.algorithm as string | undefined;
1478
+ if (configuredAlgorithm && configuredAlgorithm !== "sha256") {
1479
+ throw new InvalidUploadFileError({
1480
+ message: `Upload "${upload.name}" uses an unsupported checksum algorithm.`,
1481
+ details: {
1482
+ algorithm: configuredAlgorithm,
1483
+ },
1484
+ });
1485
+ }
1486
+
1487
+ const required = requirement
1488
+ ? requirement.required !== false && options.requireChecksum === true
1489
+ : false;
1490
+ if (!file.checksum) {
1491
+ if (!required) return;
1492
+ throw new InvalidUploadFileError({
1493
+ message: `Upload "${upload.name}" requires a sha256 checksum for "${file.name}".`,
1494
+ details: {
1495
+ fileName: file.name,
1496
+ algorithm: "sha256",
1497
+ },
1498
+ });
1499
+ }
1500
+
1501
+ if (
1502
+ file.checksum.algorithm !== "sha256" ||
1503
+ !isSha256Hex(file.checksum.value)
1504
+ ) {
1505
+ throw new InvalidUploadFileError({
1506
+ message: `Upload "${upload.name}" received an invalid checksum for "${file.name}".`,
1507
+ details: {
1508
+ fileName: file.name,
1509
+ checksum: file.checksum,
1510
+ },
1511
+ });
1512
+ }
1513
+ }
1514
+
1515
+ const UPLOAD_SIGNATURE_MAX_BYTES = 512;
1516
+
1517
+ type UploadFileBodyVerification = {
1518
+ checksum?: UploadFileChecksum;
1519
+ };
1520
+
1521
+ function needsUploadBodyVerification(
1522
+ upload: UploadDef,
1523
+ file: UploadFileIntent,
1524
+ ): boolean {
1525
+ return (
1526
+ needsUploadChecksumVerification(upload, file) ||
1527
+ needsUploadContentTypeVerification(upload, file)
1528
+ );
1529
+ }
1530
+
1531
+ function needsUploadChecksumVerification(
1532
+ upload: UploadDef,
1533
+ file: UploadFileIntent,
1534
+ ): boolean {
1535
+ return Boolean(
1536
+ file.checksum ||
1537
+ (upload.file.checksum && upload.file.checksum.required !== false),
1538
+ );
1539
+ }
1540
+
1541
+ function needsUploadContentTypeVerification(
1542
+ upload: UploadDef,
1543
+ file: UploadFileIntent,
1544
+ ): boolean {
1545
+ if (upload.file.contentTypeVerification === false) return false;
1546
+
1547
+ if (hasContentTypeSignature(file.contentType)) return true;
1548
+ return Boolean(
1549
+ upload.file.contentTypes?.some((contentType) =>
1550
+ hasContentTypeSignature(contentType),
1551
+ ),
1552
+ );
1553
+ }
1554
+
1555
+ async function verifyBlobUploadFile(
1556
+ upload: UploadDef,
1557
+ file: UploadFileIntent,
1558
+ blob: Blob,
1559
+ ): Promise<UploadFileBodyVerification> {
1560
+ if (!needsUploadBodyVerification(upload, file)) return {};
1561
+
1562
+ const needsChecksum = needsUploadChecksumVerification(upload, file);
1563
+ const bytes = needsChecksum
1564
+ ? new Uint8Array(await blob.arrayBuffer())
1565
+ : new Uint8Array(
1566
+ await blob.slice(0, UPLOAD_SIGNATURE_MAX_BYTES).arrayBuffer(),
1567
+ );
1568
+
1569
+ assertUploadContentTypeSignature(upload, file, bytes);
1570
+
1571
+ if (!needsChecksum) return {};
1572
+ const checksum = await createUploadChecksum(bytes);
1573
+ assertUploadChecksum(upload, file, checksum);
1574
+ return { checksum };
1575
+ }
1576
+
1577
+ async function verifyStoredUploadFile(
1578
+ upload: UploadDef,
1579
+ file: UploadFileIntent,
1580
+ object: StorageObject | StorageObjectBody,
1581
+ ): Promise<UploadFileBodyVerification> {
1582
+ if (!needsUploadBodyVerification(upload, file)) return {};
1583
+
1584
+ if (!("bytes" in object)) {
1585
+ throw new InvalidUploadFileError({
1586
+ message: `Uploaded object "${object.key}" must be readable for verification.`,
1587
+ details: {
1588
+ key: object.key,
1589
+ },
1590
+ });
1591
+ }
1592
+
1593
+ const needsChecksum = needsUploadChecksumVerification(upload, file);
1594
+ const bytes = needsChecksum
1595
+ ? await object.bytes()
1596
+ : await readObjectPrefix(object, UPLOAD_SIGNATURE_MAX_BYTES);
1597
+
1598
+ assertUploadContentTypeSignature(upload, file, bytes);
1599
+
1600
+ if (!needsChecksum) return {};
1601
+ const checksum = await createUploadChecksum(bytes);
1602
+ assertUploadChecksum(upload, file, checksum);
1603
+ return { checksum };
1604
+ }
1605
+
1606
+ function storageObjectMetadata(object: StorageObject): StorageObject {
1607
+ return {
1608
+ key: object.key,
1609
+ size: object.size,
1610
+ ...(object.contentType !== undefined
1611
+ ? { contentType: object.contentType }
1612
+ : {}),
1613
+ ...(object.cacheControl !== undefined
1614
+ ? { cacheControl: object.cacheControl }
1615
+ : {}),
1616
+ metadata: object.metadata,
1617
+ visibility: object.visibility,
1618
+ lastModified: object.lastModified,
1619
+ };
1620
+ }
1621
+
1622
+ function assertUploadContentTypeSignature(
1623
+ upload: UploadDef,
1624
+ file: UploadFileIntent,
1625
+ bytes: Uint8Array,
1626
+ ): void {
1627
+ if (upload.file.contentTypeVerification === false) return;
1628
+
1629
+ const declaredContentType = normalizeContentType(file.contentType);
1630
+ const acceptedContentTypes =
1631
+ upload.file.contentTypes?.map(normalizeContentType) ?? [];
1632
+ const detectedContentType = detectSupportedContentType(bytes);
1633
+
1634
+ if (
1635
+ detectedContentType &&
1636
+ acceptedContentTypes.length > 0 &&
1637
+ !acceptedContentTypes.includes(detectedContentType)
1638
+ ) {
1639
+ throw new InvalidUploadFileError({
1640
+ status: 415,
1641
+ message: `Upload "${upload.name}" does not accept detected content type "${detectedContentType}".`,
1642
+ details: {
1643
+ fileName: file.name,
1644
+ declaredContentType,
1645
+ detectedContentType,
1646
+ acceptedContentTypes,
1647
+ },
1648
+ });
1649
+ }
1650
+
1651
+ if (
1652
+ hasContentTypeSignature(declaredContentType) &&
1653
+ !matchesContentTypeSignature(declaredContentType, bytes)
1654
+ ) {
1655
+ throw new InvalidUploadFileError({
1656
+ status: 415,
1657
+ message: `Upload "${upload.name}" content type "${declaredContentType}" does not match the file bytes.`,
1658
+ details: {
1659
+ fileName: file.name,
1660
+ expectedContentType: declaredContentType,
1661
+ detectedContentType: detectedContentType ?? "unknown",
1662
+ },
1663
+ });
1664
+ }
1665
+ }
1666
+
1667
+ function assertUploadChecksum(
1668
+ upload: UploadDef,
1669
+ file: UploadFileIntent,
1670
+ actual: UploadFileChecksum,
1671
+ ): void {
1672
+ if (!file.checksum) return;
1673
+
1674
+ if (file.checksum.value.toLowerCase() === actual.value) return;
1675
+
1676
+ throw new InvalidUploadFileError({
1677
+ message: `Upload "${upload.name}" checksum does not match "${file.name}".`,
1678
+ details: {
1679
+ fileName: file.name,
1680
+ algorithm: "sha256",
1681
+ expected: file.checksum.value.toLowerCase(),
1682
+ actual: actual.value,
1683
+ },
1684
+ });
1685
+ }
1686
+
1687
+ async function assertVerifiedFile<Metadata, Ctx>(
1688
+ upload: UploadDef<string, StandardSchema, Ctx>,
1689
+ args: UploadVerifyFileHookArgs<Metadata, Ctx>,
1690
+ ): Promise<void> {
1691
+ if (!upload.verifyFile) return;
1692
+
1693
+ const result = await upload.verifyFile(
1694
+ args as UploadVerifyFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
1695
+ );
1696
+ const invalid =
1697
+ result === false ||
1698
+ (typeof result === "object" &&
1699
+ result !== null &&
1700
+ "valid" in result &&
1701
+ result.valid === false);
1702
+ if (!invalid) return;
1703
+
1704
+ throw new InvalidUploadFileError({
1705
+ message:
1706
+ typeof result === "object" && result?.reason
1707
+ ? result.reason
1708
+ : `Upload "${upload.name}" file "${args.file.name}" did not pass verification.`,
1709
+ details:
1710
+ typeof result === "object" && "details" in result
1711
+ ? result.details
1712
+ : {
1713
+ fileName: args.file.name,
1714
+ key: args.file.key,
1715
+ },
1716
+ });
1717
+ }
1718
+
1719
+ async function readObjectPrefix(
1720
+ object: StorageObjectBody,
1721
+ maxBytes: number,
1722
+ ): Promise<Uint8Array> {
1723
+ const reader = object.stream().getReader();
1724
+ const chunks: Uint8Array[] = [];
1725
+ let total = 0;
1726
+
1727
+ try {
1728
+ while (total < maxBytes) {
1729
+ const result = await reader.read();
1730
+ if (result.done) break;
1731
+ const remaining = maxBytes - total;
1732
+ const chunk =
1733
+ result.value.byteLength > remaining
1734
+ ? result.value.slice(0, remaining)
1735
+ : result.value;
1736
+ chunks.push(chunk);
1737
+ total += chunk.byteLength;
1738
+ if (result.value.byteLength > remaining) break;
1739
+ }
1740
+ } finally {
1741
+ await reader.cancel().catch(() => undefined);
1742
+ reader.releaseLock();
1743
+ }
1744
+
1745
+ const bytes = new Uint8Array(total);
1746
+ let offset = 0;
1747
+ for (const chunk of chunks) {
1748
+ bytes.set(chunk, offset);
1749
+ offset += chunk.byteLength;
1750
+ }
1751
+ return bytes;
1752
+ }
1753
+
1754
+ async function createUploadChecksum(
1755
+ bytes: Uint8Array,
1756
+ ): Promise<UploadFileChecksum> {
1757
+ if (!globalThis.crypto?.subtle) {
1758
+ throw new InvalidUploadFileError({
1759
+ message: "Upload checksum verification requires Web Crypto.",
1760
+ });
1761
+ }
1762
+
1763
+ const buffer = new ArrayBuffer(bytes.byteLength);
1764
+ new Uint8Array(buffer).set(bytes);
1765
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
1766
+ return {
1767
+ algorithm: "sha256",
1768
+ value: bytesToHex(new Uint8Array(digest)),
1769
+ };
1770
+ }
1771
+
1772
+ function bytesToHex(bytes: Uint8Array): string {
1773
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1774
+ }
1775
+
1776
+ function isSha256Hex(value: string): boolean {
1777
+ return /^[a-f0-9]{64}$/i.test(value);
1778
+ }
1779
+
1780
+ function normalizeContentType(contentType: string): string {
1781
+ return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
1782
+ }
1783
+
1784
+ function hasContentTypeSignature(contentType: string): boolean {
1785
+ return SIGNATURE_CONTENT_TYPES.has(normalizeContentType(contentType));
1786
+ }
1787
+
1788
+ function matchesContentTypeSignature(
1789
+ contentType: string,
1790
+ bytes: Uint8Array,
1791
+ ): boolean {
1792
+ const normalized = normalizeContentType(contentType);
1793
+ switch (normalized) {
1794
+ case "application/pdf":
1795
+ return startsWithBytes(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
1796
+ case "application/zip":
1797
+ return (
1798
+ startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
1799
+ startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
1800
+ startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08])
1801
+ );
1802
+ case "image/gif":
1803
+ return (
1804
+ startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a")
1805
+ );
1806
+ case "image/jpeg":
1807
+ return startsWithBytes(bytes, [0xff, 0xd8, 0xff]);
1808
+ case "image/png":
1809
+ return startsWithBytes(
1810
+ bytes,
1811
+ [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
1812
+ );
1813
+ case "image/svg+xml":
1814
+ return looksLikeSvg(bytes);
1815
+ case "image/webp":
1816
+ return (
1817
+ startsWithAscii(bytes, "RIFF") &&
1818
+ bytes.length >= 12 &&
1819
+ asciiAt(bytes, 8, 4) === "WEBP"
1820
+ );
1821
+ default:
1822
+ return false;
1823
+ }
1824
+ }
1825
+
1826
+ function detectSupportedContentType(bytes: Uint8Array): string | undefined {
1827
+ for (const contentType of SIGNATURE_CONTENT_TYPES) {
1828
+ if (matchesContentTypeSignature(contentType, bytes)) return contentType;
1829
+ }
1830
+ return undefined;
1831
+ }
1832
+
1833
+ const SIGNATURE_CONTENT_TYPES = new Set([
1834
+ "application/pdf",
1835
+ "application/zip",
1836
+ "image/gif",
1837
+ "image/jpeg",
1838
+ "image/png",
1839
+ "image/svg+xml",
1840
+ "image/webp",
1841
+ ]);
1842
+
1843
+ function startsWithBytes(
1844
+ bytes: Uint8Array,
1845
+ prefix: readonly number[],
1846
+ ): boolean {
1847
+ if (bytes.length < prefix.length) return false;
1848
+ return prefix.every((byte, index) => bytes[index] === byte);
1849
+ }
1850
+
1851
+ function startsWithAscii(bytes: Uint8Array, prefix: string): boolean {
1852
+ return asciiAt(bytes, 0, prefix.length) === prefix;
1853
+ }
1854
+
1855
+ function asciiAt(bytes: Uint8Array, offset: number, length: number): string {
1856
+ return String.fromCharCode(...bytes.slice(offset, offset + length));
1857
+ }
1858
+
1859
+ function looksLikeSvg(bytes: Uint8Array): boolean {
1860
+ const text = new TextDecoder()
1861
+ .decode(bytes)
1862
+ .replace(/^\uFEFF/, "")
1863
+ .trimStart()
1864
+ .toLowerCase();
1865
+ return (
1866
+ text.startsWith("<svg") ||
1867
+ (text.startsWith("<?xml") && text.includes("<svg"))
1868
+ );
1869
+ }
1870
+
1224
1871
  async function assertAuthorized<Metadata, Ctx>(
1225
1872
  upload: UploadDef<string, StandardSchema, Ctx>,
1226
1873
  args: UploadFileHookArgs<Metadata, Ctx>,
@@ -1228,9 +1875,7 @@ async function assertAuthorized<Metadata, Ctx>(
1228
1875
  if (!upload.authorize) {
1229
1876
  if ((upload.access ?? "protected") === "public") return;
1230
1877
 
1231
- throw new UploadError({
1232
- code: "UNAUTHORIZED_UPLOAD",
1233
- status: 403,
1878
+ throw new UnauthorizedUploadError({
1234
1879
  message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
1235
1880
  });
1236
1881
  }
@@ -1246,9 +1891,7 @@ async function assertAuthorized<Metadata, Ctx>(
1246
1891
  result.allowed === false);
1247
1892
  if (!denied) return;
1248
1893
 
1249
- throw new UploadError({
1250
- code: "UNAUTHORIZED_UPLOAD",
1251
- status: 403,
1894
+ throw new UnauthorizedUploadError({
1252
1895
  message:
1253
1896
  typeof result === "object" && result?.reason
1254
1897
  ? result.reason
@@ -1267,8 +1910,7 @@ function assertStoredObject(
1267
1910
  upload.file.maxSizeBytes !== undefined &&
1268
1911
  object.size > upload.file.maxSizeBytes
1269
1912
  ) {
1270
- throw new UploadError({
1271
- code: "INVALID_UPLOAD_FILE",
1913
+ throw new InvalidUploadFileError({
1272
1914
  status: 413,
1273
1915
  message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
1274
1916
  details: {
@@ -1279,9 +1921,7 @@ function assertStoredObject(
1279
1921
  }
1280
1922
 
1281
1923
  if (object.size !== file.size) {
1282
- throw new UploadError({
1283
- code: "INVALID_UPLOAD_FILE",
1284
- status: 422,
1924
+ throw new InvalidUploadFileError({
1285
1925
  message: `Uploaded object "${object.key}" size does not match the prepared file.`,
1286
1926
  details: {
1287
1927
  expected: file.size,
@@ -1293,11 +1933,10 @@ function assertStoredObject(
1293
1933
  if (
1294
1934
  object.contentType &&
1295
1935
  file.contentType &&
1296
- object.contentType !== file.contentType
1936
+ normalizeContentType(object.contentType) !==
1937
+ normalizeContentType(file.contentType)
1297
1938
  ) {
1298
- throw new UploadError({
1299
- code: "INVALID_UPLOAD_FILE",
1300
- status: 422,
1939
+ throw new InvalidUploadFileError({
1301
1940
  message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
1302
1941
  details: {
1303
1942
  expected: file.contentType,
@@ -1314,9 +1953,7 @@ function metadataFromFormData(formData: FormData): unknown {
1314
1953
  try {
1315
1954
  return JSON.parse(metadata);
1316
1955
  } catch {
1317
- throw new UploadError({
1318
- code: "INVALID_UPLOAD_BODY",
1319
- status: 400,
1956
+ throw new InvalidUploadBodyError({
1320
1957
  message: "Upload metadata must be valid JSON.",
1321
1958
  });
1322
1959
  }
@@ -1326,9 +1963,7 @@ function filesFromFormData(formData: FormData): File[] {
1326
1963
  const values = [...formData.getAll("file"), ...formData.getAll("files")];
1327
1964
  const files = values.filter((value): value is File => value instanceof File);
1328
1965
  if (files.length === 0) {
1329
- throw new UploadError({
1330
- code: "INVALID_UPLOAD_BODY",
1331
- status: 400,
1966
+ throw new InvalidUploadBodyError({
1332
1967
  message: 'Multipart upload must include at least one "file" field.',
1333
1968
  });
1334
1969
  }
@@ -1336,9 +1971,11 @@ function filesFromFormData(formData: FormData): File[] {
1336
1971
  }
1337
1972
 
1338
1973
  function fileIntentFromFile(file: File): UploadFileIntent {
1974
+ const contentType =
1975
+ normalizeContentType(file.type) || "application/octet-stream";
1339
1976
  return {
1340
1977
  name: file.name,
1341
- contentType: file.type || "application/octet-stream",
1978
+ contentType,
1342
1979
  size: file.size,
1343
1980
  };
1344
1981
  }