lambder 4.6.2 → 4.7.3

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/Readme.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
4
 
5
+ **New in 4.7:**
6
+
7
+ - **Compressed request payloads**: `requestCompression` on `LambderCaller` gzips the payload of any call whose JSON reaches a threshold (`true` is `{ minBytes: 4096 }`), sending it as `payloadGz` beside its byte length instead of `payload` whenever that is actually smaller; the server restores it before rate-limit key slices, guards and input validation, so no call site, handler or schema changes. Chiefly a way to fit a large payload under Lambda's ~6MB invoke cap, which applies to the compressed bytes. The envelope stays `application/json` with its routing fields in plain text, so gateways, CDNs and mocks are unaffected. `maxRequestPayloadBytes` (default 20MB) bounds what a body may expand to.
8
+ - **One compression codec**: `shared/LambderCompressionCodec.ts` is now the only place Lambder compresses or decompresses bytes. Its `restoreBoundedText(bytes, declaredBytes, encoding)` carries the guarantee every compressed value in Lambder depends on, at rest and on the wire: the declared UTF-8 byte length bounds the decompression AND must match the result exactly, so a truncated, tampered or endlessly-expanding input fails instead of decoding to something merely plausible. Compression is split across three modules by what each one needs: the codec (zlib), the option and its resolver (pure, so the browser entry can resolve the caller's option), and the request payload format (the browser's CompressionStream). `stores/LambderDdbCompression.ts` is retired into them.
9
+ - **One compression option, now everywhere**: the HTTP response option and the new request option resolve through the same `resolveCompressionOption` the DynamoDB stores and sessions use, and every site's option is the one generic `LambderCompressionOption<Settings>`. Same vocabulary at every site (`true` for that site's defaults, `false` for off, an object to override, `minBytes` as the threshold, `quality` as the Brotli quality, `encodings` as the negotiation order), same `Settings | null` resolved shape, and the same startup validation: `compression: { quality: 99 }` or `{ encodings: [] }` on a response is now a construction error instead of being silently ignored, and a field set to `undefined` keeps its default.
10
+ - **Brotli responses**: response compression now negotiates `br` before `gzip`, smaller at comparable speed (15-25% on markup and prose, substantially more on the repetitive record lists API responses tend to be), which is bandwidth saved and headroom gained against the ~6MB response cap. `compression: { encodings: ["gzip"] }` opts out, `quality` (default 5) tunes it.
11
+ - **Mandatory authorization on session APIs**: `requireSessionApiGuards: true` at creation makes `guards` a required field of every `addSessionApi`, at the type level (a missing declaration is a compile error at the registration site) and at registration (a plain-JS caller throws). An API the session alone authorizes declares a named no-op session guard, so every opt-out is explicit and one grep lists them all. The class of defect this closes is "the guard existed and the endpoint did not use it", which review discipline does not catch as a surface grows.
12
+
5
13
  **New in 4.6:**
6
14
 
7
15
  - **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
@@ -403,7 +411,7 @@ Semantics:
403
411
 
404
412
  #### Session data at rest (`compression`)
405
413
 
406
- `session.data` is stored Brotli-compressed by default: the record carries the data's JSON as Brotli bytes in `dataBr` beside its byte length in `dataBytes`, in place of a plain `data` attribute. It is the scheme `LambderDdbCache` and `LambderDdbIdempotency` already use, from one shared implementation, and the byte length both bounds the decompression and verifies it, so a truncated record fails to decode rather than decoding to something else. Session data that caches roles, permissions or product lists typically shrinks 2-3x, which keeps a growing session within one DynamoDB read unit (4KB for the consistent reads sessions use) and one write unit (1KB) for longer.
414
+ `session.data` is stored Brotli-compressed by default: the record carries the data's JSON as Brotli bytes in `dataBr` beside its byte length in `dataBytes`, in place of a plain `data` attribute. It is the scheme `LambderDdbCache` and `LambderDdbIdempotency` already use, from the one shared codec that also restores compressed request payloads, and the byte length both bounds the decompression and verifies it, so a truncated record fails to decode rather than decoding to something else. Session data that caches roles, permissions or product lists typically shrinks 2-3x, which keeps a growing session within one DynamoDB read unit (4KB for the consistent reads sessions use) and one write unit (1KB) for longer.
407
415
 
408
416
  ```typescript
409
417
  session: {
@@ -567,9 +575,18 @@ The `ctx` object provides access to request data:
567
575
  | `await res.file(path, options? & { fallback? })` | Serve file from public directory (404 when missing) |
568
576
  | `await res.templateFile(path, data?, options?)` | Render an HTML file via LambderTemplatingEngine (cached; throws when missing) |
569
577
  | `res.api(payload, config?, options?)` | Standardized API response |
570
- | `res.apiBinary(payload, config?, options?)` | API response with forced gzip |
578
+ | `res.apiBinary(payload, config?, options?)` | API response with forced compression |
579
+
580
+ Responses are finalized once at the end of the request: automatic compression (when the client accepts it, the body is compressible and large enough), automatic ETag + `If-None-Match` 304 handling on GET/HEAD, and a clear error if the body would exceed Lambda's ~6MB cap. Override per response with `compress: true | false` and `etag: false`.
571
581
 
572
- Responses are finalized once at the end of the request: automatic gzip (when the client accepts it, the body is compressible and large enough), automatic ETag + `If-None-Match` 304 handling on GET/HEAD, and a clear error if the body would exceed Lambda's ~6MB cap. Override per response with `compress: true | false` and `etag: false`.
582
+ The encoding is negotiated against `Accept-Encoding` in the order `compression.encodings` declares, `["br", "gzip"]` by default. Brotli at quality 5 (`compression.quality`) runs at roughly gzip's speed while producing smaller bodies: 15-25% on markup and prose, and substantially more on the repetitive record lists API responses tend to be. Because the ~6MB cap is checked on the FINAL body, that is headroom as well as bandwidth. A client that offers only gzip gets gzip, and `compression: { encodings: ["gzip"] }` turns Brotli off entirely for a CDN or client that mishandles it. `Vary: Accept-Encoding` rides every compressible response, whether or not this particular client accepted an encoding, so shared caches stay correct.
583
+
584
+ ```typescript
585
+ initLambder().create({
586
+ compression: { minBytes: 860, encodings: ["br", "gzip"], quality: 5 }, // the defaults
587
+ // compression: false, // no automatic compression at all
588
+ });
589
+ ```
573
590
 
574
591
  **API Config Options**: `{ notAuthorized, message, errorMessage, versionExpired, sessionExpired, logList }`
575
592
 
@@ -715,6 +732,24 @@ lambder.addSessionApi("secure.order.create", {
715
732
 
716
733
  Guard results are typed end to end: the handler's `ctx.guardData` carries exactly the declared guards that return a value, a session guard on a public API is a compile error (and a startup assert), an apiInput guard is declarable only where the API's schema carries its fields, and a parameterized guard's param is typechecked in the declaration.
717
734
 
735
+ **Requiring an authorization declaration (`requireSessionApiGuards`)**: by default a session API may declare no guards, which reads as "any signed-in user". Once an app has an authorization vocabulary, that silence is where defects hide: the guard exists, a new endpoint forgets it, and nothing notices. With `requireSessionApiGuards: true` at creation, `guards` becomes a required field of every `addSessionApi`: omitting it is a compile error at the registration site ("Property 'guards' is missing"), and a plain-JS registration throws. Public APIs are unaffected. An API that legitimately needs no authorization beyond the session (the signed-in user's own account, a log-out) declares a named no-op session guard, so the opt-out is explicit, greppable, and cannot be used on a public API:
736
+
737
+ ```typescript
738
+ const lambder = initLambder<SessionData>().create({
739
+ apiPath: "/api",
740
+ guards: {
741
+ orgPermission: lambderGuard({ session: true, handler: (ctx, _p, _r, permission: PermissionString) => requireOrRefuse(ctx.session, permission) }),
742
+ // The one opt-out: the session itself is the whole authorization.
743
+ sessionOnly: lambderGuard({ session: true, handler: () => {} }),
744
+ },
745
+ requireSessionApiGuards: true,
746
+ });
747
+
748
+ lambder.addSessionApi("secure.order.create", { input, output, guards: { orgPermission: "ORDERS.CREATE" } }, handler);
749
+ lambder.addSessionApi("secure.me.logOut", { input, output, guards: "sessionOnly" }, handler);
750
+ lambder.addSessionApi("secure.report.list", { input, output }, handler); // compile error: which guard?
751
+ ```
752
+
718
753
  For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
719
754
 
720
755
  ```typescript
@@ -823,6 +858,38 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
823
858
  // - Expected output type
824
859
  ```
825
860
 
861
+ ### Compressed Request Payloads
862
+
863
+ Large payloads run into Lambda's ~6MB invoke payload cap long before the API Gateway limit, and the cap applies to what the gateway hands the function. `requestCompression` gzips the payload of any call whose JSON reaches the threshold, so that budget holds the compressed bytes instead of the raw ones:
864
+
865
+ ```typescript
866
+ const lambderCaller = new LambderCaller<ApiContractType>({
867
+ apiPath: "/api",
868
+ isCorsEnabled: false,
869
+ requestCompression: true, // { minBytes: 4096 }
870
+ // requestCompression: { minBytes: 64_000 }, // only genuinely large calls
871
+ });
872
+
873
+ // Nothing at the call sites changes; this one goes compressed, that one plain.
874
+ await lambderCaller.api("importStops", { stops: bigArray });
875
+ await lambderCaller.api("getStop", { id: "42" });
876
+
877
+ // Per call, either way:
878
+ await lambderCaller.api("importStops", huge, { compressRequest: false });
879
+ ```
880
+
881
+ A compressed call sends `payloadGz` (gzip bytes, base64) beside `payloadBytes` (the JSON's UTF-8 byte length) in place of `payload`. It is only sent when it is smaller than the JSON it replaces: a payload that is mostly a base64 image gzips to nearly its own size, and such a call goes plain rather than slightly larger. Everything else in the envelope stays plain text, so `apiName` routing, request logs and MSW mocks are unaffected, and the request stays `application/json`: no `Content-Encoding` negotiation for a gateway, CDN or proxy to get wrong, and no new CORS preflight surface. Base64 inside the JSON rather than a binary body is not a compromise for the size cap, because API Gateway hands a binary request body to Lambda base64-encoded anyway; base64's 4/3 overhead applies to bytes that already shrank several times over. Record-shaped JSON typically gzips 5-10x, so a ~5MB budget of compressed payload carries roughly 25-40MB of it.
882
+
883
+ The option is off by default and safe to turn on or off at any time: the server understands both shapes regardless, so a deployed client and server never need to agree. gzip rather than Brotli because the browser's `CompressionStream` offers gzip and deflate only; responses, compressed by Node, do prefer Brotli. A runtime without `CompressionStream` sends payloads plainly.
884
+
885
+ **Server side**: nothing to enable. The payload is restored before rate-limit key slices, guards and input validation run, so handlers, schemas and policies see an ordinary payload and need no awareness of the wire format. `payloadBytes` both bounds the decompression and verifies it (the restored length must match exactly), so a truncated or hostile body is refused rather than expanded, and `maxRequestPayloadBytes` at creation (default 20,000,000) caps what any body may expand to. Size that ceiling to the function's memory: the restored JSON is parsed in full before any session or policy check, and a parsed document occupies several times its text size on the heap. Every malformed case answers a 400 envelope coded `lambder/invalid-request-payload` instead of a 500.
886
+
887
+ ```typescript
888
+ initLambder().create({ apiPath: "/api", maxRequestPayloadBytes: 20_000_000 });
889
+ ```
890
+
891
+ Compression moves the ceiling rather than removing it. Past roughly 25-40MB of JSON the answer is a presigned S3 upload plus a job reference, or chunking, not a better codec.
892
+
826
893
  ### Failure Semantics (apiOutcome, timeouts, per-call overrides)
827
894
 
828
895
  `api()` collapses every failure to `null`, which is indistinguishable from a legitimately-null payload. When the call site needs to know why, use `apiOutcome()`; it never throws and resolves to a discriminated union:
@@ -1,3 +1,4 @@
1
+ import { type LambderRequestCompressionOption } from '../shared/LambderRequestPayload.js';
1
2
  import type { LambderApiResponse } from '../shared/LambderApiContract.js';
2
3
  import type { ApiContractShape } from '../shared/LambderApiContract.js';
3
4
  import type { z } from "zod";
@@ -114,6 +115,13 @@ export type LambderCallOptions = {
114
115
  timeoutMs?: number;
115
116
  /** External abort signal, combined with the timeout when both are set. */
116
117
  signal?: AbortSignal;
118
+ /**
119
+ * Overrides the constructor's requestCompression for this call: `false`
120
+ * sends the payload plainly (a hot path where the CPU matters more than
121
+ * the bytes), `true` compresses it regardless of the size threshold.
122
+ * Either way a payload is only sent compressed when that is smaller.
123
+ */
124
+ compressRequest?: boolean;
117
125
  /**
118
126
  * Values for the API's guardInput-mode guards, keyed by guard name; sent
119
127
  * beside the payload and consumed by the guards before validation. The
@@ -159,6 +167,16 @@ type LambderCallerBaseOptions = {
159
167
  apiInputValidationErrorHandler?: ValidationErrorHandler;
160
168
  /** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
161
169
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
170
+ /**
171
+ * Gzip the payload of calls whose JSON reaches the threshold, sending it
172
+ * as `payloadGz` beside its byte length instead of `payload` whenever
173
+ * that is smaller (a base64 image, say, is not, and goes plain). Off by
174
+ * default; `true` is `{ minBytes: 4096 }`. Nothing at the call sites
175
+ * changes, and the server understands both shapes either way, so it can
176
+ * be turned on or off freely. Chiefly a way to fit a large payload under
177
+ * Lambda's ~6MB invoke cap, which applies to the compressed bytes.
178
+ */
179
+ requestCompression?: LambderRequestCompressionOption;
162
180
  };
163
181
  /** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
164
182
  export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & GuardInputsProviderOption<TContract, TProvided>;
@@ -186,6 +204,7 @@ export default class LambderCaller<TContract extends ApiContractShape = any, TPr
186
204
  private sessionTokenCookieKey;
187
205
  private sessionCsrfCookieKey;
188
206
  private sessionCookieDomain?;
207
+ private requestCompression;
189
208
  constructor(options: LambderCallerOptions<TContract, TProvidedGuards>);
190
209
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
191
210
  /**
@@ -1,4 +1,6 @@
1
1
  import Cookies from 'js-cookie';
2
+ import { compressPayloadJson, isRequestCompressionAvailable, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from '../shared/LambderRequestPayload.js';
3
+ import { resolveCompressionOption } from '../shared/LambderCompressionOption.js';
2
4
  /**
3
5
  * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
4
6
  * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
@@ -23,15 +25,18 @@ export default class LambderCaller {
23
25
  sessionTokenCookieKey = "LMDRSESSIONTKID";
24
26
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
25
27
  sessionCookieDomain;
28
+ requestCompression;
26
29
  constructor(options) {
27
30
  // The conditional provider option is resolved per instantiation;
28
31
  // inside the class it is read through the plain shape.
29
- const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, guardInputsProvider, } = options;
32
+ const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, } = options;
30
33
  this.apiPath = apiPath ?? "/api";
31
34
  this.apiVersion = apiVersion;
32
35
  this.isCorsEnabled = isCorsEnabled;
33
36
  this.timeoutMs = timeoutMs;
34
37
  this.sessionCookieDomain = sessionCookieDomain;
38
+ // `?? false`: unlike the at-rest stores, this one is off unless asked for.
39
+ this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_REQUEST_COMPRESSION_SETTINGS);
35
40
  this.versionExpiredHandler = versionExpiredHandler;
36
41
  this.sessionExpiredHandler = sessionExpiredHandler;
37
42
  this.messageHandler = messageHandler;
@@ -178,6 +183,17 @@ export default class LambderCaller {
178
183
  const guardInputs = providedGuardInputs !== undefined || options?.guardInputs !== undefined
179
184
  ? { ...providedGuardInputs, ...options?.guardInputs }
180
185
  : undefined;
186
+ // Compressed when enabled and the payload's JSON reaches the
187
+ // threshold; `compressRequest` overrides both ways, and a runtime
188
+ // without CompressionStream always sends the payload plainly.
189
+ // Nothing here runs (the extra stringify included) unless
190
+ // compression is actually a possibility for this call.
191
+ const compressionMinBytes = options?.compressRequest === true ? 0
192
+ : options?.compressRequest === false ? null
193
+ : this.requestCompression?.minBytes ?? null;
194
+ const compressedPayload = compressionMinBytes !== null && payload !== undefined && isRequestCompressionAvailable()
195
+ ? await compressPayloadJson(JSON.stringify(payload), compressionMinBytes)
196
+ : null;
181
197
  let res;
182
198
  try {
183
199
  res = await fetch(this.apiPath, {
@@ -188,7 +204,8 @@ export default class LambderCaller {
188
204
  redirect: 'follow', referrerPolicy: 'origin',
189
205
  headers: { 'Content-Type': 'application/json', ...(headers || {}) },
190
206
  body: JSON.stringify({
191
- apiName, version, token, siteHost, payload,
207
+ apiName, version, token, siteHost,
208
+ ...(compressedPayload ?? { payload }),
192
209
  ...(guardInputs !== undefined ? { guardInputs } : {}),
193
210
  ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
194
211
  }),
@@ -1,3 +1,4 @@
1
+ import { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, decompressPayloadJson } from '../shared/LambderRequestPayload.js';
1
2
  export default class LambderMSW {
2
3
  apiPath;
3
4
  apiVersion;
@@ -42,6 +43,20 @@ export default class LambderMSW {
42
43
  if (body.apiName !== apiName) {
43
44
  return;
44
45
  }
46
+ // A caller with requestCompression on sends the payload gzipped;
47
+ // mock handlers still receive the payload itself, and the wire
48
+ // fields are consumed the way the server consumes them.
49
+ if (typeof body[COMPRESSED_PAYLOAD_FIELD] === 'string') {
50
+ try {
51
+ body.payload = await decompressPayloadJson(body[COMPRESSED_PAYLOAD_FIELD]);
52
+ }
53
+ catch {
54
+ console.warn("LambderMSW: Failed to decompress the request payload");
55
+ return;
56
+ }
57
+ delete body[COMPRESSED_PAYLOAD_FIELD];
58
+ delete body[COMPRESSED_PAYLOAD_BYTES_FIELD];
59
+ }
45
60
  try {
46
61
  // Add artificial delay if specified
47
62
  if (options?.delay) {
package/dist/client.d.ts CHANGED
@@ -11,6 +11,10 @@ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, La
11
11
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
12
12
  export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
13
  export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
14
+ export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
15
+ export type { LambderCompressedPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/LambderRequestPayload.js";
16
+ export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
17
+ export type { LambderCompressionOption, LambderCompressionSettingsBase } from "./shared/LambderCompressionOption.js";
14
18
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
15
19
  export { createLambderI18n } from "./shared/LambderI18n.js";
16
20
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
package/dist/client.js CHANGED
@@ -11,6 +11,10 @@ export { default as LambderCaller } from "./client/LambderCaller.js";
11
11
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
12
12
  // in the browser they are plain Errors).
13
13
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
14
+ // Request payload compression (browser-safe: gzip via CompressionStream, no Node built-ins).
15
+ export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
16
+ // The compression option vocabulary every Lambder surface shares (pure: no zlib).
17
+ export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
14
18
  // Type-safe templating (tagged templates with auto-escaping)
15
19
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
16
20
  // Typed translations (standalone, isomorphic)
@@ -2,11 +2,11 @@ import type { z } from "zod";
2
2
  import type { Context } from "aws-lambda";
3
3
  import LambderResolver from "./LambderResolver.js";
4
4
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
- import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
5
+ import { LambderResponse, type LambderHttpResponse, type LambderResponseCompressionOption } from "./LambderResponse.js";
6
6
  import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
7
  import { type LambderCorsConfig } from "./LambderCors.js";
8
8
  import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
9
- import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
9
+ import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
10
10
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
11
11
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
12
12
  import { LambderFiles, type LambderFilesOption } from "./LambderFiles.js";
@@ -21,7 +21,7 @@ type MaybePromise<T> = T | Promise<T>;
21
21
  type Path = `/${string}`;
22
22
  type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
23
23
  type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
24
- type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
24
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any>) => void | Promise<void>;
25
25
  /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
26
26
  type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
27
27
  type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
@@ -118,14 +118,26 @@ export type LambderCreateOptions<TSessionData = any> = {
118
118
  files?: LambderFilesOption;
119
119
  apiPath?: string;
120
120
  apiVersion?: string;
121
- /** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
122
- compression?: boolean | {
123
- minBytes?: number;
124
- };
121
+ /**
122
+ * Automatic compression for compressible responses. `true` (the default)
123
+ * is `{ minBytes: 860, encodings: ["br", "gzip"], quality: 5 }`; `false`
124
+ * disables it. `encodings` is a preference order, so `["gzip"]` opts out
125
+ * of Brotli for a client or CDN that mishandles it, and `quality` is the
126
+ * Brotli quality, the same field the at-rest stores take.
127
+ */
128
+ compression?: LambderResponseCompressionOption;
125
129
  /** Automatic ETag + If-None-Match 304 on GET/HEAD 200 responses. Default: true. */
126
130
  etag?: boolean;
127
131
  /** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
128
132
  maxResponseBytes?: number;
133
+ /**
134
+ * Ceiling on what a gzipped request payload may restore to (Lambda's
135
+ * ~6MB invoke cap already bounds the compressed bytes). Default:
136
+ * 20,000,000. Requests over it are refused rather than decompressed.
137
+ * The restored JSON is parsed in full before any policy or session
138
+ * check, so size it to the function's memory.
139
+ */
140
+ maxRequestPayloadBytes?: number;
129
141
  /** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
130
142
  cors?: boolean | LambderCorsConfig;
131
143
  /** DynamoDB-backed sessions; required for addSessionApi/addSessionRoute. */
@@ -134,9 +146,32 @@ export type LambderCreateOptions<TSessionData = any> = {
134
146
  rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>;
135
147
  /** Named guards APIs reference (typed) via the `guards` option; build each with lambderGuard(). */
136
148
  guards?: Record<string, LambderApiGuard<any, any, any>>;
149
+ /**
150
+ * Make an authorization declaration part of registering a session API:
151
+ * every addSessionApi must declare `guards`, at the type level (a
152
+ * missing `guards` is a compile error) and at registration (a plain-JS
153
+ * caller throws). An API that legitimately needs none, because the
154
+ * session itself is the whole authorization (the signed-in user's own
155
+ * account), declares a named no-op session guard, so every opt-out is
156
+ * explicit and one grep lists them all. Needs a guards map to pick
157
+ * from. Default: false.
158
+ */
159
+ requireSessionApiGuards?: boolean;
137
160
  /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
138
161
  idempotency?: LambderApiIdempotencyConfig;
139
162
  };
163
+ /**
164
+ * The `guards` field of a session API's options: optional by default,
165
+ * required once create() received requireSessionApiGuards, so that an
166
+ * authorization declaration cannot be forgotten at the type level.
167
+ */
168
+ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
169
+ /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Required on this instance (requireSessionApiGuards): an API the session alone authorizes declares the named no-op session guard. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
170
+ guards: TGuardsOpt;
171
+ } : {
172
+ /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
173
+ guards?: TGuardsOpt;
174
+ };
140
175
  /**
141
176
  * Main Lambder class for building type-safe serverless APIs. Create
142
177
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -148,6 +183,7 @@ export type LambderCreateOptions<TSessionData = any> = {
148
183
  * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
149
184
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
150
185
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
186
+ * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
151
187
  *
152
188
  * @example
153
189
  * ```typescript
@@ -158,7 +194,7 @@ export type LambderCreateOptions<TSessionData = any> = {
158
194
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
159
195
  * ```
160
196
  */
161
- export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false> {
197
+ export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false> {
162
198
  apiPath: string;
163
199
  apiVersion: null | string;
164
200
  /** The instance's file reader (source + caches), or null without the files option. */
@@ -190,6 +226,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
190
226
  private eventActionList;
191
227
  private corsConfig;
192
228
  private finalizeOptions;
229
+ private maxRequestPayloadBytes;
230
+ private requireSessionApiGuards;
193
231
  private lambderSessionManager?;
194
232
  private sessionCookieOptions;
195
233
  private sessionTokenCookieKey;
@@ -237,7 +275,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
237
275
  addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
238
276
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
239
277
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
240
- use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
278
+ use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
241
279
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
242
280
  input: TInput;
243
281
  output: TOutput;
@@ -250,20 +288,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
250
288
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
251
289
  ttlSeconds?: number;
252
290
  }) : never;
253
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
291
+ }, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
254
292
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
255
293
  input: TInput;
256
294
  output: TOutput;
257
295
  } & {
258
296
  /** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
259
297
  rateLimit?: TRateOpt;
260
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
261
- guards?: TGuardsOpt;
262
298
  /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
263
299
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
264
300
  ttlSeconds?: number;
265
301
  }) : never;
266
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
302
+ } & LambderSessionGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
267
303
  /**
268
304
  * Fetch the session or short-circuit the request: API calls get the
269
305
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -346,5 +382,5 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
346
382
  export declare const initLambder: <TSessionData = any>() => {
347
383
  create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
348
384
  policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
349
- } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false>;
385
+ } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false>;
350
386
  };
@@ -1,15 +1,17 @@
1
1
  import LambderResolver from "./LambderResolver.js";
2
2
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
- import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
3
+ import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
4
4
  import { compileRouteMatcher } from "./LambderRouting.js";
5
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
+ import { resolveCompressionOption } from "../shared/LambderCompressionOption.js";
7
8
  import LambderSessionController from "../session/LambderSessionController.js";
8
9
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
10
  import { LambderFiles } from "./LambderFiles.js";
10
11
  import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
11
12
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
12
- import { createContext, isV2HttpEvent } from "./LambderContext.js";
13
+ import { createContext, isV2HttpEvent, restoreCompressedApiPayload } from "./LambderContext.js";
14
+ import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPayload.js";
13
15
  /**
14
16
  * Main Lambder class for building type-safe serverless APIs. Create
15
17
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -21,6 +23,7 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
21
23
  * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
22
24
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
23
25
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
26
+ * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
24
27
  *
25
28
  * @example
26
29
  * ```typescript
@@ -63,6 +66,8 @@ export default class Lambder {
63
66
  eventActionList = [];
64
67
  corsConfig = null;
65
68
  finalizeOptions;
69
+ maxRequestPayloadBytes;
70
+ requireSessionApiGuards;
66
71
  lambderSessionManager;
67
72
  sessionCookieOptions = {};
68
73
  sessionTokenCookieKey = "LMDRSESSIONTKID";
@@ -72,13 +77,16 @@ export default class Lambder {
72
77
  this.apiPath = options.apiPath ?? "/api";
73
78
  this.apiVersion = options.apiVersion ?? null;
74
79
  this.finalizeOptions = {
75
- compression: options.compression === false
76
- ? false
77
- : { minBytes: (typeof options.compression === "object" ? options.compression.minBytes : undefined)
78
- ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
80
+ // Resolved (and validated) by the same function the at-rest
81
+ // stores use; on unless explicitly disabled.
82
+ compression: resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS),
79
83
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
80
84
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
81
85
  };
86
+ this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_REQUEST_PAYLOAD_BYTES;
87
+ if (!Number.isSafeInteger(this.maxRequestPayloadBytes) || this.maxRequestPayloadBytes <= 0) {
88
+ throw new Error("maxRequestPayloadBytes must be a positive integer");
89
+ }
82
90
  if (options.cors !== undefined && options.cors !== false) {
83
91
  this.corsConfig = options.cors === true ? {} : options.cors;
84
92
  }
@@ -107,6 +115,10 @@ export default class Lambder {
107
115
  this.getOrCreatePolicyEngine().addGuards(options.guards);
108
116
  if (options.idempotency)
109
117
  this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
118
+ this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
119
+ if (this.requireSessionApiGuards && !options.guards) {
120
+ throw new Error("Lambder: requireSessionApiGuards needs a guards map at creation for session APIs to declare from.");
121
+ }
110
122
  }
111
123
  setRouteFallbackHandler(routeFallbackHandler) {
112
124
  this.routeFallbackHandler = routeFallbackHandler;
@@ -203,6 +215,10 @@ export default class Lambder {
203
215
  throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
204
216
  }
205
217
  this.registeredApiNames.add(name);
218
+ if (mode === "session" && this.requireSessionApiGuards && options.guards === undefined) {
219
+ throw new Error(`Lambder: session API "${name}" declares no guards, and requireSessionApiGuards is on. ` +
220
+ `Declare the guard that authorizes it, or the named no-op guard that marks the session itself as the whole authorization.`);
221
+ }
206
222
  const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
207
223
  if (!usesPolicies)
208
224
  return;
@@ -233,6 +249,10 @@ export default class Lambder {
233
249
  // module may annotate its parameter as the bare Lambder<SessionData> or
234
250
  // as the app's narrowed alias, and both must chain. Registration-time
235
251
  // assertions still verify every referenced policy/guard name at runtime.
252
+ // Every policy generic must be listed here: one short of the class's
253
+ // parameter list and the missing one silently falls back to its default,
254
+ // which makes an instance carrying the non-default value unassignable to
255
+ // its own plugins (requireSessionApiGuards did exactly that in 4.7.1).
236
256
  use(plugin) {
237
257
  return plugin(this);
238
258
  }
@@ -454,6 +474,20 @@ export default class Lambder {
454
474
  if (this.apiVersion && ctx._otherInternal.requestVersion && ctx._otherInternal.requestVersion !== this.apiVersion) {
455
475
  return resolver.versionExpired();
456
476
  }
477
+ // A gzipped payload is restored before anything reads it: rate-limit
478
+ // key slices, guards and input validation all see a plain payload.
479
+ if (ctx._otherInternal.isApiCall) {
480
+ const restored = await restoreCompressedApiPayload(ctx, this.maxRequestPayloadBytes);
481
+ if (!restored.ok) {
482
+ return resolver.api(null, {
483
+ errorMessage: {
484
+ type: "error",
485
+ code: LAMBDER_REFUSAL_CODES.invalidRequestPayload,
486
+ content: restored.message,
487
+ },
488
+ }, { statusCode: 400 });
489
+ }
490
+ }
457
491
  let matched = null;
458
492
  for (const action of this.actionList) {
459
493
  const params = action.match(ctx);
@@ -58,3 +58,23 @@ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TP
58
58
  session: LambderSessionContext<SessionData>;
59
59
  };
60
60
  export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
61
+ /** Outcome of restoring a compressed request payload; the message is client-facing. */
62
+ export type LambderRestorePayloadResult = {
63
+ ok: true;
64
+ } | {
65
+ ok: false;
66
+ message: string;
67
+ };
68
+ /**
69
+ * Restores a request payload the caller sent gzipped (`payloadGz` +
70
+ * `payloadBytes`) onto ctx.post.payload and ctx.apiPayload, so every later
71
+ * stage (rate-limit key slices, guards, input validation, the handler) reads
72
+ * an ordinary payload and needs no awareness of the wire format. A request
73
+ * that sent a plain payload passes through untouched.
74
+ *
75
+ * Every failure answers with a message instead of throwing: a malformed body
76
+ * is a client error, not a crash. The declared byte length both bounds the
77
+ * decompression and verifies it, so an over-large or tampered body is
78
+ * refused rather than expanded.
79
+ */
80
+ export declare const restoreCompressedApiPayload: (ctx: LambderRenderContext, maxPayloadBytes: number) => Promise<LambderRestorePayloadResult>;
@@ -1,4 +1,6 @@
1
1
  import cookieParser from "cookie";
2
+ import { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, } from "../shared/LambderRequestPayload.js";
3
+ import { restoreBoundedText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "../shared/LambderCompressionCodec.js";
2
4
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
3
5
  export const isV2HttpEvent = (event) => !!event && typeof event === "object"
4
6
  && event.version === "2.0"
@@ -98,3 +100,55 @@ export const createContext = (event, lambdaContext, apiPath) => {
98
100
  }
99
101
  };
100
102
  };
103
+ /**
104
+ * Restores a request payload the caller sent gzipped (`payloadGz` +
105
+ * `payloadBytes`) onto ctx.post.payload and ctx.apiPayload, so every later
106
+ * stage (rate-limit key slices, guards, input validation, the handler) reads
107
+ * an ordinary payload and needs no awareness of the wire format. A request
108
+ * that sent a plain payload passes through untouched.
109
+ *
110
+ * Every failure answers with a message instead of throwing: a malformed body
111
+ * is a client error, not a crash. The declared byte length both bounds the
112
+ * decompression and verifies it, so an over-large or tampered body is
113
+ * refused rather than expanded.
114
+ */
115
+ export const restoreCompressedApiPayload = async (ctx, maxPayloadBytes) => {
116
+ const post = ctx.post;
117
+ const compressed = post[COMPRESSED_PAYLOAD_FIELD];
118
+ if (compressed === undefined)
119
+ return { ok: true };
120
+ if (typeof compressed !== "string") {
121
+ return { ok: false, message: `Request ${COMPRESSED_PAYLOAD_FIELD} must be a base64 string.` };
122
+ }
123
+ const declaredBytes = post[COMPRESSED_PAYLOAD_BYTES_FIELD];
124
+ if (typeof declaredBytes !== "number" || !Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
125
+ return { ok: false, message: `Request ${COMPRESSED_PAYLOAD_BYTES_FIELD} must be the payload's byte length.` };
126
+ }
127
+ if (declaredBytes > maxPayloadBytes) {
128
+ return { ok: false, message: `Request payload of ${declaredBytes} bytes exceeds the ${maxPayloadBytes} byte limit.` };
129
+ }
130
+ // The bound and the exact-length verification are the codec's, the same
131
+ // ones a stored record gets; only the wording of the refusal is ours.
132
+ let json;
133
+ try {
134
+ json = await restoreBoundedText(Buffer.from(compressed, "base64"), declaredBytes, "gzip");
135
+ }
136
+ catch (err) {
137
+ const reason = err instanceof LambderCompressionError ? err.reason : null;
138
+ return { ok: false, message: reason === LAMBDER_RESTORE_FAILURES.lengthMismatch
139
+ ? "Compressed request payload does not match its declared length."
140
+ : "Compressed request payload could not be decompressed." };
141
+ }
142
+ let payload;
143
+ try {
144
+ payload = JSON.parse(json);
145
+ }
146
+ catch {
147
+ return { ok: false, message: "Compressed request payload is not valid JSON." };
148
+ }
149
+ delete post[COMPRESSED_PAYLOAD_FIELD];
150
+ delete post[COMPRESSED_PAYLOAD_BYTES_FIELD];
151
+ post.payload = payload;
152
+ ctx.apiPayload = payload;
153
+ return { ok: true };
154
+ };