@beignet/core 0.0.23 → 0.0.25

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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +38 -6
  3. package/dist/client-only.d.ts +2 -0
  4. package/dist/client-only.d.ts.map +1 -1
  5. package/dist/client-only.js +2 -0
  6. package/dist/client-only.js.map +1 -1
  7. package/dist/providers/metadata.d.ts +2 -0
  8. package/dist/providers/metadata.d.ts.map +1 -1
  9. package/dist/providers/metadata.js +12 -0
  10. package/dist/providers/metadata.js.map +1 -1
  11. package/dist/server/hooks/cors.d.ts +4 -3
  12. package/dist/server/hooks/cors.d.ts.map +1 -1
  13. package/dist/server/hooks/cors.js +21 -16
  14. package/dist/server/hooks/cors.js.map +1 -1
  15. package/dist/server/hooks/rate-limit.d.ts +7 -6
  16. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  17. package/dist/server/hooks/rate-limit.js +4 -1
  18. package/dist/server/hooks/rate-limit.js.map +1 -1
  19. package/dist/server/server.d.ts +15 -0
  20. package/dist/server/server.d.ts.map +1 -1
  21. package/dist/server/server.js +82 -9
  22. package/dist/server/server.js.map +1 -1
  23. package/dist/server-only.d.ts +2 -0
  24. package/dist/server-only.d.ts.map +1 -1
  25. package/dist/server-only.js +2 -0
  26. package/dist/server-only.js.map +1 -1
  27. package/dist/uploads/index.d.ts +37 -1
  28. package/dist/uploads/index.d.ts.map +1 -1
  29. package/dist/uploads/index.js +127 -2
  30. package/dist/uploads/index.js.map +1 -1
  31. package/package.json +4 -2
  32. package/skills/app-architecture/SKILL.md +29 -2
  33. package/src/client-only.ts +2 -0
  34. package/src/providers/metadata.ts +23 -0
  35. package/src/server/hooks/cors.ts +36 -16
  36. package/src/server/hooks/rate-limit.ts +12 -6
  37. package/src/server/server.ts +130 -16
  38. package/src/server-only.ts +2 -0
  39. package/src/uploads/index.ts +209 -3
@@ -275,6 +275,18 @@ type AnyServiceProvider = ServiceProvider<
275
275
  any
276
276
  >;
277
277
 
278
+ /**
279
+ * Request body limits enforced before contract body validation.
280
+ */
281
+ export interface RequestBodyOptions {
282
+ /**
283
+ * Maximum request body size in bytes for JSON/text route bodies.
284
+ *
285
+ * @default 1048576
286
+ */
287
+ maxBytes?: number;
288
+ }
289
+
278
290
  /**
279
291
  * Options for creating a Beignet server instance.
280
292
  */
@@ -350,6 +362,10 @@ export type CreateServerOptions<
350
362
  * @default true
351
363
  */
352
364
  validateResponses?: boolean;
365
+ /**
366
+ * Request body parsing limits for JSON/text contract routes.
367
+ */
368
+ requestBody?: RequestBodyOptions;
353
369
  /**
354
370
  * Route list to register up front.
355
371
  */
@@ -937,7 +953,89 @@ function defaultErrorResponse(err: unknown, ctx?: unknown): HttpResponseLike {
937
953
  };
938
954
  }
939
955
 
940
- async function parseBody(req: HttpRequestLike): Promise<unknown> {
956
+ const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024;
957
+
958
+ class RequestBodyTooLargeError extends Error {
959
+ readonly maxBytes: number;
960
+ readonly actualBytes?: number;
961
+
962
+ constructor(maxBytes: number, actualBytes?: number) {
963
+ super("Request body exceeds the configured size limit.");
964
+ this.name = "RequestBodyTooLargeError";
965
+ this.maxBytes = maxBytes;
966
+ this.actualBytes = actualBytes;
967
+ }
968
+ }
969
+
970
+ function requestBodyLimit(options: RequestBodyOptions | undefined): number {
971
+ const maxBytes = options?.maxBytes ?? DEFAULT_REQUEST_BODY_MAX_BYTES;
972
+
973
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
974
+ throw new Error(
975
+ "createServer requestBody.maxBytes must be a positive number.",
976
+ );
977
+ }
978
+
979
+ return maxBytes;
980
+ }
981
+
982
+ function assertContentLengthWithinLimit(
983
+ headers: Headers,
984
+ maxBytes: number,
985
+ ): void {
986
+ const contentLength = headers.get("content-length");
987
+ if (contentLength === null) return;
988
+
989
+ const actualBytes = Number(contentLength);
990
+ if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
991
+ if (actualBytes > maxBytes) {
992
+ throw new RequestBodyTooLargeError(maxBytes, actualBytes);
993
+ }
994
+ }
995
+
996
+ async function readLimitedRequestText(
997
+ req: HttpRequestLike,
998
+ maxBytes: number,
999
+ ): Promise<string> {
1000
+ assertContentLengthWithinLimit(req.headers, maxBytes);
1001
+
1002
+ const body = req.raw?.body;
1003
+ if (!body) {
1004
+ const text = await req.text();
1005
+ const actualBytes = new TextEncoder().encode(text).byteLength;
1006
+ if (actualBytes > maxBytes) {
1007
+ throw new RequestBodyTooLargeError(maxBytes, actualBytes);
1008
+ }
1009
+ return text;
1010
+ }
1011
+
1012
+ const reader = body.getReader();
1013
+ const decoder = new TextDecoder();
1014
+ let received = 0;
1015
+ let text = "";
1016
+
1017
+ try {
1018
+ while (true) {
1019
+ const result = await reader.read();
1020
+ if (result.done) break;
1021
+ received += result.value.byteLength;
1022
+ if (received > maxBytes) {
1023
+ throw new RequestBodyTooLargeError(maxBytes, received);
1024
+ }
1025
+ text += decoder.decode(result.value, { stream: true });
1026
+ }
1027
+ text += decoder.decode();
1028
+ } finally {
1029
+ reader.releaseLock();
1030
+ }
1031
+
1032
+ return text;
1033
+ }
1034
+
1035
+ async function parseBody(
1036
+ req: HttpRequestLike,
1037
+ maxBytes: number,
1038
+ ): Promise<unknown> {
941
1039
  const method = req.method.toUpperCase() as HttpContractConfig["method"];
942
1040
  if (!methodSupportsRequestBody(method)) {
943
1041
  return undefined;
@@ -946,15 +1044,16 @@ async function parseBody(req: HttpRequestLike): Promise<unknown> {
946
1044
  const bodyReq = req.clone?.() ?? req;
947
1045
  const contentType = req.headers.get("content-type") || "";
948
1046
  if (contentType.includes("application/json")) {
949
- const text = await bodyReq.text();
1047
+ const text = await readLimitedRequestText(bodyReq, maxBytes);
950
1048
  if (text === "") return undefined;
951
1049
  return JSON.parse(text);
952
1050
  }
953
1051
 
954
1052
  try {
955
- const text = await bodyReq.text();
1053
+ const text = await readLimitedRequestText(bodyReq, maxBytes);
956
1054
  return text === "" ? undefined : text;
957
- } catch {
1055
+ } catch (error) {
1056
+ if (error instanceof RequestBodyTooLargeError) throw error;
958
1057
  return undefined;
959
1058
  }
960
1059
  }
@@ -1011,6 +1110,7 @@ function createRequestExecutor<
1011
1110
  preMatchedParams?: Record<string, string>,
1012
1111
  ) => Promise<HttpResponse> {
1013
1112
  const warnedNativeReplacementHooks = new WeakSet<object>();
1113
+ const maxRequestBodyBytes = requestBodyLimit(options.requestBody);
1014
1114
 
1015
1115
  return async (
1016
1116
  target: ExecutionTarget<Ctx, C>,
@@ -1521,19 +1621,33 @@ function createRequestExecutor<
1521
1621
  let body: any;
1522
1622
  if (!result) {
1523
1623
  try {
1524
- body = await parseBody(req);
1624
+ body = await parseBody(req, maxRequestBodyBytes);
1525
1625
  } catch (error) {
1526
- result = {
1527
- response: requestValidationError(
1528
- contract,
1529
- 400,
1530
- "INVALID_BODY",
1531
- "Malformed JSON",
1532
- "body",
1533
- error,
1534
- ),
1535
- owner: "framework",
1536
- };
1626
+ if (error instanceof RequestBodyTooLargeError) {
1627
+ result = {
1628
+ response: requestValidationError(
1629
+ contract,
1630
+ 413,
1631
+ "PAYLOAD_TOO_LARGE",
1632
+ "Request body is too large",
1633
+ "body",
1634
+ error,
1635
+ ),
1636
+ owner: "framework",
1637
+ };
1638
+ } else {
1639
+ result = {
1640
+ response: requestValidationError(
1641
+ contract,
1642
+ 400,
1643
+ "INVALID_BODY",
1644
+ "Malformed JSON",
1645
+ "body",
1646
+ error,
1647
+ ),
1648
+ owner: "framework",
1649
+ };
1650
+ }
1537
1651
  }
1538
1652
  }
1539
1653
 
@@ -3,5 +3,7 @@
3
3
  *
4
4
  * Host frameworks may provide their own runtime-only enforcement. This marker
5
5
  * is intentionally a no-op so Beignet can enforce intent through `beignet lint`.
6
+ * Import it for side effects at the top of server-only modules:
7
+ * `import "@beignet/core/server-only";`.
6
8
  */
7
9
  export const beignetServerOnly = true;
@@ -190,6 +190,14 @@ export type UploadAuthorizeResult =
190
190
  reason?: string;
191
191
  };
192
192
 
193
+ /**
194
+ * Upload access mode.
195
+ *
196
+ * Protected uploads require an `authorize(...)` hook. Public uploads are
197
+ * intentionally reachable without that hook.
198
+ */
199
+ export type UploadAccess = "protected" | "public";
200
+
193
201
  /**
194
202
  * Arguments passed to upload definition hooks.
195
203
  */
@@ -231,6 +239,12 @@ export interface DefineUploadOptions<
231
239
  * File constraints for this upload workflow.
232
240
  */
233
241
  file: UploadFileConstraints;
242
+ /**
243
+ * Whether this upload may be prepared without an authorize hook.
244
+ *
245
+ * @default "protected"
246
+ */
247
+ access?: UploadAccess;
234
248
  /**
235
249
  * Optional human-readable description for docs and tooling.
236
250
  */
@@ -274,6 +288,7 @@ export interface UploadDef<
274
288
  readonly name: Name;
275
289
  readonly metadata: MetadataSchema;
276
290
  readonly file: UploadFileConstraints;
291
+ readonly access?: UploadAccess;
277
292
  readonly description?: string;
278
293
  authorize?(
279
294
  args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
@@ -348,6 +363,25 @@ export interface UploadManifestEntry {
348
363
  file: UploadFileConstraints;
349
364
  }
350
365
 
366
+ /**
367
+ * Request body limits for upload route actions.
368
+ */
369
+ export interface UploadRequestLimits {
370
+ /**
371
+ * Maximum JSON body size for prepare and complete actions.
372
+ *
373
+ * @default 262144
374
+ */
375
+ jsonMaxBytes?: number;
376
+ /**
377
+ * Maximum multipart body size for server-handled upload actions when
378
+ * Content-Length is present. File constraints still enforce per-file limits.
379
+ *
380
+ * @default 26214400
381
+ */
382
+ multipartMaxBytes?: number;
383
+ }
384
+
351
385
  /**
352
386
  * Options for `createUploadRouter(...)`.
353
387
  */
@@ -368,6 +402,10 @@ export interface CreateUploadRouterOptions<Ctx> {
368
402
  * Optional signer used to prepare direct upload instructions.
369
403
  */
370
404
  signer?: UploadSignerPort;
405
+ /**
406
+ * Request body limits for upload actions.
407
+ */
408
+ limits?: UploadRequestLimits;
371
409
  /**
372
410
  * Optional instrumentation target used by devtools/provider watchers.
373
411
  */
@@ -410,6 +448,7 @@ export type UploadErrorCode =
410
448
  | "INVALID_UPLOAD_FILE"
411
449
  | "UNAUTHORIZED_UPLOAD"
412
450
  | "UPLOAD_OBJECT_NOT_FOUND"
451
+ | "UPLOAD_BODY_TOO_LARGE"
413
452
  | "INVALID_UPLOAD_BODY";
414
453
 
415
454
  /**
@@ -455,6 +494,7 @@ export function defineUpload<
455
494
  maxFiles: options.file.maxFiles ?? 1,
456
495
  visibility: options.file.visibility ?? "private",
457
496
  },
497
+ access: options.access ?? "protected",
458
498
  ...(options.description !== undefined
459
499
  ? { description: options.description }
460
500
  : {}),
@@ -558,6 +598,7 @@ export function createUploadRouter<Ctx>(
558
598
  watcher: "uploads",
559
599
  },
560
600
  );
601
+ const requestLimits = uploadRequestLimits(options.limits);
561
602
 
562
603
  async function resolveCtx(): Promise<Ctx> {
563
604
  return typeof options.ctx === "function"
@@ -841,6 +882,7 @@ export function createUploadRouter<Ctx>(
841
882
  const input = await readJsonBody(request, {
842
883
  uploadName: requestOptions.uploadName,
843
884
  action: "prepare",
885
+ maxBytes: requestLimits.jsonMaxBytes,
844
886
  });
845
887
  return jsonResponse(
846
888
  await prepare(
@@ -854,6 +896,7 @@ export function createUploadRouter<Ctx>(
854
896
  const input = await readJsonBody(request, {
855
897
  uploadName: requestOptions.uploadName,
856
898
  action: "complete",
899
+ maxBytes: requestLimits.jsonMaxBytes,
857
900
  });
858
901
  return jsonResponse(
859
902
  await complete(
@@ -863,6 +906,11 @@ export function createUploadRouter<Ctx>(
863
906
  );
864
907
  }
865
908
 
909
+ assertUploadContentLengthWithinLimit(request.headers, {
910
+ uploadName: requestOptions.uploadName,
911
+ action: "upload",
912
+ maxBytes: requestLimits.multipartMaxBytes,
913
+ });
866
914
  const formData = await request.formData();
867
915
  return jsonResponse(
868
916
  await upload(requestOptions.uploadName, {
@@ -876,13 +924,134 @@ export function createUploadRouter<Ctx>(
876
924
  };
877
925
  }
878
926
 
927
+ const DEFAULT_UPLOAD_JSON_MAX_BYTES = 256 * 1024;
928
+ const DEFAULT_UPLOAD_MULTIPART_MAX_BYTES = 25 * 1024 * 1024;
929
+
930
+ function uploadRequestLimits(
931
+ limits: UploadRequestLimits | undefined,
932
+ ): Required<UploadRequestLimits> {
933
+ return {
934
+ jsonMaxBytes: positiveLimit(
935
+ limits?.jsonMaxBytes,
936
+ DEFAULT_UPLOAD_JSON_MAX_BYTES,
937
+ "limits.jsonMaxBytes",
938
+ ),
939
+ multipartMaxBytes: positiveLimit(
940
+ limits?.multipartMaxBytes,
941
+ DEFAULT_UPLOAD_MULTIPART_MAX_BYTES,
942
+ "limits.multipartMaxBytes",
943
+ ),
944
+ };
945
+ }
946
+
947
+ function positiveLimit(
948
+ value: number | undefined,
949
+ fallback: number,
950
+ name: string,
951
+ ): number {
952
+ const limit = value ?? fallback;
953
+ if (!Number.isFinite(limit) || limit <= 0) {
954
+ throw new Error(`createUploadRouter ${name} must be a positive number.`);
955
+ }
956
+ return limit;
957
+ }
958
+
959
+ function assertUploadContentLengthWithinLimit(
960
+ headers: Headers,
961
+ context: {
962
+ uploadName: string;
963
+ action: "prepare" | "complete" | "upload";
964
+ maxBytes: number;
965
+ },
966
+ ): void {
967
+ const contentLength = headers.get("content-length");
968
+ if (contentLength === null) return;
969
+
970
+ const actualBytes = Number(contentLength);
971
+ if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
972
+ if (actualBytes > context.maxBytes) {
973
+ throw new UploadError({
974
+ code: "UPLOAD_BODY_TOO_LARGE",
975
+ status: 413,
976
+ message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
977
+ details: {
978
+ maxBytes: context.maxBytes,
979
+ actualBytes,
980
+ },
981
+ });
982
+ }
983
+ }
984
+
985
+ async function readLimitedRequestText(
986
+ request: Request,
987
+ context: {
988
+ uploadName: string;
989
+ action: "prepare" | "complete";
990
+ maxBytes: number;
991
+ },
992
+ ): Promise<string> {
993
+ assertUploadContentLengthWithinLimit(request.headers, context);
994
+
995
+ if (!request.body) {
996
+ const text = await request.text();
997
+ const actualBytes = new TextEncoder().encode(text).byteLength;
998
+ if (actualBytes > context.maxBytes) {
999
+ throw new UploadError({
1000
+ code: "UPLOAD_BODY_TOO_LARGE",
1001
+ status: 413,
1002
+ message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
1003
+ details: {
1004
+ maxBytes: context.maxBytes,
1005
+ actualBytes,
1006
+ },
1007
+ });
1008
+ }
1009
+ return text;
1010
+ }
1011
+
1012
+ const reader = request.body.getReader();
1013
+ const decoder = new TextDecoder();
1014
+ let actualBytes = 0;
1015
+ let text = "";
1016
+
1017
+ try {
1018
+ while (true) {
1019
+ const result = await reader.read();
1020
+ if (result.done) break;
1021
+ actualBytes += result.value.byteLength;
1022
+ if (actualBytes > context.maxBytes) {
1023
+ throw new UploadError({
1024
+ code: "UPLOAD_BODY_TOO_LARGE",
1025
+ status: 413,
1026
+ message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
1027
+ details: {
1028
+ maxBytes: context.maxBytes,
1029
+ actualBytes,
1030
+ },
1031
+ });
1032
+ }
1033
+ text += decoder.decode(result.value, { stream: true });
1034
+ }
1035
+ text += decoder.decode();
1036
+ } finally {
1037
+ reader.releaseLock();
1038
+ }
1039
+
1040
+ return text;
1041
+ }
1042
+
879
1043
  async function readJsonBody(
880
1044
  request: Request,
881
- context: { uploadName: string; action: "prepare" | "complete" },
1045
+ context: {
1046
+ uploadName: string;
1047
+ action: "prepare" | "complete";
1048
+ maxBytes: number;
1049
+ },
882
1050
  ): Promise<unknown> {
883
1051
  try {
884
- return await request.json();
885
- } catch {
1052
+ return JSON.parse(await readLimitedRequestText(request, context));
1053
+ } catch (error) {
1054
+ if (error instanceof UploadError) throw error;
886
1055
  throw new UploadError({
887
1056
  code: "INVALID_UPLOAD_BODY",
888
1057
  status: 400,
@@ -1056,6 +1225,16 @@ async function assertAuthorized<Metadata, Ctx>(
1056
1225
  upload: UploadDef<string, StandardSchema, Ctx>,
1057
1226
  args: UploadFileHookArgs<Metadata, Ctx>,
1058
1227
  ): Promise<void> {
1228
+ if (!upload.authorize) {
1229
+ if ((upload.access ?? "protected") === "public") return;
1230
+
1231
+ throw new UploadError({
1232
+ code: "UNAUTHORIZED_UPLOAD",
1233
+ status: 403,
1234
+ message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
1235
+ });
1236
+ }
1237
+
1059
1238
  const result = await upload.authorize?.(
1060
1239
  args as UploadFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
1061
1240
  );
@@ -1084,6 +1263,33 @@ function assertStoredObject(
1084
1263
  ): void {
1085
1264
  assertFiles(upload, [file]);
1086
1265
 
1266
+ if (
1267
+ upload.file.maxSizeBytes !== undefined &&
1268
+ object.size > upload.file.maxSizeBytes
1269
+ ) {
1270
+ throw new UploadError({
1271
+ code: "INVALID_UPLOAD_FILE",
1272
+ status: 413,
1273
+ message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
1274
+ details: {
1275
+ size: object.size,
1276
+ maxSizeBytes: upload.file.maxSizeBytes,
1277
+ },
1278
+ });
1279
+ }
1280
+
1281
+ if (object.size !== file.size) {
1282
+ throw new UploadError({
1283
+ code: "INVALID_UPLOAD_FILE",
1284
+ status: 422,
1285
+ message: `Uploaded object "${object.key}" size does not match the prepared file.`,
1286
+ details: {
1287
+ expected: file.size,
1288
+ actual: object.size,
1289
+ },
1290
+ });
1291
+ }
1292
+
1087
1293
  if (
1088
1294
  object.contentType &&
1089
1295
  file.contentType &&