lambder 4.1.1 → 4.2.1

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,10 +2,18 @@
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.2:**
6
+
7
+ - **Rate-limit budgets are explicit**: every policy declares `budget: "perApi"` (each referencing API gets its own counter, so the numbers are a per-API ceiling) or `budget: "perPolicy"` (one counter shared by every API referencing the policy). There is no default, so a declaration always says what its numbers span; the policy is the group, and two separate shared budgets are two policies.
8
+ - **Per-API tuning**: the `rateLimit` option gained a map form like guards, `rateLimit: { lookupPerIp: { perMin: 20 } }`, which merges window overrides over a perApi policy's own (a tighter burst keeps the policy's daily cap). Overriding the windows of a perPolicy policy is a startup error; `errorMessage` is overridable on either.
9
+ - **Retry-After**: a 429 carries the exceeded window's reset as a `Retry-After` header (CORS exposes it by default via the new `exposeHeaders` option), `LambderCaller` failure outcomes surface it as `retryAfterSeconds`, `LambderDdbRateLimiter.isRateLimited()` answers `false | { window, limit, resetAt }`, and `LambderApiError`/`refuse()` accept `headers`.
10
+ - **One refusal shape**: every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` (`{ type, content }`), and a policy's `errorMessage` is typed as one, so an `errorMessageHandler` reading `.content` works everywhere.
11
+ - **One validation path**: preflight slices (guard `apiInput`/`guardInput`, rate-limit `apiInput` keys) answer through `setApiInputValidationErrorHandler` exactly like the API's own schema.
12
+
5
13
  **New in v4:**
6
14
 
7
15
  - **Declarative auth as guards**: guards take per-API params (`guards: { orgPermission: "SOME.PERMISSION" }`), can require a session (`session: true`, compile-checked), and RETURN typed values that land on the handler's `ctx.guardData[name]`. Together with the apiInput/guardInput input modes, permission checks and device auth become registration-time declarations instead of per-handler boilerplate.
8
- - **Hardened policy layer**: rate-limit policies can share one counter across APIs (`scope: "policy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
16
+ - **Hardened policy layer**: rate-limit policies can share one counter across APIs (now `budget: "perPolicy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
9
17
  - **Secrets hashed at rest**: session records store only sha256 hashes of the bearer secrets, so a session-table read yields no usable cookies; `LambderSessionReadError` keeps a DynamoDB blip from reading as a logout.
10
18
  - **Three package entry points**: `lambder` (server), `lambder/client` (browser-safe by construction: no AWS SDK, no Node built-ins), `lambder/testing` (`LambderMSW`); sources organized into core/policies/session/stores/client/shared.
11
19
  - **Configuration at creation**: `initLambder<SessionData>().create({...})` takes the WHOLE configuration (serving options, session, cors, rate limits, guards, idempotency) in one declaration; the enable/define chain methods are gone, so nothing can be half-configured or wired in the wrong order, and api modules annotate with `typeof lambderApp` derived from the real instance. Plus `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, and fail-open rate limiting logs its passes.
@@ -512,18 +520,22 @@ import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard
512
520
  const lambder = initLambder<SessionData>().create({
513
521
  apiPath: "/api",
514
522
  // 1. Rate limiting: your limiter instance + named policies. Each policy
515
- // declares its windows AND what one counter tracks ("per").
523
+ // declares its windows, what one counter tracks ("per"), and what one
524
+ // budget spans ("budget", required so the numbers are never ambiguous):
525
+ // "perApi" gives every referencing API its own counter (three APIs on a
526
+ // 60/min policy allow one IP 180/min in total), "perPolicy" makes every
527
+ // referencing API share ONE counter. The policy IS the group: separate
528
+ // shared budgets for, say, user APIs and report APIs are two policies.
516
529
  rateLimits: {
517
530
  limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
518
531
  policies: {
519
- authPerIp: { perMin: 5, perHour: 30, per: "ip" },
520
- writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
532
+ authPerIp: { perMin: 5, perHour: 30, per: "ip", budget: "perApi" },
533
+ writePerUser: { perMin: 30, per: "session", budget: "perApi" }, // only referable from addSessionApi (also enforced at compile time)
521
534
  codePerEmail: {
522
535
  perMin: 3,
523
- // scope "policy": ONE combined budget across every API that
524
- // references this policy (send + register + reset share the
525
- // 3/min). Default scope "api" gives each API its own counter.
526
- scope: "policy",
536
+ // ONE combined budget across every API that references this
537
+ // policy: send + register + reset share the 3/min.
538
+ budget: "perPolicy",
527
539
  // apiInput key: derives from the API's OWN payload. Validated
528
540
  // before it runs, typed in the handler, and the policy is only
529
541
  // referable from APIs whose input schema carries `email`.
@@ -580,14 +592,18 @@ lambder.addApi("public.resetPassword", {
580
592
  // in apiInput mode against the API's own payload.
581
593
  input: z.object({ email: z.string().email() }),
582
594
  output: z.object({ ok: z.boolean() }),
583
- rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
595
+ rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
584
596
  guards: "captcha", // one name, a list of names, or a { name: param } map
585
597
  }, handler);
586
598
 
587
599
  lambder.addSessionApi("secure.order.create", {
588
600
  input: OrderSchema,
589
601
  output: OrderResultSchema,
590
- rateLimit: "writePerUser",
602
+ // Map form: tune a perApi policy for this API. Overrides merge over the
603
+ // policy's windows (perMin here, the policy's other windows still apply)
604
+ // and errorMessage is overridable too. Window overrides on a perPolicy
605
+ // policy are a startup error: one shared counter has one set of limits.
606
+ rateLimit: { writePerUser: { perMin: 10 } },
591
607
  guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
592
608
  idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
593
609
  }, async (ctx, res) => {
@@ -619,7 +635,11 @@ const lambder = lambderApp.addHook(...).use(orderApi)...;
619
635
  export const handler = lambder.getHandler();
620
636
  ```
621
637
 
622
- Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
638
+ Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), carrying the standard `LambderRefusalMessage` shape unless a policy names its own `errorMessage`, so the caller's `errorMessageHandler` surfaces them with zero client code. A 429 also carries `Retry-After` (the exceeded fixed window's reset; `LambderCaller` outcomes expose it as `retryAfterSeconds`, and the CORS layer lists it in `Access-Control-Expose-Headers` by default).
639
+
640
+ **Rate limits count attempts, not successes.** Each window is one atomic conditional increment, and a refused request keeps every increment made before the refusal: the smaller windows of the refusing policy, every policy listed before it, and all of them when a later guard or the input validation refuses. There is no compensating decrement (it would give up the conditional-ADD atomicity and add a write per refusal). So order stacked policies by which counter you want charged on refusals: `["authPerIp", "codePerEmail"]` still charges the IP when the per-email cap refuses, which is the abuse-resistant direction.
641
+
642
+ Preflight input slices (guard `apiInput`/`guardInput` values, rate-limit `apiInput` keys) answer a rejection through the same path as the API's own schema: `setApiInputValidationErrorHandler` when set, otherwise the standard 422 body. One failure, one shape, whichever schema rejected it.
623
643
 
624
644
  **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
625
645
 
@@ -60,6 +60,8 @@ export type LambderApiOutcome<T> = {
60
60
  status?: number;
61
61
  /** Envelope errorMessage, when the server provided one. */
62
62
  errorMessage?: any;
63
+ /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it). */
64
+ retryAfterSeconds?: number;
63
65
  /** Underlying Error for network/timeout/server/unknown failures. */
64
66
  error?: Error;
65
67
  /** Zod issue detail for 'validation'. */
@@ -222,6 +222,10 @@ export default class LambderCaller {
222
222
  }
223
223
  return { ok: false, reason: 'validation', status: res.status, zodError };
224
224
  }
225
+ // Retry-After (delta-seconds) rides every refusal that knows its
226
+ // reset time, e.g. a rate limit; absent or unreadable is undefined.
227
+ const retryAfterValue = Number(res.headers.get("retry-after") ?? NaN);
228
+ const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
225
229
  let data;
226
230
  try {
227
231
  data = await res.json();
@@ -248,7 +252,7 @@ export default class LambderCaller {
248
252
  else {
249
253
  await reportError(new Error("Version Expired; Please refresh;"));
250
254
  }
251
- return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
255
+ return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
252
256
  }
253
257
  if (data.sessionExpired) {
254
258
  this.clearSessionCookies();
@@ -258,7 +262,7 @@ export default class LambderCaller {
258
262
  else {
259
263
  await reportError(new Error("Session Expired; Please log in again;"));
260
264
  }
261
- return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
265
+ return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
262
266
  }
263
267
  if (data.notAuthorized) {
264
268
  if (notAuthorizedHandler) {
@@ -267,7 +271,7 @@ export default class LambderCaller {
267
271
  else {
268
272
  await reportError(new Error("Not Authorized;"));
269
273
  }
270
- return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data };
274
+ return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
271
275
  }
272
276
  if (data.message && messageHandler) {
273
277
  await messageHandler(data.message);
@@ -276,7 +280,7 @@ export default class LambderCaller {
276
280
  if (errorMessageHandler) {
277
281
  await errorMessageHandler(data.errorMessage);
278
282
  }
279
- return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data };
283
+ return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
280
284
  }
281
285
  return { ok: true, payload: data.payload, response: data };
282
286
  }
@@ -9,7 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
9
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
10
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
11
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
12
- import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames } from "../policies/LambderApiRateLimits.js";
12
+ import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
13
13
  import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
14
14
  import type { MergeContract } from "../shared/LambderApiContract.js";
15
15
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
@@ -205,6 +205,13 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
205
205
  /** Apply the serveIndexHtml gates; null means fall through. */
206
206
  private tryServeIndexHtml;
207
207
  private getOrCreatePolicyEngine;
208
+ /**
209
+ * The response for a rejected input: the app's
210
+ * setApiInputValidationErrorHandler when set, otherwise the standard 422
211
+ * body. The API's own schema and every preflight slice (guard inputs,
212
+ * rate-limit keys) answer through here, so one failure has one shape.
213
+ */
214
+ private inputValidationRefusal;
208
215
  /** Registration-time checks shared by addApi/addSessionApi. */
209
216
  private assertApiRegistration;
210
217
  addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
@@ -212,11 +219,11 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
212
219
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
213
220
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
214
221
  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>;
215
- addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
222
+ 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: {
216
223
  input: TInput;
217
224
  output: TOutput;
218
225
  } & {
219
- /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
226
+ /** 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. */
220
227
  rateLimit?: TRateOpt;
221
228
  /** 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. */
222
229
  guards?: TGuardsOpt;
@@ -225,11 +232,11 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
225
232
  ttlSeconds?: number;
226
233
  }) : never;
227
234
  }, 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>;
228
- addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
235
+ 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: {
229
236
  input: TInput;
230
237
  output: TOutput;
231
238
  } & {
232
- /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
239
+ /** 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. */
233
240
  rateLimit?: TRateOpt;
234
241
  /** 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. */
235
242
  guards?: TGuardsOpt;
@@ -173,10 +173,23 @@ export default class Lambder {
173
173
  return response;
174
174
  }
175
175
  getOrCreatePolicyEngine() {
176
+ // Late-bound: the handler may be set after creation, so the engine
177
+ // asks at request time rather than capturing it here.
176
178
  if (!this.apiPolicyEngine)
177
- this.apiPolicyEngine = new LambderApiPolicyEngine();
179
+ this.apiPolicyEngine = new LambderApiPolicyEngine((ctx, resolver, zodError) => this.inputValidationRefusal(ctx, resolver, zodError));
178
180
  return this.apiPolicyEngine;
179
181
  }
182
+ /**
183
+ * The response for a rejected input: the app's
184
+ * setApiInputValidationErrorHandler when set, otherwise the standard 422
185
+ * body. The API's own schema and every preflight slice (guard inputs,
186
+ * rate-limit keys) answer through here, so one failure has one shape.
187
+ */
188
+ async inputValidationRefusal(ctx, resolver, zodError) {
189
+ if (this.apiInputValidationErrorHandler)
190
+ return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
191
+ return resolver.json({ error: "Input validation failed", zodError }, { statusCode: 422 });
192
+ }
180
193
  /** Registration-time checks shared by addApi/addSessionApi. */
181
194
  assertApiRegistration(name, mode, options) {
182
195
  if (this.registeredApiNames.has(name)) {
@@ -233,12 +246,8 @@ export default class Lambder {
233
246
  if (this.apiPolicyEngine)
234
247
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
235
248
  const inputResult = schema.input.safeParse(ctx.apiPayload);
236
- if (!inputResult.success) {
237
- if (this.apiInputValidationErrorHandler) {
238
- return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
239
- }
240
- return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
241
- }
249
+ if (!inputResult.success)
250
+ return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
242
251
  ctx.apiPayload = inputResult.data;
243
252
  const run = async () => await handler(ctx, resolver);
244
253
  if (this.apiPolicyEngine && schema.idempotency)
@@ -265,12 +274,8 @@ export default class Lambder {
265
274
  if (this.apiPolicyEngine)
266
275
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
267
276
  const inputResult = schema.input.safeParse(ctx.apiPayload);
268
- if (!inputResult.success) {
269
- if (this.apiInputValidationErrorHandler) {
270
- return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
271
- }
272
- return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
273
- }
277
+ if (!inputResult.success)
278
+ return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
274
279
  ctx.apiPayload = inputResult.data;
275
280
  const run = async () => await handler(ctx, resolver);
276
281
  if (this.apiPolicyEngine && schema.idempotency)
@@ -341,7 +346,10 @@ export default class Lambder {
341
346
  ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
342
347
  ...(err.notAuthorized ? { notAuthorized: true } : {}),
343
348
  ...(err.sessionExpired ? { sessionExpired: true } : {}),
344
- }, err.statusCode !== undefined ? { statusCode: err.statusCode } : undefined);
349
+ }, {
350
+ ...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
351
+ ...(err.headers ? { headers: err.headers } : {}),
352
+ });
345
353
  }
346
354
  getHandler() {
347
355
  return ((event, context) => Lambder.isHttpEvent(event)
@@ -415,7 +423,7 @@ export default class Lambder {
415
423
  if (isAPI) {
416
424
  if (this.apiFallbackHandler)
417
425
  return await this.apiFallbackHandler(ctx, resolver);
418
- return resolver.api(null, { errorMessage: "API not found." });
426
+ return resolver.api(null, { errorMessage: { type: "warning", content: "API not found." } });
419
427
  }
420
428
  if (this.publicFilesHandler) {
421
429
  const fileResponse = await this.publicFilesHandler.handle(ctx);
@@ -6,6 +6,12 @@ export type LambderCorsConfig = {
6
6
  credentials?: boolean;
7
7
  methods?: string[];
8
8
  allowHeaders?: string[];
9
+ /**
10
+ * Response headers a cross-origin browser caller may read. Default:
11
+ * ["Retry-After"], so rate-limit refusals stay readable (it is not on the
12
+ * CORS safelist, and a hidden header reads as null, not as an error).
13
+ */
14
+ exposeHeaders?: string[];
9
15
  maxAge?: number;
10
16
  };
11
17
  /** Mutate the response with the CORS headers the config allows for this request. */
@@ -27,4 +27,9 @@ export const applyCorsHeaders = (config, ctx, response, isPreflight) => {
27
27
  if (config.maxAge !== undefined)
28
28
  response.setHeader("Access-Control-Max-Age", String(config.maxAge));
29
29
  }
30
+ else {
31
+ const exposeHeaders = config.exposeHeaders ?? ["Retry-After"];
32
+ if (exposeHeaders.length)
33
+ response.setHeader("Access-Control-Expose-Headers", exposeHeaders.join(", "));
34
+ }
30
35
  };
package/dist/index.d.ts CHANGED
@@ -23,13 +23,13 @@ export { LambderSessionDataRefreshError, LambderSessionReadError } from "./sessi
23
23
  export { LambderDdbCache } from "./stores/LambderDdbCache.js";
24
24
  export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
25
25
  export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
26
- export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy, LambderRateLimitExceededMap, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
26
+ export type { LambderDdbRateLimiterOptions, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
27
27
  export { LambderDdbIdempotency } from "./stores/LambderDdbIdempotency.js";
28
28
  export type { LambderDdbIdempotencyOptions, LambderIdempotencyBeginResult, LambderIdempotencyDoneRecord, } from "./stores/LambderDdbIdempotency.js";
29
29
  export { lambderGuard } from "./policies/LambderApiGuards.js";
30
30
  export type { LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderAllowedGuardNames, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardsOptionValue, LambderGuardDataOf, LambderGuardInputsOf, } from "./policies/LambderApiGuards.js";
31
31
  export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
32
- export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, } from "./policies/LambderApiRateLimits.js";
32
+ export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderRateLimitOverride, LambderRateLimitOption, LambderRateLimitOptionValue, } from "./policies/LambderApiRateLimits.js";
33
33
  export type { LambderApiIdempotencyConfig } from "./policies/LambderApiIdempotency.js";
34
34
  export { createLambderI18n } from "./shared/LambderI18n.js";
35
35
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
@@ -1,6 +1,7 @@
1
1
  import type { z } from "zod";
2
2
  import type { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
3
3
  import type LambderResolver from "../core/LambderResolver.js";
4
+ import type { LambderResponse } from "../core/LambderResponse.js";
4
5
  /**
5
6
  * A named guard, run before the API's own input validation. Three input
6
7
  * modes:
@@ -30,9 +31,10 @@ import type LambderResolver from "../core/LambderResolver.js";
30
31
  * the API handler's context as `ctx.guardData[guardName]`, fully typed.
31
32
  * Guards that return nothing never appear in guardData.
32
33
  *
33
- * A validation failure answers the standard 422 shape, and the handler
34
- * refuses by throwing (typically refuse()/LambderApiError). Build with
35
- * lambderGuard() so the handler's payload/ctx/param types line up.
34
+ * A validation failure answers like the API's own input validation (the
35
+ * app's setApiInputValidationErrorHandler when set, else the standard 422),
36
+ * and the handler refuses by throwing (typically refuse()/LambderApiError).
37
+ * Build with lambderGuard() so the handler's payload/ctx/param types line up.
36
38
  */
37
39
  export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny, TParam = any, TOutput = any> = {
38
40
  apiInput: TInput;
@@ -198,20 +200,29 @@ type GuardInputsEntries<TGuards, TOpt> = {
198
200
  export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
199
201
  /** The guards option's runtime shape: a name, ordered names, or a name-to-param map. */
200
202
  export type LambderGuardsOptionValue = string | readonly string[] | Readonly<Record<string, unknown>>;
203
+ /**
204
+ * How a rejected input answers. Lambder binds this to its own decision
205
+ * (setApiInputValidationErrorHandler when set, else the standard 422 body),
206
+ * so the API's schema and every preflight slice refuse with one shape.
207
+ */
208
+ export type LambderInputValidationRefusal = (ctx: LambderRenderContext, resolver: LambderResolver, zodError: z.ZodError) => Promise<LambderResponse>;
201
209
  /**
202
210
  * Validate a preflight input slice (an apiInput slice of the raw payload, or
203
211
  * a guardInput value from the raw guardInputs map). Runs before the API's
204
- * own validation; failures answer the same 422 shape as regular input
205
- * validation. Shared with the rate-limit engine's apiInput-keyed policies.
212
+ * own validation; a failure throws the response `onInvalid` decides, the
213
+ * same one regular input validation answers. Shared with the rate-limit
214
+ * engine's apiInput-keyed policies.
206
215
  */
207
- export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, resolver: LambderResolver) => unknown;
216
+ export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, ctx: LambderRenderContext, resolver: LambderResolver, onInvalid: LambderInputValidationRefusal) => Promise<unknown>;
208
217
  /**
209
218
  * Runtime side of the guards subsystem: holds the defined guards, asserts
210
219
  * API registrations against them at startup, and executes an API's declared
211
220
  * guards during preflight. Composed into LambderApiPolicyEngine.
212
221
  */
213
222
  export declare class LambderApiGuardsEngine {
223
+ private readonly onInvalidInput;
214
224
  private guards;
225
+ constructor(onInvalidInput: LambderInputValidationRefusal);
215
226
  addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
216
227
  /** Startup validation of one API registration's guards option. */
217
228
  assertRegistration(apiName: string, mode: "public" | "session", guardsOption?: LambderGuardsOptionValue): void;
@@ -14,14 +14,14 @@ const toGuardEntries = (value) => {
14
14
  /**
15
15
  * Validate a preflight input slice (an apiInput slice of the raw payload, or
16
16
  * a guardInput value from the raw guardInputs map). Runs before the API's
17
- * own validation; failures answer the same 422 shape as regular input
18
- * validation. Shared with the rate-limit engine's apiInput-keyed policies.
17
+ * own validation; a failure throws the response `onInvalid` decides, the
18
+ * same one regular input validation answers. Shared with the rate-limit
19
+ * engine's apiInput-keyed policies.
19
20
  */
20
- export const parsePreflightSlice = (input, value, resolver) => {
21
+ export const parsePreflightSlice = async (input, value, ctx, resolver, onInvalid) => {
21
22
  const parsed = input.safeParse(value);
22
- if (!parsed.success) {
23
- throw resolver.json({ error: "Input validation failed", zodError: parsed.error }, { statusCode: 422 });
24
- }
23
+ if (!parsed.success)
24
+ throw await onInvalid(ctx, resolver, parsed.error);
25
25
  return parsed.data;
26
26
  };
27
27
  /**
@@ -30,7 +30,11 @@ export const parsePreflightSlice = (input, value, resolver) => {
30
30
  * guards during preflight. Composed into LambderApiPolicyEngine.
31
31
  */
32
32
  export class LambderApiGuardsEngine {
33
+ onInvalidInput;
33
34
  guards = {};
35
+ constructor(onInvalidInput) {
36
+ this.onInvalidInput = onInvalidInput;
37
+ }
34
38
  addGuards(guards) {
35
39
  for (const [name, guardDef] of Object.entries(guards)) {
36
40
  if (this.guards[name])
@@ -63,10 +67,10 @@ export class LambderApiGuardsEngine {
63
67
  const post = ctx.post;
64
68
  let payload;
65
69
  if (guardDef.apiInput) {
66
- payload = parsePreflightSlice(guardDef.apiInput, post?.payload, resolver);
70
+ payload = await parsePreflightSlice(guardDef.apiInput, post?.payload, ctx, resolver, this.onInvalidInput);
67
71
  }
68
72
  else if (guardDef.guardInput) {
69
- payload = parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], resolver);
73
+ payload = await parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], ctx, resolver, this.onInvalidInput);
70
74
  }
71
75
  // A guard's return value becomes the handler's typed
72
76
  // ctx.guardData[name]; check-only guards return undefined.
@@ -37,7 +37,11 @@ export class LambderApiIdempotencyEngine {
37
37
  if (rawKey === undefined || rawKey === null)
38
38
  return null;
39
39
  if (typeof rawKey !== "string" || rawKey.length < IDEMPOTENCY_MIN_KEY_LENGTH || rawKey.length > IDEMPOTENCY_MAX_KEY_LENGTH) {
40
- throw new LambderApiError(`Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`, { statusCode: 400 });
40
+ const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
41
+ throw new LambderApiError(content, {
42
+ statusCode: 400,
43
+ errorMessage: { type: "error", content },
44
+ });
41
45
  }
42
46
  return rawKey;
43
47
  }
@@ -112,7 +116,7 @@ export class LambderApiIdempotencyEngine {
112
116
  if (begun.state === "pending") {
113
117
  throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
114
118
  statusCode: 409,
115
- errorMessage: "This request is already being processed.",
119
+ errorMessage: { type: "warning", content: "This request is already being processed." },
116
120
  });
117
121
  }
118
122
  if (begun.state === "done") {
@@ -1,12 +1,12 @@
1
1
  import type { LambderRenderContext } from "../core/LambderContext.js";
2
2
  import type LambderResolver from "../core/LambderResolver.js";
3
3
  import type { LambderResponse } from "../core/LambderResponse.js";
4
- import { type LambderApiGuard, type LambderGuardsOptionValue } from "./LambderApiGuards.js";
5
- import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
4
+ import { type LambderApiGuard, type LambderGuardsOptionValue, type LambderInputValidationRefusal } from "./LambderApiGuards.js";
5
+ import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitOptionValue } from "./LambderApiRateLimits.js";
6
6
  import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
7
7
  /** The declarative options one API registration may carry. */
8
8
  type LambderApiPolicyOptions = {
9
- rateLimit?: string | readonly string[];
9
+ rateLimit?: LambderRateLimitOptionValue;
10
10
  guards?: LambderGuardsOptionValue;
11
11
  idempotency?: unknown;
12
12
  };
@@ -23,6 +23,8 @@ export declare class LambderApiPolicyEngine {
23
23
  private rateLimits;
24
24
  private guards;
25
25
  private idempotency;
26
+ /** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
27
+ constructor(onInvalidInput: LambderInputValidationRefusal);
26
28
  setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
27
29
  addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
28
30
  setIdempotency(config: LambderApiIdempotencyConfig): void;
@@ -11,9 +11,14 @@ import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
11
11
  * per-API options.
12
12
  */
13
13
  export class LambderApiPolicyEngine {
14
- rateLimits = new LambderApiRateLimitsEngine();
15
- guards = new LambderApiGuardsEngine();
14
+ rateLimits;
15
+ guards;
16
16
  idempotency = new LambderApiIdempotencyEngine();
17
+ /** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
18
+ constructor(onInvalidInput) {
19
+ this.rateLimits = new LambderApiRateLimitsEngine(onInvalidInput);
20
+ this.guards = new LambderApiGuardsEngine(onInvalidInput);
21
+ }
17
22
  setRateLimits(config) {
18
23
  this.rateLimits.configure(config);
19
24
  }
@@ -1,15 +1,18 @@
1
1
  import type { z } from "zod";
2
2
  import type { LambderRenderContext } from "../core/LambderContext.js";
3
3
  import type LambderResolver from "../core/LambderResolver.js";
4
- import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
4
+ import { type LambderRateLimitPolicy, type LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
5
+ import { type LambderRefusalMessage } from "../shared/LambderApiError.js";
6
+ import { type LambderInputValidationRefusal } from "./LambderApiGuards.js";
5
7
  /**
6
8
  * A custom rate-limit key. `apiInput` names the fields of the API's OWN
7
9
  * payload the key derives from: the slice is validated against the raw
8
- * payload before `handler` runs (failures answer the standard 422 validation
9
- * shape) and the handler receives it typed. Referencing the policy from an
10
- * API whose input schema does not carry those fields is a compile error, so
11
- * the API's schema stays the single owner of the field. Build with
12
- * lambderRateLimitKey() so the handler's payload type follows `apiInput`.
10
+ * payload before `handler` runs (failures answer like regular input
11
+ * validation, through setApiInputValidationErrorHandler when set) and the
12
+ * handler receives it typed. Referencing the policy from an API whose input
13
+ * schema does not carry those fields is a compile error, so the API's schema
14
+ * stays the single owner of the field. Build with lambderRateLimitKey() so
15
+ * the handler's payload type follows `apiInput`.
13
16
  */
14
17
  export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
15
18
  apiInput: TInput;
@@ -38,19 +41,27 @@ export declare function lambderRateLimitKey(key: {
38
41
  };
39
42
  /** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
40
43
  export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
41
- /** A named rate-limit policy: fixed windows plus the key one counter tracks. */
44
+ /**
45
+ * What one budget spans. Required on every policy, so a declaration always
46
+ * says what its numbers mean:
47
+ *
48
+ * - "perApi": every API referencing the policy gets its own counter, so the
49
+ * windows are a per-API ceiling (three APIs referencing a 60/min policy
50
+ * allow one subject 180/min in total). An API may tune the windows in its
51
+ * declaration: `rateLimit: { name: { perMin: 20 } }`.
52
+ * - "perPolicy": every API referencing the policy shares ONE counter, so the
53
+ * windows are one combined budget (e.g. one per-email allowance across
54
+ * send, register, and reset). The policy IS the group: to give user APIs
55
+ * and report APIs separate shared budgets, declare two policies.
56
+ */
57
+ export type LambderRateLimitBudget = "perApi" | "perPolicy";
58
+ /** A named rate-limit policy: fixed windows, the key one counter tracks, and what one budget spans. */
42
59
  export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
43
60
  per: LambderRateLimitPer;
44
- /**
45
- * What one counter spans. "api" (default): each API referencing the
46
- * policy gets its own counter, so the windows are a per-API budget.
47
- * "policy": every API referencing the policy shares one counter, so the
48
- * windows are one combined budget (e.g. one per-email allowance across
49
- * send, register, and reset endpoints).
50
- */
51
- scope?: "api" | "policy";
52
- /** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
53
- errorMessage?: any;
61
+ /** Whether the windows are a per-API ceiling or one budget shared by every referencing API. See LambderRateLimitBudget. */
62
+ budget: LambderRateLimitBudget;
63
+ /** Envelope errorMessage for refused requests. Default: a warning saying too many requests. */
64
+ errorMessage?: LambderRefusalMessage;
54
65
  };
55
66
  export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
56
67
  /** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
@@ -72,6 +83,29 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
72
83
  };
73
84
  } ? (TPayload extends z.output<S> ? K : never) : K;
74
85
  }[keyof TPolicies] & string;
86
+ /**
87
+ * What an API may override on a policy it references, in the map form of the
88
+ * rateLimit option. Windows merge over the policy's own (a tighter burst keeps
89
+ * the policy's daily cap) and are only overridable on "perApi" budgets: a
90
+ * shared counter has one set of numbers. errorMessage is per-API text, so it
91
+ * is overridable on either budget.
92
+ */
93
+ export type LambderRateLimitOverride = LambderRateLimitPolicy & {
94
+ errorMessage?: LambderRefusalMessage;
95
+ };
96
+ type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
97
+ budget: "perApi";
98
+ } ? LambderRateLimitOverride : Pick<LambderRateLimitOverride, "errorMessage">;
99
+ /**
100
+ * The per-API `rateLimit` option: one policy name, an ordered list of names,
101
+ * or an object map that can carry each policy's override (`true` applies the
102
+ * policy as declared). Map entries are checked in insertion order.
103
+ */
104
+ export type LambderRateLimitOption<TPolicies, TPayload, TIncludeSession extends boolean> = LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> | readonly LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession>[] | {
105
+ readonly [K in LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
106
+ };
107
+ /** The rateLimit option's runtime shape: a name, ordered names, or a name-to-override map (LambderRateLimitOption narrows the names and overrides per policy). */
108
+ export type LambderRateLimitOptionValue = string | readonly string[] | Readonly<Record<string, true | LambderRateLimitOverride | undefined>>;
75
109
  /**
76
110
  * Runtime side of the rate-limit subsystem: holds the limiter and its named
77
111
  * policies, asserts API registrations against them at startup, and checks an
@@ -79,12 +113,21 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
79
113
  * LambderApiPolicyEngine.
80
114
  */
81
115
  export declare class LambderApiRateLimitsEngine {
116
+ private readonly onInvalidInput;
82
117
  private limiter;
83
118
  private policies;
119
+ constructor(onInvalidInput: LambderInputValidationRefusal);
84
120
  configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
85
121
  /** Startup validation of one API registration's rateLimit option. */
86
- assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?: string | readonly string[]): void;
87
- /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
88
- run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: string | readonly string[]): Promise<void>;
122
+ assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?: LambderRateLimitOptionValue): void;
123
+ /**
124
+ * Check the API's policies in declared order; the first exceeded one
125
+ * refuses with a 429 envelope and a Retry-After header. Attempts count,
126
+ * not successes: every counter checked before the refusing one (and every
127
+ * counter, when a later guard or validation refuses) keeps its increment,
128
+ * so list first the policy you want charged on refusals.
129
+ */
130
+ run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: LambderRateLimitOptionValue): Promise<void>;
89
131
  private resolveKey;
90
132
  }
133
+ export {};
@@ -1,8 +1,21 @@
1
+ import { RATE_LIMIT_WINDOWS } from "../stores/LambderDdbRateLimiter.js";
1
2
  import { LambderApiError } from "../shared/LambderApiError.js";
2
3
  import { parsePreflightSlice } from "./LambderApiGuards.js";
3
- const RATE_LIMIT_WINDOW_KEYS = ["perMin", "per10Min", "perHour", "perDay", "perWeek", "perMonth"];
4
+ const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
5
+ /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
6
+ const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", content: "Too many requests. Please try again later." };
4
7
  export function lambderRateLimitKey(key) { return key; }
5
- const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
8
+ /** Normalize the three rateLimit-option forms into ordered entries; an explicit `undefined` map value declares nothing. */
9
+ const toRateLimitEntries = (value) => {
10
+ if (value === undefined)
11
+ return [];
12
+ if (typeof value === "string")
13
+ return [{ name: value }];
14
+ if (Array.isArray(value))
15
+ return value.map((name) => ({ name }));
16
+ return Object.entries(value).flatMap(([name, override]) => override === undefined ? [] : override === true ? [{ name }] : [{ name, override }]);
17
+ };
18
+ const hasWindowOverride = (override) => RATE_LIMIT_WINDOW_KEYS.some((key) => override[key] !== undefined);
6
19
  /**
7
20
  * Runtime side of the rate-limit subsystem: holds the limiter and its named
8
21
  * policies, asserts API registrations against them at startup, and checks an
@@ -10,8 +23,12 @@ const toList = (value) => value === undefined ? [] : typeof value === "string" ?
10
23
  * LambderApiPolicyEngine.
11
24
  */
12
25
  export class LambderApiRateLimitsEngine {
26
+ onInvalidInput;
13
27
  limiter = null;
14
28
  policies = {};
29
+ constructor(onInvalidInput) {
30
+ this.onInvalidInput = onInvalidInput;
31
+ }
15
32
  configure(config) {
16
33
  if (this.limiter)
17
34
  throw new Error("Lambder: rateLimits were already configured.");
@@ -23,13 +40,17 @@ export class LambderApiRateLimitsEngine {
23
40
  if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
24
41
  throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
25
42
  }
43
+ const budget = policy.budget;
44
+ if (budget !== "perApi" && budget !== "perPolicy") {
45
+ throw new Error(`Lambder: rate-limit policy "${name}" needs budget: "perApi" (each referencing API counts separately) or "perPolicy" (one counter shared by every referencing API).`);
46
+ }
26
47
  }
27
48
  this.limiter = config.limiter;
28
49
  this.policies = { ...config.policies };
29
50
  }
30
51
  /** Startup validation of one API registration's rateLimit option. */
31
52
  assertRegistration(apiName, mode, rateLimitOption) {
32
- for (const name of toList(rateLimitOption)) {
53
+ for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
33
54
  const policy = this.policies[name];
34
55
  if (!policy) {
35
56
  throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
@@ -37,25 +58,43 @@ export class LambderApiRateLimitsEngine {
37
58
  if (policy.per === "session" && mode !== "session") {
38
59
  throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
39
60
  }
61
+ if (override && policy.budget === "perPolicy" && hasWindowOverride(override)) {
62
+ throw new Error(`Lambder: API "${apiName}" overrides the windows of rate-limit policy "${name}", whose budget is "perPolicy": one counter shared by every referencing API has one set of limits. Declare a separate policy instead.`);
63
+ }
40
64
  }
41
65
  }
42
- /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
66
+ /**
67
+ * Check the API's policies in declared order; the first exceeded one
68
+ * refuses with a 429 envelope and a Retry-After header. Attempts count,
69
+ * not successes: every counter checked before the refusing one (and every
70
+ * counter, when a later guard or validation refuses) keeps its increment,
71
+ * so list first the policy you want charged on refusals.
72
+ */
43
73
  async run(apiName, ctx, resolver, rateLimitOption) {
44
- for (const name of toList(rateLimitOption)) {
74
+ for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
45
75
  const policy = this.policies[name];
46
76
  if (!policy || !this.limiter)
47
77
  throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
48
78
  const key = await this.resolveKey(ctx, resolver, policy.per);
49
- // scope "policy" shares one counter across every API referencing
50
- // the policy; the default gives each API its own budget.
51
- const trackerKey = policy.scope === "policy"
79
+ // "perPolicy" shares one counter across every API referencing the
80
+ // policy; "perApi" keys each API separately, which is also what
81
+ // lets an API override the windows without colliding.
82
+ const trackerKey = policy.budget === "perPolicy"
52
83
  ? `policy|${name}|${key}`
53
84
  : `api|${apiName}|${name}|${key}`;
54
- const limited = await this.limiter.isRateLimited(trackerKey, policy);
55
- if (limited) {
56
- throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
57
- errorMessage: policy.errorMessage ?? "Too many requests. Please try again later.",
85
+ const limits = {};
86
+ for (const windowKey of RATE_LIMIT_WINDOW_KEYS) {
87
+ const limit = override?.[windowKey] ?? policy[windowKey];
88
+ if (limit !== undefined)
89
+ limits[windowKey] = limit;
90
+ }
91
+ const exceeded = await this.limiter.isRateLimited(trackerKey, limits);
92
+ if (exceeded) {
93
+ const retryAfterSeconds = Math.max(1, exceeded.resetAt - Math.floor(Date.now() / 1000));
94
+ throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, {
95
+ errorMessage: override?.errorMessage ?? policy.errorMessage ?? DEFAULT_RATE_LIMIT_REFUSAL,
58
96
  statusCode: 429,
97
+ headers: { "Retry-After": String(retryAfterSeconds) },
59
98
  });
60
99
  }
61
100
  }
@@ -70,7 +109,7 @@ export class LambderApiRateLimitsEngine {
70
109
  return `session:${sessionKey}`;
71
110
  }
72
111
  const payload = per.apiInput
73
- ? parsePreflightSlice(per.apiInput, ctx.post?.payload, resolver)
112
+ ? await parsePreflightSlice(per.apiInput, ctx.post?.payload, ctx, resolver, this.onInvalidInput)
74
113
  : undefined;
75
114
  return `custom:${await per.handler(ctx, payload)}`;
76
115
  }
@@ -18,6 +18,8 @@ export type LambderApiErrorOptions = {
18
18
  * 422 (reserved for input validation).
19
19
  */
20
20
  statusCode?: HttpStatusCode;
21
+ /** Extra response headers on the refusal (e.g. Retry-After on a rate limit). */
22
+ headers?: Record<string, string>;
21
23
  /** Underlying cause, preserved on the standard Error `cause` property. */
22
24
  cause?: unknown;
23
25
  };
@@ -48,6 +50,7 @@ export declare class LambderApiError extends Error {
48
50
  readonly notAuthorized?: boolean;
49
51
  readonly sessionExpired?: boolean;
50
52
  readonly statusCode?: HttpStatusCode;
53
+ readonly headers?: Record<string, string>;
51
54
  constructor(message: string, options?: LambderApiErrorOptions);
52
55
  }
53
56
  /** Brand-based type guard (see LambderApiError.isLambderApiError). */
@@ -73,6 +76,8 @@ export type LambderRefuseOptions = {
73
76
  sessionExpired?: boolean;
74
77
  /** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). */
75
78
  statusCode?: HttpStatusCode;
79
+ /** Extra response headers on the refusal (e.g. Retry-After). */
80
+ headers?: Record<string, string>;
76
81
  /** Underlying cause, preserved on the Error cause property. */
77
82
  cause?: unknown;
78
83
  };
@@ -25,6 +25,7 @@ export class LambderApiError extends Error {
25
25
  notAuthorized;
26
26
  sessionExpired;
27
27
  statusCode;
28
+ headers;
28
29
  constructor(message, options = {}) {
29
30
  super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
30
31
  this.name = "LambderApiError";
@@ -32,6 +33,7 @@ export class LambderApiError extends Error {
32
33
  this.notAuthorized = options.notAuthorized;
33
34
  this.sessionExpired = options.sessionExpired;
34
35
  this.statusCode = options.statusCode;
36
+ this.headers = options.headers;
35
37
  }
36
38
  }
37
39
  /** Brand-based type guard (see LambderApiError.isLambderApiError). */
@@ -58,6 +60,7 @@ export const refuse = (content, options = {}) => {
58
60
  notAuthorized: options.notAuthorized,
59
61
  sessionExpired: options.sessionExpired,
60
62
  statusCode: options.statusCode,
63
+ headers: options.headers,
61
64
  cause: options.cause,
62
65
  });
63
66
  };
@@ -1,15 +1,42 @@
1
1
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
- export interface LambderRateLimitPolicy {
3
- perMin?: number;
4
- per10Min?: number;
5
- perHour?: number;
6
- perDay?: number;
7
- perWeek?: number;
8
- perMonth?: number;
9
- }
10
- export type LambderRateLimitExceededMap = Partial<Record<keyof LambderRateLimitPolicy, number>>;
11
- /** `false` when allowed, otherwise the window(s) whose limit was hit. */
12
- export type LambderRateLimitResult = false | LambderRateLimitExceededMap;
2
+ /**
3
+ * The fixed windows a policy may cap, smallest first (the evaluation order),
4
+ * with their length. The policy type derives from this table, so the two can
5
+ * never drift.
6
+ */
7
+ export declare const RATE_LIMIT_WINDOWS: readonly [{
8
+ readonly key: "perMin";
9
+ readonly seconds: 60;
10
+ }, {
11
+ readonly key: "per10Min";
12
+ readonly seconds: number;
13
+ }, {
14
+ readonly key: "perHour";
15
+ readonly seconds: number;
16
+ }, {
17
+ readonly key: "perDay";
18
+ readonly seconds: number;
19
+ }, {
20
+ readonly key: "perWeek";
21
+ readonly seconds: number;
22
+ }, {
23
+ readonly key: "perMonth";
24
+ readonly seconds: number;
25
+ }];
26
+ export type LambderRateLimitWindow = (typeof RATE_LIMIT_WINDOWS)[number]["key"];
27
+ /** Per-window caps. A window that is absent or 0 is not enforced. */
28
+ export type LambderRateLimitPolicy = Partial<Record<LambderRateLimitWindow, number>>;
29
+ /**
30
+ * The window that refused: which one, its limit, and the epoch second at
31
+ * which that fixed window resets (Retry-After derives from it).
32
+ */
33
+ export type LambderRateLimitExceeded = {
34
+ window: LambderRateLimitWindow;
35
+ limit: number;
36
+ resetAt: number;
37
+ };
38
+ /** `false` when allowed, otherwise the window whose limit was hit. */
39
+ export type LambderRateLimitResult = false | LambderRateLimitExceeded;
13
40
  export interface LambderDdbRateLimiterOptions {
14
41
  tableName: string;
15
42
  region?: string;
@@ -27,8 +54,11 @@ export interface LambderDdbRateLimiterOptions {
27
54
  * Each window is a single item counted with a conditional `ADD`, so the
28
55
  * increment and the limit check happen atomically in one request. Windows are
29
56
  * evaluated from smallest to largest and evaluation stops at the first
30
- * exceeded window, which keeps blocked requests cheap and avoids inflating the
31
- * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
57
+ * exceeded window, which keeps blocked requests cheap and spares the larger
58
+ * counters. Attempts count, not successes: a counter checked before the
59
+ * refusing one keeps its increment (there is no compensating decrement, which
60
+ * would give up the conditional-ADD atomicity). Items carry an `expiresAt`
61
+ * attribute for DynamoDB TTL.
32
62
  *
33
63
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
34
64
  * Items are prefixed `RL#` by default, so the table can be shared with
@@ -44,7 +74,8 @@ export declare class LambderDdbRateLimiter {
44
74
  constructor(options: LambderDdbRateLimiterOptions);
45
75
  /**
46
76
  * Increment every configured window for `trackerKey` (IP, session, user id, ...)
47
- * and report whether any of them is over its limit.
77
+ * and report whether any of them is over its limit, with the window's
78
+ * reset time when so.
48
79
  */
49
80
  isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
50
81
  /** Increments one window counter. Returns true when the limit was already reached. */
@@ -1,5 +1,10 @@
1
1
  import { DynamoDBClient, UpdateItemCommand, } from "@aws-sdk/client-dynamodb";
2
- const WINDOW_CONFIG = [
2
+ /**
3
+ * The fixed windows a policy may cap, smallest first (the evaluation order),
4
+ * with their length. The policy type derives from this table, so the two can
5
+ * never drift.
6
+ */
7
+ export const RATE_LIMIT_WINDOWS = [
3
8
  { key: "perMin", seconds: 60 },
4
9
  { key: "per10Min", seconds: 10 * 60 },
5
10
  { key: "perHour", seconds: 60 * 60 },
@@ -13,8 +18,11 @@ const WINDOW_CONFIG = [
13
18
  * Each window is a single item counted with a conditional `ADD`, so the
14
19
  * increment and the limit check happen atomically in one request. Windows are
15
20
  * evaluated from smallest to largest and evaluation stops at the first
16
- * exceeded window, which keeps blocked requests cheap and avoids inflating the
17
- * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
21
+ * exceeded window, which keeps blocked requests cheap and spares the larger
22
+ * counters. Attempts count, not successes: a counter checked before the
23
+ * refusing one keeps its increment (there is no compensating decrement, which
24
+ * would give up the conditional-ADD atomicity). Items carry an `expiresAt`
25
+ * attribute for DynamoDB TTL.
18
26
  *
19
27
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
20
28
  * Items are prefixed `RL#` by default, so the table can be shared with
@@ -41,23 +49,24 @@ export class LambderDdbRateLimiter {
41
49
  }
42
50
  /**
43
51
  * Increment every configured window for `trackerKey` (IP, session, user id, ...)
44
- * and report whether any of them is over its limit.
52
+ * and report whether any of them is over its limit, with the window's
53
+ * reset time when so.
45
54
  */
46
55
  async isRateLimited(trackerKey, policy) {
47
- for (const { key, seconds } of WINDOW_CONFIG) {
56
+ const nowSeconds = Math.floor(Date.now() / 1000);
57
+ for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
48
58
  const limit = policy[key];
49
59
  if (!limit)
50
60
  continue;
51
- const exceeded = await this.incrementWindow(trackerKey, key, seconds, limit);
61
+ const windowStart = Math.floor(nowSeconds / seconds) * seconds;
62
+ const exceeded = await this.incrementWindow(trackerKey, key, windowStart, seconds, limit, nowSeconds);
52
63
  if (exceeded)
53
- return { [key]: limit };
64
+ return { window: key, limit, resetAt: windowStart + seconds };
54
65
  }
55
66
  return false;
56
67
  }
57
68
  /** Increments one window counter. Returns true when the limit was already reached. */
58
- async incrementWindow(trackerKey, sortKeyPrefix, windowSeconds, limit) {
59
- const nowSeconds = Math.floor(Date.now() / 1000);
60
- const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
69
+ async incrementWindow(trackerKey, sortKeyPrefix, windowStart, windowSeconds, limit, nowSeconds) {
61
70
  const expiresAt = nowSeconds + Math.ceil(windowSeconds * this.ttlWindowMultiplier);
62
71
  const input = {
63
72
  TableName: this.tableName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.1.1",
3
+ "version": "4.2.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",