@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
@@ -1,6 +1,11 @@
1
1
  import { createProviderInstrumentation } from "../providers/index.js";
2
2
  /**
3
- * Error thrown for expected upload failures.
3
+ * Base error for expected upload failures.
4
+ *
5
+ * Upload workflows throw one of the specific subclasses, such as
6
+ * {@link UploadNotFoundError} or {@link UnauthorizedUploadError}. Catch this
7
+ * base class to handle any expected upload failure; the upload router maps it
8
+ * to an HTTP response using `code`, `status`, and `details`.
4
9
  */
5
10
  export class UploadError extends Error {
6
11
  code;
@@ -14,6 +19,87 @@ export class UploadError extends Error {
14
19
  this.details = args.details;
15
20
  }
16
21
  }
22
+ /**
23
+ * Error thrown when an upload name is not registered on the router.
24
+ */
25
+ export class UploadNotFoundError extends UploadError {
26
+ constructor(args) {
27
+ super({ code: "UPLOAD_NOT_FOUND", status: 404, ...args });
28
+ this.name = "UploadNotFoundError";
29
+ }
30
+ }
31
+ /**
32
+ * Error thrown when an upload request targets an unknown upload action.
33
+ */
34
+ export class InvalidUploadActionError extends UploadError {
35
+ constructor(args) {
36
+ super({ code: "INVALID_UPLOAD_ACTION", status: 400, ...args });
37
+ this.name = "InvalidUploadActionError";
38
+ }
39
+ }
40
+ /**
41
+ * Error thrown when upload metadata fails schema validation.
42
+ */
43
+ export class InvalidUploadMetadataError extends UploadError {
44
+ constructor(args) {
45
+ super({ code: "INVALID_UPLOAD_METADATA", status: 422, ...args });
46
+ this.name = "InvalidUploadMetadataError";
47
+ }
48
+ }
49
+ /**
50
+ * Error thrown when a file fails upload validation, such as file count,
51
+ * content type (415), size limits (413), or prepared-file mismatches.
52
+ */
53
+ export class InvalidUploadFileError extends UploadError {
54
+ constructor(args) {
55
+ super({
56
+ code: "INVALID_UPLOAD_FILE",
57
+ message: args.message,
58
+ status: args.status ?? 422,
59
+ details: args.details,
60
+ });
61
+ this.name = "InvalidUploadFileError";
62
+ }
63
+ }
64
+ /**
65
+ * Error thrown when an upload is denied by authorize(...) or lacks
66
+ * authorization configuration.
67
+ */
68
+ export class UnauthorizedUploadError extends UploadError {
69
+ constructor(args) {
70
+ super({ code: "UNAUTHORIZED_UPLOAD", status: 403, ...args });
71
+ this.name = "UnauthorizedUploadError";
72
+ }
73
+ }
74
+ /**
75
+ * Error thrown when a completed upload references a stored object that does
76
+ * not exist.
77
+ */
78
+ export class UploadObjectNotFoundError extends UploadError {
79
+ constructor(args) {
80
+ super({ code: "UPLOAD_OBJECT_NOT_FOUND", status: 404, ...args });
81
+ this.name = "UploadObjectNotFoundError";
82
+ }
83
+ }
84
+ /**
85
+ * Error thrown when an upload request body exceeds the configured size limit.
86
+ */
87
+ export class UploadBodyTooLargeError extends UploadError {
88
+ constructor(args) {
89
+ super({ code: "UPLOAD_BODY_TOO_LARGE", status: 413, ...args });
90
+ this.name = "UploadBodyTooLargeError";
91
+ }
92
+ }
93
+ /**
94
+ * Error thrown when an upload request body cannot be parsed or has an
95
+ * invalid shape.
96
+ */
97
+ export class InvalidUploadBodyError extends UploadError {
98
+ constructor(args) {
99
+ super({ code: "INVALID_UPLOAD_BODY", status: 400, ...args });
100
+ this.name = "InvalidUploadBodyError";
101
+ }
102
+ }
17
103
  /**
18
104
  * Define a typed upload workflow.
19
105
  */
@@ -36,9 +122,26 @@ export function defineUpload(name, options) {
36
122
  ...(options.storageMetadata
37
123
  ? { storageMetadata: options.storageMetadata }
38
124
  : {}),
125
+ ...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
39
126
  ...(options.onComplete ? { onComplete: options.onComplete } : {}),
40
127
  };
41
128
  }
129
+ /**
130
+ * Create upload helper methods bound to an application context type.
131
+ *
132
+ * Call it once in `lib/uploads.ts`:
133
+ *
134
+ * ```ts
135
+ * export const { defineUpload } = createUploads<AppContext>();
136
+ * ```
137
+ */
138
+ export function createUploads() {
139
+ return {
140
+ defineUpload(name, options) {
141
+ return defineUpload(name, options);
142
+ },
143
+ };
144
+ }
42
145
  /**
43
146
  * Define a nested upload registry while preserving upload names and metadata
44
147
  * types for client code.
@@ -123,9 +226,7 @@ export function createUploadRouter(options) {
123
226
  const registered = [...uploads.keys()]
124
227
  .map((registeredName) => `"${registeredName}"`)
125
228
  .join(", ");
126
- throw new UploadError({
127
- code: "UPLOAD_NOT_FOUND",
128
- status: 404,
229
+ throw new UploadNotFoundError({
129
230
  message: `Upload "${name}" is not registered. Registered uploads: ${registered || "none"}. Upload routes resolve the defineUpload(...) name, not the defineUploads({...}) registry key.`,
130
231
  });
131
232
  }
@@ -143,7 +244,9 @@ export function createUploadRouter(options) {
143
244
  const parsed = parsePrepareInput(uploadName, input);
144
245
  const ctx = await resolveCtx();
145
246
  const metadata = await parseMetadata(upload, parsed.metadata);
146
- assertFiles(upload, parsed.files);
247
+ assertFiles(upload, parsed.files, {
248
+ requireChecksum: Boolean(options.signer),
249
+ });
147
250
  const files = [];
148
251
  for (const file of parsed.files) {
149
252
  const uploadId = id();
@@ -208,7 +311,7 @@ export function createUploadRouter(options) {
208
311
  const parsed = parseCompleteInput(uploadName, input);
209
312
  const ctx = await resolveCtx();
210
313
  const metadata = await parseMetadata(upload, parsed.metadata);
211
- assertFiles(upload, parsed.files);
314
+ assertFiles(upload, parsed.files, { requireChecksum: true });
212
315
  const files = [];
213
316
  for (const file of parsed.files) {
214
317
  await assertAuthorized(upload, {
@@ -224,9 +327,7 @@ export function createUploadRouter(options) {
224
327
  uploadId: file.uploadId,
225
328
  });
226
329
  if (file.key !== expectedKey) {
227
- throw new UploadError({
228
- code: "INVALID_UPLOAD_FILE",
229
- status: 422,
330
+ throw new InvalidUploadFileError({
230
331
  message: `Uploaded object key does not match upload "${upload.name}".`,
231
332
  details: {
232
333
  expectedKey,
@@ -234,16 +335,29 @@ export function createUploadRouter(options) {
234
335
  },
235
336
  });
236
337
  }
237
- const object = await options.storage.stat(file.key);
338
+ const object = needsUploadBodyVerification(upload, file)
339
+ ? await options.storage.get(file.key)
340
+ : await options.storage.stat(file.key);
238
341
  if (!object) {
239
- throw new UploadError({
240
- code: "UPLOAD_OBJECT_NOT_FOUND",
241
- status: 404,
342
+ throw new UploadObjectNotFoundError({
242
343
  message: `Uploaded object "${file.key}" was not found.`,
243
344
  });
244
345
  }
245
346
  assertStoredObject(upload, file, object);
246
- files.push({ ...file, object });
347
+ const verified = await verifyStoredUploadFile(upload, file, object);
348
+ const completedObject = storageObjectMetadata(object);
349
+ const completedFile = {
350
+ ...file,
351
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
352
+ object: completedObject,
353
+ };
354
+ await assertVerifiedFile(upload, {
355
+ ctx,
356
+ metadata,
357
+ file: completedFile,
358
+ storage: options.storage,
359
+ });
360
+ files.push(completedFile);
247
361
  }
248
362
  const result = await upload.onComplete?.({ ctx, metadata, files });
249
363
  instrumentation.custom({
@@ -280,45 +394,57 @@ export function createUploadRouter(options) {
280
394
  const metadata = await parseMetadata(definition, metadataFromFormData(input.formData));
281
395
  const webFiles = filesFromFormData(input.formData);
282
396
  const intents = webFiles.map(fileIntentFromFile);
283
- assertFiles(definition, intents);
397
+ assertFiles(definition, intents, { requireChecksum: false });
284
398
  const completed = [];
285
399
  for (const [index, file] of webFiles.entries()) {
286
400
  const intent = intents[index];
287
401
  if (!intent)
288
402
  continue;
403
+ const verified = await verifyBlobUploadFile(definition, intent, file);
404
+ const verifiedIntent = {
405
+ ...intent,
406
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
407
+ };
289
408
  const uploadId = id();
290
409
  await assertAuthorized(definition, {
291
410
  ctx,
292
411
  metadata,
293
- file: intent,
412
+ file: verifiedIntent,
294
413
  uploadId,
295
414
  });
296
415
  const key = await definition.key({
297
416
  ctx,
298
417
  metadata,
299
- file: intent,
418
+ file: verifiedIntent,
300
419
  uploadId,
301
420
  });
302
421
  const storageMetadata = (await definition.storageMetadata?.({
303
422
  ctx,
304
423
  metadata,
305
- file: intent,
424
+ file: verifiedIntent,
306
425
  uploadId,
307
426
  })) ?? {};
308
427
  const object = await options.storage.put(key, file, {
309
- contentType: intent.contentType,
428
+ contentType: verifiedIntent.contentType,
310
429
  ...(definition.file.cacheControl !== undefined
311
430
  ? { cacheControl: definition.file.cacheControl }
312
431
  : {}),
313
432
  metadata: storageMetadata,
314
433
  visibility: definition.file.visibility ?? "private",
315
434
  });
316
- completed.push({
317
- ...intent,
435
+ const completedFile = {
436
+ ...verifiedIntent,
318
437
  uploadId,
319
438
  key,
320
439
  object,
440
+ };
441
+ await assertVerifiedFile(definition, {
442
+ ctx,
443
+ metadata,
444
+ file: completedFile,
445
+ storage: options.storage,
321
446
  });
447
+ completed.push(completedFile);
322
448
  }
323
449
  const result = await definition.onComplete?.({
324
450
  ctx,
@@ -419,9 +545,7 @@ function assertUploadContentLengthWithinLimit(headers, context) {
419
545
  if (!Number.isFinite(actualBytes) || actualBytes < 0)
420
546
  return;
421
547
  if (actualBytes > context.maxBytes) {
422
- throw new UploadError({
423
- code: "UPLOAD_BODY_TOO_LARGE",
424
- status: 413,
548
+ throw new UploadBodyTooLargeError({
425
549
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
426
550
  details: {
427
551
  maxBytes: context.maxBytes,
@@ -436,9 +560,7 @@ async function readLimitedRequestText(request, context) {
436
560
  const text = await request.text();
437
561
  const actualBytes = new TextEncoder().encode(text).byteLength;
438
562
  if (actualBytes > context.maxBytes) {
439
- throw new UploadError({
440
- code: "UPLOAD_BODY_TOO_LARGE",
441
- status: 413,
563
+ throw new UploadBodyTooLargeError({
442
564
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
443
565
  details: {
444
566
  maxBytes: context.maxBytes,
@@ -459,9 +581,7 @@ async function readLimitedRequestText(request, context) {
459
581
  break;
460
582
  actualBytes += result.value.byteLength;
461
583
  if (actualBytes > context.maxBytes) {
462
- throw new UploadError({
463
- code: "UPLOAD_BODY_TOO_LARGE",
464
- status: 413,
584
+ throw new UploadBodyTooLargeError({
465
585
  message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
466
586
  details: {
467
587
  maxBytes: context.maxBytes,
@@ -485,9 +605,7 @@ async function readJsonBody(request, context) {
485
605
  catch (error) {
486
606
  if (error instanceof UploadError)
487
607
  throw error;
488
- throw new UploadError({
489
- code: "INVALID_UPLOAD_BODY",
490
- status: 400,
608
+ throw new InvalidUploadBodyError({
491
609
  message: `Upload "${context.uploadName}" ${context.action} body must be valid JSON.`,
492
610
  });
493
611
  }
@@ -532,9 +650,7 @@ function parsePrepareInput(uploadName, body) {
532
650
  requireCompletedFileFields: false,
533
651
  });
534
652
  if (issues.length > 0) {
535
- throw new UploadError({
536
- code: "INVALID_UPLOAD_BODY",
537
- status: 400,
653
+ throw new InvalidUploadBodyError({
538
654
  message: `Upload "${uploadName}" prepare body is invalid.`,
539
655
  details: { issues },
540
656
  });
@@ -546,9 +662,7 @@ function parseCompleteInput(uploadName, body) {
546
662
  requireCompletedFileFields: true,
547
663
  });
548
664
  if (issues.length > 0) {
549
- throw new UploadError({
550
- code: "INVALID_UPLOAD_BODY",
551
- status: 400,
665
+ throw new InvalidUploadBodyError({
552
666
  message: `Upload "${uploadName}" complete body is invalid.`,
553
667
  details: { issues },
554
668
  });
@@ -558,9 +672,7 @@ function parseCompleteInput(uploadName, body) {
558
672
  async function parseMetadata(upload, input) {
559
673
  const result = await upload.metadata["~standard"].validate(input);
560
674
  if (result.issues?.length) {
561
- throw new UploadError({
562
- code: "INVALID_UPLOAD_METADATA",
563
- status: 422,
675
+ throw new InvalidUploadMetadataError({
564
676
  message: `Invalid metadata for upload "${upload.name}".`,
565
677
  details: { issues: result.issues },
566
678
  });
@@ -570,29 +682,27 @@ async function parseMetadata(upload, input) {
570
682
  }
571
683
  throw new Error("Invalid Standard Schema result: missing value");
572
684
  }
573
- function assertFiles(upload, files) {
685
+ function assertFiles(upload, files, options = {}) {
574
686
  const maxFiles = upload.file.maxFiles ?? 1;
575
687
  if (files.length === 0 || files.length > maxFiles) {
576
- throw new UploadError({
577
- code: "INVALID_UPLOAD_FILE",
578
- status: 422,
688
+ throw new InvalidUploadFileError({
579
689
  message: `Upload "${upload.name}" requires between 1 and ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`,
580
690
  details: { fileCount: files.length, maxFiles },
581
691
  });
582
692
  }
583
693
  for (const file of files) {
584
694
  if (!file.name || !file.contentType || !Number.isFinite(file.size)) {
585
- throw new UploadError({
586
- code: "INVALID_UPLOAD_FILE",
587
- status: 422,
695
+ throw new InvalidUploadFileError({
588
696
  message: `Upload "${upload.name}" received invalid file metadata.`,
589
697
  details: { file },
590
698
  });
591
699
  }
700
+ assertFileChecksum(upload, file, options);
592
701
  if (upload.file.contentTypes?.length &&
593
- !upload.file.contentTypes.includes(file.contentType)) {
594
- throw new UploadError({
595
- code: "INVALID_UPLOAD_FILE",
702
+ !upload.file.contentTypes
703
+ .map(normalizeContentType)
704
+ .includes(normalizeContentType(file.contentType))) {
705
+ throw new InvalidUploadFileError({
596
706
  status: 415,
597
707
  message: `Upload "${upload.name}" does not accept "${file.contentType}".`,
598
708
  details: {
@@ -603,8 +713,7 @@ function assertFiles(upload, files) {
603
713
  }
604
714
  if (upload.file.maxSizeBytes !== undefined &&
605
715
  file.size > upload.file.maxSizeBytes) {
606
- throw new UploadError({
607
- code: "INVALID_UPLOAD_FILE",
716
+ throw new InvalidUploadFileError({
608
717
  status: 413,
609
718
  message: `Upload "${upload.name}" exceeds the maximum file size.`,
610
719
  details: {
@@ -615,13 +724,305 @@ function assertFiles(upload, files) {
615
724
  }
616
725
  }
617
726
  }
727
+ function assertFileChecksum(upload, file, options) {
728
+ const requirement = upload.file.checksum;
729
+ if (!requirement && !file.checksum)
730
+ return;
731
+ const configuredAlgorithm = requirement?.algorithm;
732
+ if (configuredAlgorithm && configuredAlgorithm !== "sha256") {
733
+ throw new InvalidUploadFileError({
734
+ message: `Upload "${upload.name}" uses an unsupported checksum algorithm.`,
735
+ details: {
736
+ algorithm: configuredAlgorithm,
737
+ },
738
+ });
739
+ }
740
+ const required = requirement
741
+ ? requirement.required !== false && options.requireChecksum === true
742
+ : false;
743
+ if (!file.checksum) {
744
+ if (!required)
745
+ return;
746
+ throw new InvalidUploadFileError({
747
+ message: `Upload "${upload.name}" requires a sha256 checksum for "${file.name}".`,
748
+ details: {
749
+ fileName: file.name,
750
+ algorithm: "sha256",
751
+ },
752
+ });
753
+ }
754
+ if (file.checksum.algorithm !== "sha256" ||
755
+ !isSha256Hex(file.checksum.value)) {
756
+ throw new InvalidUploadFileError({
757
+ message: `Upload "${upload.name}" received an invalid checksum for "${file.name}".`,
758
+ details: {
759
+ fileName: file.name,
760
+ checksum: file.checksum,
761
+ },
762
+ });
763
+ }
764
+ }
765
+ const UPLOAD_SIGNATURE_MAX_BYTES = 512;
766
+ function needsUploadBodyVerification(upload, file) {
767
+ return (needsUploadChecksumVerification(upload, file) ||
768
+ needsUploadContentTypeVerification(upload, file));
769
+ }
770
+ function needsUploadChecksumVerification(upload, file) {
771
+ return Boolean(file.checksum ||
772
+ (upload.file.checksum && upload.file.checksum.required !== false));
773
+ }
774
+ function needsUploadContentTypeVerification(upload, file) {
775
+ if (upload.file.contentTypeVerification === false)
776
+ return false;
777
+ if (hasContentTypeSignature(file.contentType))
778
+ return true;
779
+ return Boolean(upload.file.contentTypes?.some((contentType) => hasContentTypeSignature(contentType)));
780
+ }
781
+ async function verifyBlobUploadFile(upload, file, blob) {
782
+ if (!needsUploadBodyVerification(upload, file))
783
+ return {};
784
+ const needsChecksum = needsUploadChecksumVerification(upload, file);
785
+ const bytes = needsChecksum
786
+ ? new Uint8Array(await blob.arrayBuffer())
787
+ : new Uint8Array(await blob.slice(0, UPLOAD_SIGNATURE_MAX_BYTES).arrayBuffer());
788
+ assertUploadContentTypeSignature(upload, file, bytes);
789
+ if (!needsChecksum)
790
+ return {};
791
+ const checksum = await createUploadChecksum(bytes);
792
+ assertUploadChecksum(upload, file, checksum);
793
+ return { checksum };
794
+ }
795
+ async function verifyStoredUploadFile(upload, file, object) {
796
+ if (!needsUploadBodyVerification(upload, file))
797
+ return {};
798
+ if (!("bytes" in object)) {
799
+ throw new InvalidUploadFileError({
800
+ message: `Uploaded object "${object.key}" must be readable for verification.`,
801
+ details: {
802
+ key: object.key,
803
+ },
804
+ });
805
+ }
806
+ const needsChecksum = needsUploadChecksumVerification(upload, file);
807
+ const bytes = needsChecksum
808
+ ? await object.bytes()
809
+ : await readObjectPrefix(object, UPLOAD_SIGNATURE_MAX_BYTES);
810
+ assertUploadContentTypeSignature(upload, file, bytes);
811
+ if (!needsChecksum)
812
+ return {};
813
+ const checksum = await createUploadChecksum(bytes);
814
+ assertUploadChecksum(upload, file, checksum);
815
+ return { checksum };
816
+ }
817
+ function storageObjectMetadata(object) {
818
+ return {
819
+ key: object.key,
820
+ size: object.size,
821
+ ...(object.contentType !== undefined
822
+ ? { contentType: object.contentType }
823
+ : {}),
824
+ ...(object.cacheControl !== undefined
825
+ ? { cacheControl: object.cacheControl }
826
+ : {}),
827
+ metadata: object.metadata,
828
+ visibility: object.visibility,
829
+ lastModified: object.lastModified,
830
+ };
831
+ }
832
+ function assertUploadContentTypeSignature(upload, file, bytes) {
833
+ if (upload.file.contentTypeVerification === false)
834
+ return;
835
+ const declaredContentType = normalizeContentType(file.contentType);
836
+ const acceptedContentTypes = upload.file.contentTypes?.map(normalizeContentType) ?? [];
837
+ const detectedContentType = detectSupportedContentType(bytes);
838
+ if (detectedContentType &&
839
+ acceptedContentTypes.length > 0 &&
840
+ !acceptedContentTypes.includes(detectedContentType)) {
841
+ throw new InvalidUploadFileError({
842
+ status: 415,
843
+ message: `Upload "${upload.name}" does not accept detected content type "${detectedContentType}".`,
844
+ details: {
845
+ fileName: file.name,
846
+ declaredContentType,
847
+ detectedContentType,
848
+ acceptedContentTypes,
849
+ },
850
+ });
851
+ }
852
+ if (hasContentTypeSignature(declaredContentType) &&
853
+ !matchesContentTypeSignature(declaredContentType, bytes)) {
854
+ throw new InvalidUploadFileError({
855
+ status: 415,
856
+ message: `Upload "${upload.name}" content type "${declaredContentType}" does not match the file bytes.`,
857
+ details: {
858
+ fileName: file.name,
859
+ expectedContentType: declaredContentType,
860
+ detectedContentType: detectedContentType ?? "unknown",
861
+ },
862
+ });
863
+ }
864
+ }
865
+ function assertUploadChecksum(upload, file, actual) {
866
+ if (!file.checksum)
867
+ return;
868
+ if (file.checksum.value.toLowerCase() === actual.value)
869
+ return;
870
+ throw new InvalidUploadFileError({
871
+ message: `Upload "${upload.name}" checksum does not match "${file.name}".`,
872
+ details: {
873
+ fileName: file.name,
874
+ algorithm: "sha256",
875
+ expected: file.checksum.value.toLowerCase(),
876
+ actual: actual.value,
877
+ },
878
+ });
879
+ }
880
+ async function assertVerifiedFile(upload, args) {
881
+ if (!upload.verifyFile)
882
+ return;
883
+ const result = await upload.verifyFile(args);
884
+ const invalid = result === false ||
885
+ (typeof result === "object" &&
886
+ result !== null &&
887
+ "valid" in result &&
888
+ result.valid === false);
889
+ if (!invalid)
890
+ return;
891
+ throw new InvalidUploadFileError({
892
+ message: typeof result === "object" && result?.reason
893
+ ? result.reason
894
+ : `Upload "${upload.name}" file "${args.file.name}" did not pass verification.`,
895
+ details: typeof result === "object" && "details" in result
896
+ ? result.details
897
+ : {
898
+ fileName: args.file.name,
899
+ key: args.file.key,
900
+ },
901
+ });
902
+ }
903
+ async function readObjectPrefix(object, maxBytes) {
904
+ const reader = object.stream().getReader();
905
+ const chunks = [];
906
+ let total = 0;
907
+ try {
908
+ while (total < maxBytes) {
909
+ const result = await reader.read();
910
+ if (result.done)
911
+ break;
912
+ const remaining = maxBytes - total;
913
+ const chunk = result.value.byteLength > remaining
914
+ ? result.value.slice(0, remaining)
915
+ : result.value;
916
+ chunks.push(chunk);
917
+ total += chunk.byteLength;
918
+ if (result.value.byteLength > remaining)
919
+ break;
920
+ }
921
+ }
922
+ finally {
923
+ await reader.cancel().catch(() => undefined);
924
+ reader.releaseLock();
925
+ }
926
+ const bytes = new Uint8Array(total);
927
+ let offset = 0;
928
+ for (const chunk of chunks) {
929
+ bytes.set(chunk, offset);
930
+ offset += chunk.byteLength;
931
+ }
932
+ return bytes;
933
+ }
934
+ async function createUploadChecksum(bytes) {
935
+ if (!globalThis.crypto?.subtle) {
936
+ throw new InvalidUploadFileError({
937
+ message: "Upload checksum verification requires Web Crypto.",
938
+ });
939
+ }
940
+ const buffer = new ArrayBuffer(bytes.byteLength);
941
+ new Uint8Array(buffer).set(bytes);
942
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
943
+ return {
944
+ algorithm: "sha256",
945
+ value: bytesToHex(new Uint8Array(digest)),
946
+ };
947
+ }
948
+ function bytesToHex(bytes) {
949
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
950
+ }
951
+ function isSha256Hex(value) {
952
+ return /^[a-f0-9]{64}$/i.test(value);
953
+ }
954
+ function normalizeContentType(contentType) {
955
+ return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
956
+ }
957
+ function hasContentTypeSignature(contentType) {
958
+ return SIGNATURE_CONTENT_TYPES.has(normalizeContentType(contentType));
959
+ }
960
+ function matchesContentTypeSignature(contentType, bytes) {
961
+ const normalized = normalizeContentType(contentType);
962
+ switch (normalized) {
963
+ case "application/pdf":
964
+ return startsWithBytes(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
965
+ case "application/zip":
966
+ return (startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
967
+ startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
968
+ startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08]));
969
+ case "image/gif":
970
+ return (startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a"));
971
+ case "image/jpeg":
972
+ return startsWithBytes(bytes, [0xff, 0xd8, 0xff]);
973
+ case "image/png":
974
+ return startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
975
+ case "image/svg+xml":
976
+ return looksLikeSvg(bytes);
977
+ case "image/webp":
978
+ return (startsWithAscii(bytes, "RIFF") &&
979
+ bytes.length >= 12 &&
980
+ asciiAt(bytes, 8, 4) === "WEBP");
981
+ default:
982
+ return false;
983
+ }
984
+ }
985
+ function detectSupportedContentType(bytes) {
986
+ for (const contentType of SIGNATURE_CONTENT_TYPES) {
987
+ if (matchesContentTypeSignature(contentType, bytes))
988
+ return contentType;
989
+ }
990
+ return undefined;
991
+ }
992
+ const SIGNATURE_CONTENT_TYPES = new Set([
993
+ "application/pdf",
994
+ "application/zip",
995
+ "image/gif",
996
+ "image/jpeg",
997
+ "image/png",
998
+ "image/svg+xml",
999
+ "image/webp",
1000
+ ]);
1001
+ function startsWithBytes(bytes, prefix) {
1002
+ if (bytes.length < prefix.length)
1003
+ return false;
1004
+ return prefix.every((byte, index) => bytes[index] === byte);
1005
+ }
1006
+ function startsWithAscii(bytes, prefix) {
1007
+ return asciiAt(bytes, 0, prefix.length) === prefix;
1008
+ }
1009
+ function asciiAt(bytes, offset, length) {
1010
+ return String.fromCharCode(...bytes.slice(offset, offset + length));
1011
+ }
1012
+ function looksLikeSvg(bytes) {
1013
+ const text = new TextDecoder()
1014
+ .decode(bytes)
1015
+ .replace(/^\uFEFF/, "")
1016
+ .trimStart()
1017
+ .toLowerCase();
1018
+ return (text.startsWith("<svg") ||
1019
+ (text.startsWith("<?xml") && text.includes("<svg")));
1020
+ }
618
1021
  async function assertAuthorized(upload, args) {
619
1022
  if (!upload.authorize) {
620
1023
  if ((upload.access ?? "protected") === "public")
621
1024
  return;
622
- throw new UploadError({
623
- code: "UNAUTHORIZED_UPLOAD",
624
- status: 403,
1025
+ throw new UnauthorizedUploadError({
625
1026
  message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
626
1027
  });
627
1028
  }
@@ -633,9 +1034,7 @@ async function assertAuthorized(upload, args) {
633
1034
  result.allowed === false);
634
1035
  if (!denied)
635
1036
  return;
636
- throw new UploadError({
637
- code: "UNAUTHORIZED_UPLOAD",
638
- status: 403,
1037
+ throw new UnauthorizedUploadError({
639
1038
  message: typeof result === "object" && result?.reason
640
1039
  ? result.reason
641
1040
  : `Upload "${upload.name}" is not authorized.`,
@@ -645,8 +1044,7 @@ function assertStoredObject(upload, file, object) {
645
1044
  assertFiles(upload, [file]);
646
1045
  if (upload.file.maxSizeBytes !== undefined &&
647
1046
  object.size > upload.file.maxSizeBytes) {
648
- throw new UploadError({
649
- code: "INVALID_UPLOAD_FILE",
1047
+ throw new InvalidUploadFileError({
650
1048
  status: 413,
651
1049
  message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
652
1050
  details: {
@@ -656,9 +1054,7 @@ function assertStoredObject(upload, file, object) {
656
1054
  });
657
1055
  }
658
1056
  if (object.size !== file.size) {
659
- throw new UploadError({
660
- code: "INVALID_UPLOAD_FILE",
661
- status: 422,
1057
+ throw new InvalidUploadFileError({
662
1058
  message: `Uploaded object "${object.key}" size does not match the prepared file.`,
663
1059
  details: {
664
1060
  expected: file.size,
@@ -668,10 +1064,9 @@ function assertStoredObject(upload, file, object) {
668
1064
  }
669
1065
  if (object.contentType &&
670
1066
  file.contentType &&
671
- object.contentType !== file.contentType) {
672
- throw new UploadError({
673
- code: "INVALID_UPLOAD_FILE",
674
- status: 422,
1067
+ normalizeContentType(object.contentType) !==
1068
+ normalizeContentType(file.contentType)) {
1069
+ throw new InvalidUploadFileError({
675
1070
  message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
676
1071
  details: {
677
1072
  expected: file.contentType,
@@ -688,9 +1083,7 @@ function metadataFromFormData(formData) {
688
1083
  return JSON.parse(metadata);
689
1084
  }
690
1085
  catch {
691
- throw new UploadError({
692
- code: "INVALID_UPLOAD_BODY",
693
- status: 400,
1086
+ throw new InvalidUploadBodyError({
694
1087
  message: "Upload metadata must be valid JSON.",
695
1088
  });
696
1089
  }
@@ -699,18 +1092,17 @@ function filesFromFormData(formData) {
699
1092
  const values = [...formData.getAll("file"), ...formData.getAll("files")];
700
1093
  const files = values.filter((value) => value instanceof File);
701
1094
  if (files.length === 0) {
702
- throw new UploadError({
703
- code: "INVALID_UPLOAD_BODY",
704
- status: 400,
1095
+ throw new InvalidUploadBodyError({
705
1096
  message: 'Multipart upload must include at least one "file" field.',
706
1097
  });
707
1098
  }
708
1099
  return files;
709
1100
  }
710
1101
  function fileIntentFromFile(file) {
1102
+ const contentType = normalizeContentType(file.type) || "application/octet-stream";
711
1103
  return {
712
1104
  name: file.name,
713
- contentType: file.type || "application/octet-stream",
1105
+ contentType,
714
1106
  size: file.size,
715
1107
  };
716
1108
  }