@beignet/next 0.0.51 → 0.0.53

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.
package/src/index.ts CHANGED
@@ -606,6 +606,15 @@ export type CreateWebhookRouteOptions<
606
606
  * @default false
607
607
  */
608
608
  allowUnknownEvents?: AllowUnknownEvents;
609
+ /**
610
+ * Maximum raw webhook body size in bytes.
611
+ *
612
+ * Bodies above this limit are rejected before server resolution, hooks,
613
+ * context creation, verification, or fulfillment.
614
+ *
615
+ * @default 1048576
616
+ */
617
+ maxBodyBytes?: number;
609
618
  /**
610
619
  * App-owned fulfillment handler. The webhook is verified before this runs.
611
620
  *
@@ -701,6 +710,16 @@ export type CreatePaymentWebhookRouteOptions<
701
710
  * @default "stripe-signature"
702
711
  */
703
712
  signatureHeader?: string;
713
+ /**
714
+ * Maximum raw webhook body size in bytes.
715
+ *
716
+ * Signed bodies above this limit are rejected before provider verification
717
+ * or fulfillment, and before server resolution, hooks, or context creation.
718
+ * A missing signature takes precedence and cancels the unread body.
719
+ *
720
+ * @default 1048576
721
+ */
722
+ maxBodyBytes?: number;
704
723
  /**
705
724
  * App-owned fulfillment handler. The webhook is verified before this runs.
706
725
  *
@@ -1117,40 +1136,59 @@ async function storageResponse(
1117
1136
  if (!key) return storageNotFoundResponse();
1118
1137
 
1119
1138
  const object = await storage.get(key);
1120
- if (object?.visibility !== "public") {
1139
+ if (!object) {
1121
1140
  return storageNotFoundResponse();
1122
1141
  }
1123
-
1124
- const headers = new Headers(resolveHeaders(options.headers, req));
1125
- headers.set("content-length", String(object.size));
1126
- headers.set("last-modified", object.lastModified.toUTCString());
1127
- if (!headers.has("x-content-type-options")) {
1128
- headers.set("x-content-type-options", "nosniff");
1142
+ if (object.visibility !== "public") {
1143
+ await object.cancel().catch(() => undefined);
1144
+ return storageNotFoundResponse();
1129
1145
  }
1130
1146
 
1131
- if (object.contentType && !headers.has("content-type")) {
1132
- headers.set("content-type", object.contentType);
1133
- }
1147
+ try {
1148
+ const headers = new Headers(resolveHeaders(options.headers, req));
1149
+ headers.set("content-length", String(object.size));
1150
+ headers.set("last-modified", object.lastModified.toUTCString());
1151
+ if (!headers.has("x-content-type-options")) {
1152
+ headers.set("x-content-type-options", "nosniff");
1153
+ }
1134
1154
 
1135
- if (!headers.has("content-disposition")) {
1136
- const contentDisposition = resolveStorageContentDisposition(
1137
- options,
1138
- req,
1139
- object,
1140
- );
1141
- if (contentDisposition) {
1142
- headers.set("content-disposition", contentDisposition);
1155
+ if (object.contentType && !headers.has("content-type")) {
1156
+ headers.set("content-type", object.contentType);
1143
1157
  }
1144
- }
1145
1158
 
1146
- if (object.cacheControl && !headers.has("cache-control")) {
1147
- headers.set("cache-control", object.cacheControl);
1148
- }
1159
+ if (!headers.has("content-disposition")) {
1160
+ const contentDisposition = resolveStorageContentDisposition(
1161
+ options,
1162
+ req,
1163
+ object,
1164
+ );
1165
+ if (contentDisposition) {
1166
+ headers.set("content-disposition", contentDisposition);
1167
+ }
1168
+ }
1149
1169
 
1150
- return new Response(includeBody ? object.stream() : null, {
1151
- status: 200,
1152
- headers,
1153
- });
1170
+ if (object.cacheControl && !headers.has("cache-control")) {
1171
+ headers.set("cache-control", object.cacheControl);
1172
+ }
1173
+
1174
+ if (!includeBody) {
1175
+ await object.cancel();
1176
+ return new Response(null, { status: 200, headers });
1177
+ }
1178
+
1179
+ const body = object.stream();
1180
+ try {
1181
+ return new Response(body, { status: 200, headers });
1182
+ } catch (error) {
1183
+ await body.cancel(error).catch(() => undefined);
1184
+ throw error;
1185
+ }
1186
+ } catch (error) {
1187
+ if (!object.bodyUsed) {
1188
+ await object.cancel(error).catch(() => undefined);
1189
+ }
1190
+ throw error;
1191
+ }
1154
1192
  }
1155
1193
 
1156
1194
  function currentRequestServer(req: Request): OpenAPIServer {
@@ -1399,6 +1437,7 @@ type OperationalErrorCode =
1399
1437
  | "CRON_SECRET_NOT_CONFIGURED"
1400
1438
  | "OUTBOX_DRAIN_FAILED"
1401
1439
  | "PAYMENT_WEBHOOK_BODY_READ_FAILED"
1440
+ | "PAYMENT_WEBHOOK_BODY_TOO_LARGE"
1402
1441
  | "PAYMENT_WEBHOOK_CONTEXT_FAILED"
1403
1442
  | "PAYMENT_WEBHOOK_HANDLER_FAILED"
1404
1443
  | "PAYMENT_WEBHOOK_SIGNATURE_MISSING"
@@ -1406,6 +1445,7 @@ type OperationalErrorCode =
1406
1445
  | "SCHEDULE_FAILED"
1407
1446
  | "UNAUTHORIZED"
1408
1447
  | "WEBHOOK_BODY_READ_FAILED"
1448
+ | "WEBHOOK_BODY_TOO_LARGE"
1409
1449
  | "WEBHOOK_CONTEXT_FAILED"
1410
1450
  | "WEBHOOK_HANDLER_FAILED"
1411
1451
  | "WEBHOOK_VERIFICATION_FAILED";
@@ -1448,14 +1488,94 @@ function toHttpResponseHeaders(
1448
1488
  * creation then receives a rehydrated request carrying the same method, URL,
1449
1489
  * headers, and raw body.
1450
1490
  */
1451
- function toContextRequest(req: Request, rawBody: string): HttpRequestLike {
1452
- return toRequestLike(
1453
- new Request(req.url, {
1454
- method: req.method,
1455
- headers: req.headers,
1456
- body: rawBody,
1457
- }),
1458
- );
1491
+ function rehydrateWebhookRequest(req: Request, rawBody: string): Request {
1492
+ return new Request(req.url, {
1493
+ method: req.method,
1494
+ headers: req.headers,
1495
+ body: rawBody,
1496
+ signal: req.signal,
1497
+ });
1498
+ }
1499
+
1500
+ const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 1024 * 1024;
1501
+
1502
+ class WebhookBodyTooLargeError extends Error {
1503
+ constructor(readonly maxBytes: number) {
1504
+ super("Webhook body exceeds the configured size limit.");
1505
+ this.name = "WebhookBodyTooLargeError";
1506
+ }
1507
+ }
1508
+
1509
+ function webhookBodyLimit(maxBytes: number | undefined): number {
1510
+ const resolved = maxBytes ?? DEFAULT_WEBHOOK_BODY_MAX_BYTES;
1511
+ if (!Number.isFinite(resolved) || resolved <= 0) {
1512
+ throw new Error("Webhook maxBodyBytes must be a positive number.");
1513
+ }
1514
+ return resolved;
1515
+ }
1516
+
1517
+ function cancelWebhookBody(
1518
+ source: { cancel(reason?: unknown): Promise<void> },
1519
+ reason: unknown,
1520
+ ): void {
1521
+ try {
1522
+ void source.cancel(reason).catch(() => {});
1523
+ } catch {
1524
+ // Cancellation is best-effort and must not replace the selected response.
1525
+ }
1526
+ }
1527
+
1528
+ function cancelWebhookRequestBody(req: HttpRequestLike, reason: unknown): void {
1529
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
1530
+ if (body) cancelWebhookBody(body, reason);
1531
+ }
1532
+
1533
+ async function readWebhookBody(
1534
+ req: HttpRequestLike,
1535
+ maxBytes: number,
1536
+ ): Promise<string> {
1537
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
1538
+ const contentLength = req.headers.get("content-length");
1539
+
1540
+ if (contentLength !== null) {
1541
+ const declaredBytes = Number(contentLength);
1542
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
1543
+ const error = new WebhookBodyTooLargeError(maxBytes);
1544
+ if (body) cancelWebhookBody(body, error);
1545
+ throw error;
1546
+ }
1547
+ }
1548
+
1549
+ if (!body) {
1550
+ const text = await req.text();
1551
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
1552
+ throw new WebhookBodyTooLargeError(maxBytes);
1553
+ }
1554
+ return text;
1555
+ }
1556
+
1557
+ const reader = body.getReader();
1558
+ const decoder = new TextDecoder();
1559
+ let receivedBytes = 0;
1560
+ let text = "";
1561
+
1562
+ try {
1563
+ while (true) {
1564
+ const result = await reader.read();
1565
+ if (result.done) break;
1566
+
1567
+ receivedBytes += result.value.byteLength;
1568
+ if (receivedBytes > maxBytes) {
1569
+ const error = new WebhookBodyTooLargeError(maxBytes);
1570
+ cancelWebhookBody(reader, error);
1571
+ throw error;
1572
+ }
1573
+ text += decoder.decode(result.value, { stream: true });
1574
+ }
1575
+ return text + decoder.decode();
1576
+ } finally {
1577
+ reader.releaseLock();
1578
+ }
1459
1579
  }
1460
1580
 
1461
1581
  function headersToRecord(headers: Headers): Record<string, string> {
@@ -1955,8 +2075,9 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1955
2075
  /**
1956
2076
  * Create a Next.js POST handler for generic verified inbound webhooks.
1957
2077
  *
1958
- * The route reads the raw request body as text, normalizes request headers,
1959
- * builds app context from the real request through
2078
+ * The route bounds and reads the raw request body before resolving the server,
2079
+ * then normalizes request headers and builds app context from a rehydrated
2080
+ * request through
1960
2081
  * `server.createRequestContext(...)`, verifies the event through the webhook
1961
2082
  * definition or a context-aware verifier, validates the matching event
1962
2083
  * payload schema, and delegates fulfillment to app code.
@@ -1970,28 +2091,43 @@ export function createWebhookRoute<
1970
2091
  ): {
1971
2092
  POST: (req: Request) => Promise<Response>;
1972
2093
  } {
2094
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
2095
+ const preflightBodies = new WeakMap<Request, string>();
2096
+
1973
2097
  const handleWebhookRequest = async ({
1974
2098
  req,
1975
2099
  ctx,
2100
+ rawBody: providedRawBody,
1976
2101
  }: {
1977
2102
  req: HttpRequestLike;
1978
2103
  ctx: Ctx;
2104
+ rawBody?: string;
1979
2105
  }): Promise<Response> => {
1980
2106
  const nativeReq = nativeRequestOf(req);
1981
2107
  const headers = resolveHeaders(options.headers, nativeReq);
1982
2108
  const requestHeaders = headersToRecord(new Headers(req.headers));
1983
2109
 
1984
- let rawBody: string;
2110
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
2111
+ preflightBodies.delete(nativeReq);
1985
2112
 
1986
- try {
1987
- rawBody = await req.text();
1988
- } catch {
1989
- return operationalErrorJson(
1990
- "WEBHOOK_BODY_READ_FAILED",
1991
- "Webhook body read failed.",
1992
- 400,
1993
- headers,
1994
- );
2113
+ if (rawBody === undefined) {
2114
+ try {
2115
+ rawBody = await readWebhookBody(req, maxBodyBytes);
2116
+ } catch (error) {
2117
+ return error instanceof WebhookBodyTooLargeError
2118
+ ? operationalErrorJson(
2119
+ "WEBHOOK_BODY_TOO_LARGE",
2120
+ "Webhook body exceeds the configured size limit.",
2121
+ 413,
2122
+ headers,
2123
+ )
2124
+ : operationalErrorJson(
2125
+ "WEBHOOK_BODY_READ_FAILED",
2126
+ "Webhook body read failed.",
2127
+ 400,
2128
+ headers,
2129
+ );
2130
+ }
1995
2131
  }
1996
2132
 
1997
2133
  let event: WebhookRouteHandlerArgs<
@@ -2102,32 +2238,42 @@ export function createWebhookRoute<
2102
2238
 
2103
2239
  return {
2104
2240
  POST: async (req) => {
2105
- const server = await resolveServerSource(options.server);
2106
-
2107
- const pipelined = runPipeline(server);
2108
- if (pipelined) return pipelined(req);
2109
-
2110
- // Minimal servers without rawRoute(...) — usually test fakes — build
2111
- // context directly and run the same handler outside the hooks pipeline.
2112
2241
  const headers = resolveHeaders(options.headers, req);
2113
2242
 
2114
2243
  let rawBody: string;
2115
2244
  try {
2116
- rawBody = await req.text();
2117
- } catch {
2118
- return operationalErrorJson(
2119
- "WEBHOOK_BODY_READ_FAILED",
2120
- "Webhook body read failed.",
2121
- 400,
2122
- headers,
2123
- );
2245
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
2246
+ } catch (error) {
2247
+ return error instanceof WebhookBodyTooLargeError
2248
+ ? operationalErrorJson(
2249
+ "WEBHOOK_BODY_TOO_LARGE",
2250
+ "Webhook body exceeds the configured size limit.",
2251
+ 413,
2252
+ headers,
2253
+ )
2254
+ : operationalErrorJson(
2255
+ "WEBHOOK_BODY_READ_FAILED",
2256
+ "Webhook body read failed.",
2257
+ 400,
2258
+ headers,
2259
+ );
2260
+ }
2261
+
2262
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
2263
+ const server = await resolveServerSource(options.server);
2264
+ const pipelined = runPipeline(server);
2265
+ if (pipelined) {
2266
+ preflightBodies.set(contextRequest, rawBody);
2267
+ return pipelined(contextRequest);
2124
2268
  }
2125
2269
 
2126
- const contextRequest = toContextRequest(req, rawBody);
2270
+ // Minimal servers without rawRoute(...) — usually test fakes — build
2271
+ // context directly and run the same handler outside the hooks pipeline.
2272
+ const requestLike = toRequestLike(contextRequest);
2127
2273
 
2128
2274
  let ctx: Ctx;
2129
2275
  try {
2130
- ctx = await server.createRequestContext(contextRequest);
2276
+ ctx = await server.createRequestContext(requestLike);
2131
2277
  } catch {
2132
2278
  return operationalErrorJson(
2133
2279
  "WEBHOOK_CONTEXT_FAILED",
@@ -2137,7 +2283,11 @@ export function createWebhookRoute<
2137
2283
  );
2138
2284
  }
2139
2285
 
2140
- return handleWebhookRequest({ req: toContextRequest(req, rawBody), ctx });
2286
+ return handleWebhookRequest({
2287
+ req: requestLike,
2288
+ ctx,
2289
+ rawBody,
2290
+ });
2141
2291
  },
2142
2292
  };
2143
2293
  }
@@ -2145,8 +2295,9 @@ export function createWebhookRoute<
2145
2295
  /**
2146
2296
  * Create a Next.js POST handler for provider-verified payment webhooks.
2147
2297
  *
2148
- * The route extracts the provider signature header, reads the raw request
2149
- * body as text, builds app context from the real request through
2298
+ * The route checks the provider signature header and bounds the raw request
2299
+ * body before resolving the server, then builds app context from a rehydrated
2300
+ * request through
2150
2301
  * `server.createRequestContext(...)`, verifies the event through
2151
2302
  * `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet
2152
2303
  * billing flows backed by `PaymentsPort`. Keep this route on the Node.js
@@ -2160,19 +2311,27 @@ export function createPaymentWebhookRoute<
2160
2311
  POST: (req: Request) => Promise<Response>;
2161
2312
  } {
2162
2313
  const signatureHeader = options.signatureHeader ?? "stripe-signature";
2314
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
2315
+ const preflightBodies = new WeakMap<Request, string>();
2163
2316
 
2164
2317
  const handlePaymentWebhook = async ({
2165
2318
  req,
2166
2319
  ctx,
2320
+ rawBody: providedRawBody,
2167
2321
  }: {
2168
2322
  req: HttpRequestLike;
2169
2323
  ctx: Ctx;
2324
+ rawBody?: string;
2170
2325
  }): Promise<Response> => {
2171
2326
  const nativeReq = nativeRequestOf(req);
2172
2327
  const headers = resolveHeaders(options.headers, nativeReq);
2173
2328
  const signature = new Headers(req.headers).get(signatureHeader);
2174
2329
 
2175
2330
  if (!signature) {
2331
+ cancelWebhookRequestBody(
2332
+ req,
2333
+ new Error(`Missing ${signatureHeader} header.`),
2334
+ );
2176
2335
  return operationalErrorJson(
2177
2336
  "PAYMENT_WEBHOOK_SIGNATURE_MISSING",
2178
2337
  `Missing ${signatureHeader} header.`,
@@ -2181,17 +2340,27 @@ export function createPaymentWebhookRoute<
2181
2340
  );
2182
2341
  }
2183
2342
 
2184
- let rawBody: string;
2343
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
2344
+ preflightBodies.delete(nativeReq);
2185
2345
 
2186
- try {
2187
- rawBody = await req.text();
2188
- } catch {
2189
- return operationalErrorJson(
2190
- "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2191
- "Payment webhook body read failed.",
2192
- 400,
2193
- headers,
2194
- );
2346
+ if (rawBody === undefined) {
2347
+ try {
2348
+ rawBody = await readWebhookBody(req, maxBodyBytes);
2349
+ } catch (error) {
2350
+ return error instanceof WebhookBodyTooLargeError
2351
+ ? operationalErrorJson(
2352
+ "PAYMENT_WEBHOOK_BODY_TOO_LARGE",
2353
+ "Payment webhook body exceeds the configured size limit.",
2354
+ 413,
2355
+ headers,
2356
+ )
2357
+ : operationalErrorJson(
2358
+ "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2359
+ "Payment webhook body read failed.",
2360
+ 400,
2361
+ headers,
2362
+ );
2363
+ }
2195
2364
  }
2196
2365
 
2197
2366
  let event: PaymentWebhookEvent;
@@ -2274,17 +2443,15 @@ export function createPaymentWebhookRoute<
2274
2443
 
2275
2444
  return {
2276
2445
  POST: async (req) => {
2277
- const server = await resolveServerSource(options.server);
2278
-
2279
- const pipelined = runPipeline(server);
2280
- if (pipelined) return pipelined(req);
2281
-
2282
- // Minimal servers without rawRoute(...) — usually test fakes — build
2283
- // context directly and run the same handler outside the hooks pipeline.
2284
- // The signature gate runs first so no context is created without one.
2285
2446
  const headers = resolveHeaders(options.headers, req);
2286
2447
 
2287
2448
  if (!req.headers.get(signatureHeader)) {
2449
+ if (req.body) {
2450
+ cancelWebhookBody(
2451
+ req.body,
2452
+ new Error(`Missing ${signatureHeader} header.`),
2453
+ );
2454
+ }
2288
2455
  return operationalErrorJson(
2289
2456
  "PAYMENT_WEBHOOK_SIGNATURE_MISSING",
2290
2457
  `Missing ${signatureHeader} header.`,
@@ -2295,19 +2462,38 @@ export function createPaymentWebhookRoute<
2295
2462
 
2296
2463
  let rawBody: string;
2297
2464
  try {
2298
- rawBody = await req.text();
2299
- } catch {
2300
- return operationalErrorJson(
2301
- "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2302
- "Payment webhook body read failed.",
2303
- 400,
2304
- headers,
2305
- );
2465
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
2466
+ } catch (error) {
2467
+ return error instanceof WebhookBodyTooLargeError
2468
+ ? operationalErrorJson(
2469
+ "PAYMENT_WEBHOOK_BODY_TOO_LARGE",
2470
+ "Payment webhook body exceeds the configured size limit.",
2471
+ 413,
2472
+ headers,
2473
+ )
2474
+ : operationalErrorJson(
2475
+ "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2476
+ "Payment webhook body read failed.",
2477
+ 400,
2478
+ headers,
2479
+ );
2480
+ }
2481
+
2482
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
2483
+ const server = await resolveServerSource(options.server);
2484
+ const pipelined = runPipeline(server);
2485
+ if (pipelined) {
2486
+ preflightBodies.set(contextRequest, rawBody);
2487
+ return pipelined(contextRequest);
2306
2488
  }
2307
2489
 
2490
+ // Minimal servers without rawRoute(...) — usually test fakes — build
2491
+ // context directly and run the same handler outside the hooks pipeline.
2492
+ const requestLike = toRequestLike(contextRequest);
2493
+
2308
2494
  let ctx: Ctx;
2309
2495
  try {
2310
- ctx = await server.createRequestContext(toContextRequest(req, rawBody));
2496
+ ctx = await server.createRequestContext(requestLike);
2311
2497
  } catch {
2312
2498
  return operationalErrorJson(
2313
2499
  "PAYMENT_WEBHOOK_CONTEXT_FAILED",
@@ -2317,7 +2503,11 @@ export function createPaymentWebhookRoute<
2317
2503
  );
2318
2504
  }
2319
2505
 
2320
- return handlePaymentWebhook({ req: toContextRequest(req, rawBody), ctx });
2506
+ return handlePaymentWebhook({
2507
+ req: requestLike,
2508
+ ctx,
2509
+ rawBody,
2510
+ });
2321
2511
  },
2322
2512
  };
2323
2513
  }