lambder 4.1.1 → 4.2.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,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**: a policy's `budget` is `"perApi"` (default: each referencing API gets its own counter, so the numbers are a per-API ceiling and three APIs on a 60/min policy allow one IP 180/min in total) or `"perPolicy"` (one counter shared by every API referencing the policy). 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, with codes**: `LambderRefusalMessage` gained an optional machine-readable `code` (`refuse(content, { code })`), so clients branch and translate on an identifier instead of string-matching prose. Every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` stamped with a `LAMBDER_REFUSAL_CODES` constant under the reserved `lambder/` prefix; a rate-limit policy's own `errorMessage` (typed as a refusal message) inherits `lambder/rate-limited` unless it sets a code.
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.
@@ -471,7 +479,7 @@ Responses are finalized once at the end of the request: automatic gzip (when the
471
479
 
472
480
  ### Typed API Refusals (refuse / LambderApiError)
473
481
 
474
- A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. The one-liner for the common case is `refuse()`: callable from anywhere in an API call's stack, it throws a typed refusal carrying the standard `LambderRefusalMessage` shape (`{ type, title?, content }`) that the pipeline maps onto the envelope's `errorMessage`, so refusals never pollute crash logging and clients get a parseable response:
482
+ A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. The one-liner for the common case is `refuse()`: callable from anywhere in an API call's stack, it throws a typed refusal carrying the standard `LambderRefusalMessage` shape (`{ type, code?, title?, content }`) that the pipeline maps onto the envelope's `errorMessage`, so refusals never pollute crash logging and clients get a parseable response:
475
483
 
476
484
  ```typescript
477
485
  import { refuse } from "lambder";
@@ -479,9 +487,12 @@ import { refuse } from "lambder";
479
487
  if (!row) refuse("Record not found."); // { type: "warning", content }
480
488
  if (!isAdmin) refuse("Admins only.", { notAuthorized: true }); // + envelope flag
481
489
  refuse("Too many attempts.", { type: "error", statusCode: 429 }); // custom rendering intent + status
490
+ if (exists) refuse("Already reported.", { code: "ALREADY_REPORTED" }); // + machine-readable identity
482
491
  // TypeScript applies never-return narrowing: after `if (!row) refuse(...)`, row is defined.
483
492
  ```
484
493
 
494
+ `code` is the refusal's identity for machines: clients branch and translate on it (a translated client never displays `content`, it looks the code up), and `content` stays the human-readable fallback for codes a client does not know yet. Keep your app's codes as one typed vocabulary in shared code. The framework stamps the refusals it authors itself with `LAMBDER_REFUSAL_CODES` (exported from `lambder` and `lambder/client`) under the reserved `lambder/` prefix, so app codes never collide: `rateLimited`, `duplicateInFlight`, `invalidIdempotencyKey`, `apiNotFound`. A rate-limit policy's own `errorMessage` inherits `lambder/rate-limited` unless it sets a code, so an `errorMessageHandler` can treat every rate limit alike and still special-case the ones you name.
495
+
485
496
  For full control of the errorMessage payload (apps with their own message vocabulary), throw `LambderApiError` directly; `refuse()` is sugar over it:
486
497
 
487
498
  ```typescript
@@ -512,7 +523,12 @@ import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard
512
523
  const lambder = initLambder<SessionData>().create({
513
524
  apiPath: "/api",
514
525
  // 1. Rate limiting: your limiter instance + named policies. Each policy
515
- // declares its windows AND what one counter tracks ("per").
526
+ // declares its windows, what one counter tracks ("per"), and what one
527
+ // budget spans ("budget"): "perApi" (default) gives every referencing
528
+ // API its own counter, so three APIs on a 60/min policy allow one IP
529
+ // 180/min in total; "perPolicy" makes every referencing API share ONE
530
+ // counter. The policy IS the group: separate shared budgets for, say,
531
+ // user APIs and report APIs are two policies.
516
532
  rateLimits: {
517
533
  limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
518
534
  policies: {
@@ -520,10 +536,9 @@ const lambder = initLambder<SessionData>().create({
520
536
  writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
521
537
  codePerEmail: {
522
538
  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",
539
+ // ONE combined budget across every API that references this
540
+ // policy: send + register + reset share the 3/min.
541
+ budget: "perPolicy",
527
542
  // apiInput key: derives from the API's OWN payload. Validated
528
543
  // before it runs, typed in the handler, and the policy is only
529
544
  // referable from APIs whose input schema carries `email`.
@@ -580,14 +595,18 @@ lambder.addApi("public.resetPassword", {
580
595
  // in apiInput mode against the API's own payload.
581
596
  input: z.object({ email: z.string().email() }),
582
597
  output: z.object({ ok: z.boolean() }),
583
- rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
598
+ rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
584
599
  guards: "captcha", // one name, a list of names, or a { name: param } map
585
600
  }, handler);
586
601
 
587
602
  lambder.addSessionApi("secure.order.create", {
588
603
  input: OrderSchema,
589
604
  output: OrderResultSchema,
590
- rateLimit: "writePerUser",
605
+ // Map form: tune a perApi policy for this API. Overrides merge over the
606
+ // policy's windows (perMin here, the policy's other windows still apply)
607
+ // and errorMessage is overridable too. Window overrides on a perPolicy
608
+ // policy are a startup error: one shared counter has one set of limits.
609
+ rateLimit: { writePerUser: { perMin: 10 } },
591
610
  guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
592
611
  idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
593
612
  }, async (ctx, res) => {
@@ -619,7 +638,11 @@ const lambder = lambderApp.addHook(...).use(orderApi)...;
619
638
  export const handler = lambder.getHandler();
620
639
  ```
621
640
 
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.
641
+ 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).
642
+
643
+ **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.
644
+
645
+ 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
646
 
624
647
  **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
648
 
@@ -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
  }
package/dist/client.d.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  */
9
9
  export { default as LambderCaller } from "./client/LambderCaller.js";
10
10
  export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
11
- export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
12
- export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
11
+ export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
12
+ export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
13
  export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
14
14
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
15
15
  export { createLambderI18n } from "./shared/LambderI18n.js";
package/dist/client.js CHANGED
@@ -10,7 +10,7 @@
10
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
- export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
13
+ export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
14
14
  // Type-safe templating (tagged templates with auto-escaping)
15
15
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
16
16
  // Typed translations (standalone, isomorphic)
@@ -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;
@@ -6,7 +6,7 @@ import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import LambderSessionController from "../session/LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
- import { isLambderApiError } from "../shared/LambderApiError.js";
9
+ import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
10
10
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
12
12
  /**
@@ -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", code: LAMBDER_REFUSAL_CODES.apiNotFound, 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
@@ -3,8 +3,8 @@ export default Lambder;
3
3
  export { initLambder } from './core/Lambder.js';
4
4
  export { default as LambderCaller } from "./client/LambderCaller.js";
5
5
  export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
6
- export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
7
- export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
6
+ export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
7
+ export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
8
8
  export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
9
9
  export { default as LambderResolver } from "./core/LambderResolver.js";
10
10
  export { default as LambderSessionManager } from "./session/LambderSessionManager.js";
@@ -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";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export default Lambder;
3
3
  export { initLambder } from './core/Lambder.js';
4
4
  export { default as LambderCaller } from "./client/LambderCaller.js";
5
5
  // Typed API refusals (isomorphic: shared code may throw them from anywhere)
6
- export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
6
+ export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
7
7
  export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
8
8
  export { default as LambderResolver } from "./core/LambderResolver.js";
9
9
  export { default as LambderSessionManager } from "./session/LambderSessionManager.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.
@@ -1,4 +1,4 @@
1
- import { LambderApiError } from "../shared/LambderApiError.js";
1
+ import { LambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
2
2
  import { LambderResponse, normalizeHeaders } from "../core/LambderResponse.js";
3
3
  /** A crashed original must not block retries forever: pending claims expire on their own. */
4
4
  const IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
@@ -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", code: LAMBDER_REFUSAL_CODES.invalidIdempotencyKey, 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", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, 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,26 @@ 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:
46
+ *
47
+ * - "perApi" (default): every API referencing the policy gets its own
48
+ * counter, so the windows are a per-API ceiling (three APIs referencing a
49
+ * 60/min policy allow one subject 180/min in total). An API may tune the
50
+ * windows in its declaration: `rateLimit: { name: { perMin: 20 } }`.
51
+ * - "perPolicy": every API referencing the policy shares ONE counter, so the
52
+ * windows are one combined budget (e.g. one per-email allowance across
53
+ * send, register, and reset). The policy IS the group: to give user APIs
54
+ * and report APIs separate shared budgets, declare two policies.
55
+ */
56
+ export type LambderRateLimitBudget = "perApi" | "perPolicy";
57
+ /** A named rate-limit policy: fixed windows, the key one counter tracks, and what one budget spans. */
42
58
  export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
43
59
  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;
60
+ /** Whether the windows are a per-API ceiling (default) or one budget shared by every referencing API. See LambderRateLimitBudget. */
61
+ budget?: LambderRateLimitBudget;
62
+ /** Envelope errorMessage for refused requests; inherits code "lambder/rate-limited" unless it sets its own. Default: a warning saying too many requests. */
63
+ errorMessage?: LambderRefusalMessage;
54
64
  };
55
65
  export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
56
66
  /** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
@@ -72,6 +82,29 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
72
82
  };
73
83
  } ? (TPayload extends z.output<S> ? K : never) : K;
74
84
  }[keyof TPolicies] & string;
85
+ /**
86
+ * What an API may override on a policy it references, in the map form of the
87
+ * rateLimit option. Windows merge over the policy's own (a tighter burst keeps
88
+ * the policy's daily cap) and are only overridable on "perApi" budgets: a
89
+ * shared counter has one set of numbers. errorMessage is per-API text, so it
90
+ * is overridable on either budget.
91
+ */
92
+ export type LambderRateLimitOverride = LambderRateLimitPolicy & {
93
+ errorMessage?: LambderRefusalMessage;
94
+ };
95
+ type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
96
+ budget: "perPolicy";
97
+ } ? Pick<LambderRateLimitOverride, "errorMessage"> : LambderRateLimitOverride;
98
+ /**
99
+ * The per-API `rateLimit` option: one policy name, an ordered list of names,
100
+ * or an object map that can carry each policy's override (`true` applies the
101
+ * policy as declared). Map entries are checked in insertion order.
102
+ */
103
+ export type LambderRateLimitOption<TPolicies, TPayload, TIncludeSession extends boolean> = LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> | readonly LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession>[] | {
104
+ readonly [K in LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
105
+ };
106
+ /** The rateLimit option's runtime shape: a name, ordered names, or a name-to-override map (LambderRateLimitOption narrows the names and overrides per policy). */
107
+ export type LambderRateLimitOptionValue = string | readonly string[] | Readonly<Record<string, true | LambderRateLimitOverride | undefined>>;
75
108
  /**
76
109
  * Runtime side of the rate-limit subsystem: holds the limiter and its named
77
110
  * policies, asserts API registrations against them at startup, and checks an
@@ -79,12 +112,21 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
79
112
  * LambderApiPolicyEngine.
80
113
  */
81
114
  export declare class LambderApiRateLimitsEngine {
115
+ private readonly onInvalidInput;
82
116
  private limiter;
83
117
  private policies;
118
+ constructor(onInvalidInput: LambderInputValidationRefusal);
84
119
  configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
85
120
  /** 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>;
121
+ assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?: LambderRateLimitOptionValue): void;
122
+ /**
123
+ * Check the API's policies in declared order; the first exceeded one
124
+ * refuses with a 429 envelope and a Retry-After header. Attempts count,
125
+ * not successes: every counter checked before the refusing one (and every
126
+ * counter, when a later guard or validation refuses) keeps its increment,
127
+ * so list first the policy you want charged on refusals.
128
+ */
129
+ run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: LambderRateLimitOptionValue): Promise<void>;
89
130
  private resolveKey;
90
131
  }
132
+ export {};
@@ -1,8 +1,21 @@
1
- import { LambderApiError } from "../shared/LambderApiError.js";
1
+ import { RATE_LIMIT_WINDOWS } from "../stores/LambderDdbRateLimiter.js";
2
+ import { LambderApiError, LAMBDER_REFUSAL_CODES } 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", code: LAMBDER_REFUSAL_CODES.rateLimited, 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 !== undefined && budget !== "perApi" && budget !== "perPolicy") {
45
+ throw new Error(`Lambder: rate-limit policy "${name}" has budget "${String(budget)}"; use "perApi" (default: 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,46 @@ 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
+ // A policy's (or override's) own message inherits the framework
95
+ // code unless it sets a more specific one of its own.
96
+ const message = override?.errorMessage ?? policy.errorMessage;
97
+ throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, {
98
+ errorMessage: message ? { code: LAMBDER_REFUSAL_CODES.rateLimited, ...message } : DEFAULT_RATE_LIMIT_REFUSAL,
58
99
  statusCode: 429,
100
+ headers: { "Retry-After": String(retryAfterSeconds) },
59
101
  });
60
102
  }
61
103
  }
@@ -70,7 +112,7 @@ export class LambderApiRateLimitsEngine {
70
112
  return `session:${sessionKey}`;
71
113
  }
72
114
  const payload = per.apiInput
73
- ? parsePreflightSlice(per.apiInput, ctx.post?.payload, resolver)
115
+ ? await parsePreflightSlice(per.apiInput, ctx.post?.payload, ctx, resolver, this.onInvalidInput)
74
116
  : undefined;
75
117
  return `custom:${await per.handler(ctx, payload)}`;
76
118
  }
@@ -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,23 +50,50 @@ 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). */
54
57
  export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
55
58
  /**
56
59
  * The standard shape refusals carry on the envelope's errorMessage field.
57
- * The caller's errorMessageHandler receives it as-is; apps with their own
58
- * errorMessage vocabulary can keep using LambderApiError directly instead.
60
+ * `code` is the refusal's machine-readable identity: clients branch and
61
+ * translate on it and never string-match `content`, which stays the
62
+ * human-readable fallback for codes a client does not know yet. Apps keep
63
+ * their own typed code vocabulary; the framework's own refusals carry a
64
+ * LambderRefusalCode. The caller's errorMessageHandler receives the object
65
+ * as-is; apps with their own errorMessage vocabulary can keep using
66
+ * LambderApiError directly instead.
59
67
  */
60
68
  export type LambderRefusalMessage = {
61
69
  type: "warning" | "error" | "info";
70
+ /** Machine-readable identity of the refusal (the app's own vocabulary, or a LambderRefusalCode). */
71
+ code?: string;
62
72
  title?: string;
63
73
  content: string;
64
74
  };
75
+ /**
76
+ * Codes the framework stamps on the refusals it authors itself, under the
77
+ * reserved `lambder/` prefix so app codes never collide. Compare against
78
+ * these constants on the client (exported from `lambder/client` too) rather
79
+ * than retyping the strings.
80
+ */
81
+ export declare const LAMBDER_REFUSAL_CODES: {
82
+ /** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
83
+ readonly rateLimited: "lambder/rate-limited";
84
+ /** The original of an idempotent request is still processing (409). */
85
+ readonly duplicateInFlight: "lambder/duplicate-in-flight";
86
+ /** The idempotencyKey is malformed (400). */
87
+ readonly invalidIdempotencyKey: "lambder/invalid-idempotency-key";
88
+ /** No API is registered under the requested name. */
89
+ readonly apiNotFound: "lambder/api-not-found";
90
+ };
91
+ export type LambderRefusalCode = (typeof LAMBDER_REFUSAL_CODES)[keyof typeof LAMBDER_REFUSAL_CODES];
65
92
  export type LambderRefuseOptions = {
66
93
  /** Rendering intent for the client's errorMessageHandler. Default: "warning". */
67
94
  type?: LambderRefusalMessage["type"];
95
+ /** Machine-readable identity of the refusal, for clients to branch and translate on. */
96
+ code?: string;
68
97
  /** Optional heading shown above the content. */
69
98
  title?: string;
70
99
  /** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). */
@@ -73,6 +102,8 @@ export type LambderRefuseOptions = {
73
102
  sessionExpired?: boolean;
74
103
  /** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). */
75
104
  statusCode?: HttpStatusCode;
105
+ /** Extra response headers on the refusal (e.g. Retry-After). */
106
+ headers?: Record<string, string>;
76
107
  /** Underlying cause, preserved on the Error cause property. */
77
108
  cause?: unknown;
78
109
  };
@@ -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,10 +33,27 @@ 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). */
38
40
  export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;
41
+ /**
42
+ * Codes the framework stamps on the refusals it authors itself, under the
43
+ * reserved `lambder/` prefix so app codes never collide. Compare against
44
+ * these constants on the client (exported from `lambder/client` too) rather
45
+ * than retyping the strings.
46
+ */
47
+ export const LAMBDER_REFUSAL_CODES = {
48
+ /** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
49
+ rateLimited: "lambder/rate-limited",
50
+ /** The original of an idempotent request is still processing (409). */
51
+ duplicateInFlight: "lambder/duplicate-in-flight",
52
+ /** The idempotencyKey is malformed (400). */
53
+ invalidIdempotencyKey: "lambder/invalid-idempotency-key",
54
+ /** No API is registered under the requested name. */
55
+ apiNotFound: "lambder/api-not-found",
56
+ };
39
57
  /**
40
58
  * Refuse the current API call: a routine business "no" (not found, invalid
41
59
  * input, not allowed) with a user-facing message. Throws a LambderApiError
@@ -52,12 +70,14 @@ export const refuse = (content, options = {}) => {
52
70
  throw new LambderApiError(content, {
53
71
  errorMessage: {
54
72
  type: options.type ?? "warning",
73
+ ...(options.code !== undefined ? { code: options.code } : {}),
55
74
  ...(options.title !== undefined ? { title: options.title } : {}),
56
75
  content,
57
76
  },
58
77
  notAuthorized: options.notAuthorized,
59
78
  sessionExpired: options.sessionExpired,
60
79
  statusCode: options.statusCode,
80
+ headers: options.headers,
61
81
  cause: options.cause,
62
82
  });
63
83
  };
@@ -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.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",