@beignet/core 0.0.32 → 0.0.34

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 (139) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +409 -34
  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/contracts/contract-builder.d.ts +0 -4
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +0 -6
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/events/index.d.ts +6 -0
  11. package/dist/events/index.d.ts.map +1 -1
  12. package/dist/events/index.js +20 -10
  13. package/dist/events/index.js.map +1 -1
  14. package/dist/flags/index.d.ts +0 -4
  15. package/dist/flags/index.d.ts.map +1 -1
  16. package/dist/flags/index.js +0 -4
  17. package/dist/flags/index.js.map +1 -1
  18. package/dist/jobs/index.d.ts +415 -4
  19. package/dist/jobs/index.d.ts.map +1 -1
  20. package/dist/jobs/index.js +392 -3
  21. package/dist/jobs/index.js.map +1 -1
  22. package/dist/notifications/index.d.ts +194 -10
  23. package/dist/notifications/index.d.ts.map +1 -1
  24. package/dist/notifications/index.js +397 -59
  25. package/dist/notifications/index.js.map +1 -1
  26. package/dist/outbox/index.d.ts +150 -1
  27. package/dist/outbox/index.d.ts.map +1 -1
  28. package/dist/outbox/index.js +170 -1
  29. package/dist/outbox/index.js.map +1 -1
  30. package/dist/ports/events.d.ts +2 -2
  31. package/dist/ports/index.d.ts +7 -2
  32. package/dist/ports/index.d.ts.map +1 -1
  33. package/dist/ports/index.js +2 -1
  34. package/dist/ports/index.js.map +1 -1
  35. package/dist/providers/instrumentation.d.ts +4 -0
  36. package/dist/providers/instrumentation.d.ts.map +1 -1
  37. package/dist/providers/instrumentation.js.map +1 -1
  38. package/dist/providers/provider.d.ts +1 -1
  39. package/dist/schedules/index.d.ts +8 -7
  40. package/dist/schedules/index.d.ts.map +1 -1
  41. package/dist/schedules/index.js +47 -16
  42. package/dist/schedules/index.js.map +1 -1
  43. package/dist/server/hooks/index.d.ts +1 -0
  44. package/dist/server/hooks/index.d.ts.map +1 -1
  45. package/dist/server/hooks/index.js +1 -0
  46. package/dist/server/hooks/index.js.map +1 -1
  47. package/dist/server/hooks/rate-limit.d.ts +26 -13
  48. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  49. package/dist/server/hooks/rate-limit.js +28 -34
  50. package/dist/server/hooks/rate-limit.js.map +1 -1
  51. package/dist/server/hooks/security.d.ts +179 -0
  52. package/dist/server/hooks/security.d.ts.map +1 -0
  53. package/dist/server/hooks/security.js +225 -0
  54. package/dist/server/hooks/security.js.map +1 -0
  55. package/dist/server/index.d.ts +8 -0
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +8 -0
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/instrumentation.d.ts.map +1 -1
  60. package/dist/server/instrumentation.js +22 -6
  61. package/dist/server/instrumentation.js.map +1 -1
  62. package/dist/server/request-context.d.ts +4 -0
  63. package/dist/server/request-context.d.ts.map +1 -1
  64. package/dist/server/request-context.js +3 -0
  65. package/dist/server/request-context.js.map +1 -1
  66. package/dist/server/runtime-integrity.d.ts +84 -0
  67. package/dist/server/runtime-integrity.d.ts.map +1 -0
  68. package/dist/server/runtime-integrity.js +160 -0
  69. package/dist/server/runtime-integrity.js.map +1 -0
  70. package/dist/server/server.d.ts +10 -10
  71. package/dist/server/server.d.ts.map +1 -1
  72. package/dist/server/server.js +75 -11
  73. package/dist/server/server.js.map +1 -1
  74. package/dist/server/trusted-proxy.d.ts +77 -0
  75. package/dist/server/trusted-proxy.d.ts.map +1 -0
  76. package/dist/server/trusted-proxy.js +129 -0
  77. package/dist/server/trusted-proxy.js.map +1 -0
  78. package/dist/tasks/index.d.ts +6 -7
  79. package/dist/tasks/index.d.ts.map +1 -1
  80. package/dist/tasks/index.js +22 -5
  81. package/dist/tasks/index.js.map +1 -1
  82. package/dist/tenancy/index.d.ts +41 -0
  83. package/dist/tenancy/index.d.ts.map +1 -0
  84. package/dist/tenancy/index.js +34 -0
  85. package/dist/tenancy/index.js.map +1 -0
  86. package/dist/testing/index.d.ts +1 -1
  87. package/dist/testing/index.d.ts.map +1 -1
  88. package/dist/testing/index.js +1 -0
  89. package/dist/testing/index.js.map +1 -1
  90. package/dist/tracing/execution.d.ts +14 -0
  91. package/dist/tracing/execution.d.ts.map +1 -0
  92. package/dist/tracing/execution.js +17 -0
  93. package/dist/tracing/execution.js.map +1 -0
  94. package/dist/tracing/index.d.ts +49 -0
  95. package/dist/tracing/index.d.ts.map +1 -1
  96. package/dist/tracing/index.js +59 -0
  97. package/dist/tracing/index.js.map +1 -1
  98. package/dist/uploads/client.d.ts.map +1 -1
  99. package/dist/uploads/client.js +44 -6
  100. package/dist/uploads/client.js.map +1 -1
  101. package/dist/uploads/index.d.ts +71 -0
  102. package/dist/uploads/index.d.ts.map +1 -1
  103. package/dist/uploads/index.js +344 -15
  104. package/dist/uploads/index.js.map +1 -1
  105. package/dist/webhooks/index.d.ts +55 -4
  106. package/dist/webhooks/index.d.ts.map +1 -1
  107. package/dist/webhooks/index.js +106 -2
  108. package/dist/webhooks/index.js.map +1 -1
  109. package/package.json +5 -5
  110. package/skills/app-architecture/SKILL.md +31 -2
  111. package/src/application/index.ts +88 -56
  112. package/src/contracts/contract-builder.ts +0 -7
  113. package/src/events/index.ts +27 -14
  114. package/src/flags/index.ts +0 -5
  115. package/src/jobs/index.ts +895 -7
  116. package/src/notifications/index.ts +678 -70
  117. package/src/outbox/index.ts +358 -2
  118. package/src/ports/events.ts +2 -2
  119. package/src/ports/index.ts +17 -1
  120. package/src/providers/instrumentation.ts +4 -0
  121. package/src/providers/provider.ts +1 -1
  122. package/src/schedules/index.ts +60 -26
  123. package/src/server/hooks/index.ts +10 -0
  124. package/src/server/hooks/rate-limit.ts +52 -46
  125. package/src/server/hooks/security.ts +503 -0
  126. package/src/server/index.ts +8 -0
  127. package/src/server/instrumentation.ts +25 -5
  128. package/src/server/request-context.ts +7 -0
  129. package/src/server/runtime-integrity.ts +322 -0
  130. package/src/server/server.ts +88 -35
  131. package/src/server/trusted-proxy.ts +249 -0
  132. package/src/tasks/index.ts +29 -12
  133. package/src/tenancy/index.ts +55 -0
  134. package/src/testing/index.ts +2 -0
  135. package/src/tracing/execution.ts +37 -0
  136. package/src/tracing/index.ts +137 -0
  137. package/src/uploads/client.ts +65 -6
  138. package/src/uploads/index.ts +539 -14
  139. package/src/webhooks/index.ts +238 -9
@@ -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>;
@@ -613,6 +698,7 @@ export function defineUpload<
613
698
  ...(options.storageMetadata
614
699
  ? { storageMetadata: options.storageMetadata }
615
700
  : {}),
701
+ ...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
616
702
  ...(options.onComplete ? { onComplete: options.onComplete } : {}),
617
703
  };
618
704
  }
@@ -783,7 +869,9 @@ export function createUploadRouter<Ctx>(
783
869
  const parsed = parsePrepareInput(uploadName, input);
784
870
  const ctx = await resolveCtx();
785
871
  const metadata = await parseMetadata(upload, parsed.metadata);
786
- assertFiles(upload, parsed.files);
872
+ assertFiles(upload, parsed.files, {
873
+ requireChecksum: Boolean(options.signer),
874
+ });
787
875
 
788
876
  const files: PreparedUploadResultFile[] = [];
789
877
  for (const file of parsed.files) {
@@ -855,7 +943,7 @@ export function createUploadRouter<Ctx>(
855
943
  const parsed = parseCompleteInput(uploadName, input);
856
944
  const ctx = await resolveCtx();
857
945
  const metadata = await parseMetadata(upload, parsed.metadata);
858
- assertFiles(upload, parsed.files);
946
+ assertFiles(upload, parsed.files, { requireChecksum: true });
859
947
 
860
948
  const files: CompletedUploadFile[] = [];
861
949
  for (const file of parsed.files) {
@@ -880,14 +968,29 @@ export function createUploadRouter<Ctx>(
880
968
  },
881
969
  });
882
970
  }
883
- 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);
884
974
  if (!object) {
885
975
  throw new UploadObjectNotFoundError({
886
976
  message: `Uploaded object "${file.key}" was not found.`,
887
977
  });
888
978
  }
889
979
  assertStoredObject(upload, file, object);
890
- 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);
891
994
  }
892
995
 
893
996
  const result = await upload.onComplete?.({ ctx, metadata, files });
@@ -930,46 +1033,58 @@ export function createUploadRouter<Ctx>(
930
1033
  );
931
1034
  const webFiles = filesFromFormData(input.formData);
932
1035
  const intents = webFiles.map(fileIntentFromFile);
933
- assertFiles(definition, intents);
1036
+ assertFiles(definition, intents, { requireChecksum: false });
934
1037
 
935
1038
  const completed: CompletedUploadFile[] = [];
936
1039
  for (const [index, file] of webFiles.entries()) {
937
1040
  const intent = intents[index];
938
1041
  if (!intent) continue;
1042
+ const verified = await verifyBlobUploadFile(definition, intent, file);
1043
+ const verifiedIntent = {
1044
+ ...intent,
1045
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
1046
+ };
939
1047
  const uploadId = id();
940
1048
  await assertAuthorized(definition, {
941
1049
  ctx,
942
1050
  metadata,
943
- file: intent,
1051
+ file: verifiedIntent,
944
1052
  uploadId,
945
1053
  });
946
1054
  const key = await definition.key({
947
1055
  ctx,
948
1056
  metadata,
949
- file: intent,
1057
+ file: verifiedIntent,
950
1058
  uploadId,
951
1059
  });
952
1060
  const storageMetadata =
953
1061
  (await definition.storageMetadata?.({
954
1062
  ctx,
955
1063
  metadata,
956
- file: intent,
1064
+ file: verifiedIntent,
957
1065
  uploadId,
958
1066
  })) ?? {};
959
1067
  const object = await options.storage.put(key, file, {
960
- contentType: intent.contentType,
1068
+ contentType: verifiedIntent.contentType,
961
1069
  ...(definition.file.cacheControl !== undefined
962
1070
  ? { cacheControl: definition.file.cacheControl }
963
1071
  : {}),
964
1072
  metadata: storageMetadata,
965
1073
  visibility: definition.file.visibility ?? "private",
966
1074
  });
967
- completed.push({
968
- ...intent,
1075
+ const completedFile = {
1076
+ ...verifiedIntent,
969
1077
  uploadId,
970
1078
  key,
971
1079
  object,
1080
+ };
1081
+ await assertVerifiedFile(definition, {
1082
+ ctx,
1083
+ metadata,
1084
+ file: completedFile,
1085
+ storage: options.storage,
972
1086
  });
1087
+ completed.push(completedFile);
973
1088
  }
974
1089
 
975
1090
  const result = await definition.onComplete?.({
@@ -1299,6 +1414,7 @@ async function parseMetadata<U extends UploadDef>(
1299
1414
  function assertFiles(
1300
1415
  upload: UploadDef,
1301
1416
  files: readonly UploadFileIntent[],
1417
+ options: { requireChecksum?: boolean } = {},
1302
1418
  ): void {
1303
1419
  const maxFiles = upload.file.maxFiles ?? 1;
1304
1420
  if (files.length === 0 || files.length > maxFiles) {
@@ -1316,9 +1432,13 @@ function assertFiles(
1316
1432
  });
1317
1433
  }
1318
1434
 
1435
+ assertFileChecksum(upload, file, options);
1436
+
1319
1437
  if (
1320
1438
  upload.file.contentTypes?.length &&
1321
- !upload.file.contentTypes.includes(file.contentType)
1439
+ !upload.file.contentTypes
1440
+ .map(normalizeContentType)
1441
+ .includes(normalizeContentType(file.contentType))
1322
1442
  ) {
1323
1443
  throw new InvalidUploadFileError({
1324
1444
  status: 415,
@@ -1346,6 +1466,408 @@ function assertFiles(
1346
1466
  }
1347
1467
  }
1348
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
+
1349
1871
  async function assertAuthorized<Metadata, Ctx>(
1350
1872
  upload: UploadDef<string, StandardSchema, Ctx>,
1351
1873
  args: UploadFileHookArgs<Metadata, Ctx>,
@@ -1411,7 +1933,8 @@ function assertStoredObject(
1411
1933
  if (
1412
1934
  object.contentType &&
1413
1935
  file.contentType &&
1414
- object.contentType !== file.contentType
1936
+ normalizeContentType(object.contentType) !==
1937
+ normalizeContentType(file.contentType)
1415
1938
  ) {
1416
1939
  throw new InvalidUploadFileError({
1417
1940
  message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
@@ -1448,9 +1971,11 @@ function filesFromFormData(formData: FormData): File[] {
1448
1971
  }
1449
1972
 
1450
1973
  function fileIntentFromFile(file: File): UploadFileIntent {
1974
+ const contentType =
1975
+ normalizeContentType(file.type) || "application/octet-stream";
1451
1976
  return {
1452
1977
  name: file.name,
1453
- contentType: file.type || "application/octet-stream",
1978
+ contentType,
1454
1979
  size: file.size,
1455
1980
  };
1456
1981
  }