lambder 3.4.2 → 3.5.2

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,7 +2,7 @@
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 v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1) and typed translations with `createLambderI18n` (3.2).
5
+ **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1), typed translations with `createLambderI18n` (3.2), and in 3.5: typed API refusals with `LambderApiError`, caller outcomes/timeouts with `apiOutcome()`, plus declarative per-API rate limits, guards, and idempotency.
6
6
 
7
7
  ## Features
8
8
 
@@ -428,9 +428,88 @@ Responses are finalized once at the end of the request: automatic gzip (when the
428
428
 
429
429
  **Die Methods**: `res.die.*` - Builds the response and throws it, immediately halting the request at any call depth (handlers, hooks, nested helper functions). Plain `throw res.html(...)` works the same way.
430
430
 
431
+ ### Typed API Refusals (LambderApiError)
432
+
433
+ 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. Throw `LambderApiError` from anywhere in an API call's stack and the pipeline maps it onto the structured envelope instead of the global error handler, so refusals never pollute crash logging and clients get a parseable response:
434
+
435
+ ```typescript
436
+ import { LambderApiError } from "lambder";
437
+
438
+ // In any helper, no resolver needed:
439
+ export const requirePermission = (granted: boolean) => {
440
+ if (!granted) throw new LambderApiError("Permission denied.", {
441
+ notAuthorized: true, // envelope flag -> caller's notAuthorizedHandler
442
+ errorMessage: { type: "warning", content: "Not allowed." }, // any shape your errorMessageHandler expects
443
+ // sessionExpired: true, // optional envelope flag
444
+ // statusCode: 403, // optional; default 200 (avoid 5xx and 422)
445
+ });
446
+ };
447
+ ```
448
+
449
+ `errorMessage` defaults to the error's message string, so `throw new LambderApiError("Nope.")` alone is already visible to the client. Thrown outside an API call (e.g. in a route handler) it behaves like a normal error. The class is isomorphic and dependency-free, so shared server/browser packages can import it safely. Detection is brand-based (`isLambderApiError`), so it works even when two copies of lambder end up in one bundle.
450
+
451
+ Related: when an API call crashes with no `setGlobalErrorHandler` (or the handler itself fails), the last-resort 500 is now a JSON envelope (`{ payload: null, errorMessage: "Internal server error." }`) instead of a plain-text page; routes keep the plain-text 500.
452
+
453
+ ### Declarative API Policies (rate limits, guards, idempotency)
454
+
455
+ Declare named building blocks once; reference them from API definitions with full type inference (unknown names are compile errors, and everything is re-asserted at registration time for plain-JS safety). Each piece is independent and optional.
456
+
457
+ ```typescript
458
+ import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, LambderApiError } from "lambder";
459
+
460
+ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
461
+ // 1. Rate limiting: your limiter instance + named policies. Each policy
462
+ // declares its windows AND what one counter tracks ("per").
463
+ .enableApiRateLimits({
464
+ limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
465
+ policies: {
466
+ authPerIp: { perMin: 5, perHour: 30, per: "ip" },
467
+ writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
468
+ codePerEmail: { perMin: 3, per: (ctx) => String(ctx.post?.payload?.email ?? "").toLowerCase(),
469
+ errorMessage: { type: "warning", content: "Too many attempts for this address." } },
470
+ },
471
+ })
472
+ // 2. Idempotency: a store instance + replay defaults. May share the rate
473
+ // limiter's table (records use an IDEM# key prefix).
474
+ .enableApiIdempotency({
475
+ store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
476
+ defaultTtlSeconds: 24 * 3600,
477
+ failOpen: true, // DynamoDB down => execute without dedupe instead of failing
478
+ })
479
+ // 3. Named guards: run before input validation, refuse by throwing.
480
+ // Callable multiple times; domain modules can contribute their own.
481
+ .defineApiGuards({
482
+ captcha: async (ctx) => {
483
+ if (!await verifyCaptcha(ctx.post?.payload?.captchaToken, ctx.ip)) {
484
+ throw new LambderApiError("Captcha failed", { errorMessage: "Verification failed, please retry." });
485
+ }
486
+ },
487
+ });
488
+
489
+ lambder.addApi("public.resetPassword", {
490
+ input: z.object({ email: z.string().email(), captchaToken: z.string() }),
491
+ output: z.object({ ok: z.boolean() }),
492
+ rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
493
+ guards: "captcha",
494
+ }, handler);
495
+
496
+ lambder.addSessionApi("secure.order.create", {
497
+ input: OrderSchema,
498
+ output: OrderResultSchema,
499
+ rateLimit: "writePerUser",
500
+ idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
501
+ }, handler);
502
+ ```
503
+
504
+ Request flow per API: session (session APIs) → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
505
+
506
+ **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation and reuse it on retries. The scope is identity (session key, or IP for public APIs) + API name + key: concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL, and a crashed original releases its claim so a retry actually retries. A response delivered by throwing (`res.die.*`, `throw res.api(...)`) counts as a completion and is stored like a returned one; thrown `LambderApiError` refusals release the claim instead. Responses with status ≥ 500 are never stored. 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.
507
+
508
+ Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
509
+
431
510
  ### DynamoDB Cache (LambderDdbCache)
432
511
 
433
- Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
512
+ Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
434
513
 
435
514
  ```typescript
436
515
  import { LambderDdbCache } from "lambder";
@@ -505,6 +584,33 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
505
584
  // - Expected output type
506
585
  ```
507
586
 
587
+ ### Failure Semantics (apiOutcome, timeouts, per-call overrides)
588
+
589
+ `api()` collapses every failure to `null`, which is indistinguishable from a legitimately-null payload. When the call site needs to know why, use `apiOutcome()`; it never throws and resolves to a discriminated union:
590
+
591
+ ```typescript
592
+ const outcome = await lambderCaller.apiOutcome("getCompanyPage", { companyName: "Acme" });
593
+ if (outcome.ok) {
594
+ render(outcome.payload);
595
+ } else if (outcome.reason === "network" || outcome.reason === "timeout") {
596
+ showOfflineScreen();
597
+ } else if (outcome.reason === "sessionExpired") {
598
+ redirectToLogin();
599
+ } else {
600
+ // 'server' (5xx / non-envelope body), 'validation' (422), 'versionExpired',
601
+ // 'notAuthorized', 'errorMessage' (structured refusal), 'unknown'
602
+ showError(outcome.errorMessage);
603
+ }
604
+ ```
605
+
606
+ Every configured handler still fires on the matching failure, so global UX (toasts, re-login prompts) lives in the constructor while individual call sites branch on the outcome.
607
+
608
+ Also available:
609
+
610
+ - **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
611
+ - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
612
+ - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success.
613
+
508
614
  ### Benefits
509
615
 
510
616
  ✅ **No Manual Type Definitions** - Types are inferred from your Zod schemas
package/dist/Lambder.d.ts CHANGED
@@ -8,6 +8,7 @@ import { type LambderCorsConfig } from "./LambderCors.js";
8
8
  import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
9
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
10
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
+ import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuardFunction, type LambderApiRegistrationOptions, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
11
12
  import type { MergeContract } from "./LambderApiContract.js";
12
13
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
13
14
  export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
@@ -16,7 +17,7 @@ type MaybePromise<T> = T | Promise<T>;
16
17
  type Path = `/${string}`;
17
18
  type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
18
19
  type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
19
- type HookCreatedFunction = (lambderInstance: Lambder<any, any>) => void | Promise<void>;
20
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
20
21
  /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
21
22
  type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
22
23
  type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
@@ -79,6 +80,9 @@ export type LambderConstructorOptions = {
79
80
  *
80
81
  * @typeParam TSessionData - Type of session data stored in DynamoDB
81
82
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
83
+ * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
84
+ * @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
85
+ * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
82
86
  *
83
87
  * @example
84
88
  * ```typescript
@@ -89,7 +93,7 @@ export type LambderConstructorOptions = {
89
93
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
90
94
  * ```
91
95
  */
92
- export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}> {
96
+ export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuardName extends string = never, _TIdempotencyEnabled extends boolean = false> {
93
97
  apiPath: string;
94
98
  apiVersion: null | string;
95
99
  publicPath: string;
@@ -105,6 +109,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
105
109
  */
106
110
  readonly ApiContract: _TContract;
107
111
  private actionList;
112
+ private apiPolicyEngine;
113
+ private registeredApiNames;
108
114
  private hookList;
109
115
  private createdHooks;
110
116
  private initPromise;
@@ -173,19 +179,50 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
173
179
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
174
180
  /** Apply the serveIndexHtml gates; null means fall through. */
175
181
  private tryServeIndexHtml;
182
+ /**
183
+ * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
184
+ * instance plus named policies, each declaring its windows and what one
185
+ * counter tracks (`per`: "ip", "session", or a custom key function).
186
+ * APIs then reference policies by name via the `rateLimit` option; the
187
+ * returned type narrows so only declared names are accepted, and
188
+ * policies keyed per "session" are only referable from addSessionApi.
189
+ * Callable once; call it before the API registrations that use it.
190
+ */
191
+ enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuardName, _TIdempotencyEnabled>;
192
+ /**
193
+ * Wire declarative idempotency: your LambderDdbIdempotency instance plus
194
+ * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
195
+ * the option is a type error until this is called. Requests carrying a
196
+ * client `idempotencyKey` (sent by LambderCaller) claim an
197
+ * identity+api+key scope atomically: concurrent duplicates refuse with
198
+ * 409, replays of a completed request return the stored response, and a
199
+ * crashed original releases its claim. Callable once.
200
+ */
201
+ enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName, true>;
202
+ /**
203
+ * Define named guards that APIs reference (typed) via the `guards`
204
+ * option. Guards run before input validation, in the order the API
205
+ * declares them; a guard refuses by throwing (typically LambderApiError).
206
+ * Callable multiple times so domain modules can contribute their own;
207
+ * names must not collide.
208
+ */
209
+ defineApiGuards<TGuards extends Record<string, LambderApiGuardFunction>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName | Extract<keyof TGuards, string>, _TIdempotencyEnabled>;
210
+ private getOrCreatePolicyEngine;
211
+ /** Registration-time checks shared by addApi/addSessionApi. */
212
+ private assertApiRegistration;
176
213
  addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
177
214
  addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
178
215
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
179
216
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
180
- use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract>) => Lambder<TSessionData, _TNewContract>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract)>;
217
+ 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, _TGuardName, _TIdempotencyEnabled>;
181
218
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
182
219
  input: TInput;
183
220
  output: TOutput;
184
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
221
+ } & LambderApiRegistrationOptions<LambderPublicRateLimitNames<_TRateLimitPolicies>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
185
222
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
186
223
  input: TInput;
187
224
  output: TOutput;
188
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
225
+ } & LambderApiRegistrationOptions<Extract<keyof _TRateLimitPolicies, string>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
189
226
  /**
190
227
  * Fetch the session or short-circuit the request: API calls get the
191
228
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -199,6 +236,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
199
236
  getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
200
237
  getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
201
238
  private getResolver;
239
+ /** Map a thrown LambderApiError onto the structured API envelope. */
240
+ private apiErrorResponse;
202
241
  getHandler(): LambderHandler;
203
242
  /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
204
243
  static isHttpEvent(event: unknown): event is LambderHttpEvent;
package/dist/Lambder.js CHANGED
@@ -6,12 +6,17 @@ import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "./LambderSessionManager.js";
7
7
  import LambderSessionController from "./LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { isLambderApiError } from "./LambderApiError.js";
10
+ import { LambderApiPolicyEngine, } from "./LambderApiPolicies.js";
9
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
10
12
  /**
11
13
  * Main Lambder class for building type-safe serverless APIs
12
14
  *
13
15
  * @typeParam TSessionData - Type of session data stored in DynamoDB
14
16
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
17
+ * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
18
+ * @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
19
+ * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
15
20
  *
16
21
  * @example
17
22
  * ```typescript
@@ -38,6 +43,8 @@ export default class Lambder {
38
43
  */
39
44
  ApiContract;
40
45
  actionList = [];
46
+ apiPolicyEngine = null;
47
+ registeredApiNames = new Set();
41
48
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
42
49
  createdHooks = [];
43
50
  initPromise = null;
@@ -155,6 +162,65 @@ export default class Lambder {
155
162
  }
156
163
  return response;
157
164
  }
165
+ // ---------------------------------------------------------------------
166
+ // Declarative API policies (rate limits, guards, idempotency)
167
+ // ---------------------------------------------------------------------
168
+ /**
169
+ * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
170
+ * instance plus named policies, each declaring its windows and what one
171
+ * counter tracks (`per`: "ip", "session", or a custom key function).
172
+ * APIs then reference policies by name via the `rateLimit` option; the
173
+ * returned type narrows so only declared names are accepted, and
174
+ * policies keyed per "session" are only referable from addSessionApi.
175
+ * Callable once; call it before the API registrations that use it.
176
+ */
177
+ enableApiRateLimits(config) {
178
+ this.getOrCreatePolicyEngine().setRateLimits(config);
179
+ return this;
180
+ }
181
+ /**
182
+ * Wire declarative idempotency: your LambderDdbIdempotency instance plus
183
+ * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
184
+ * the option is a type error until this is called. Requests carrying a
185
+ * client `idempotencyKey` (sent by LambderCaller) claim an
186
+ * identity+api+key scope atomically: concurrent duplicates refuse with
187
+ * 409, replays of a completed request return the stored response, and a
188
+ * crashed original releases its claim. Callable once.
189
+ */
190
+ enableApiIdempotency(config) {
191
+ this.getOrCreatePolicyEngine().setIdempotency(config);
192
+ return this;
193
+ }
194
+ /**
195
+ * Define named guards that APIs reference (typed) via the `guards`
196
+ * option. Guards run before input validation, in the order the API
197
+ * declares them; a guard refuses by throwing (typically LambderApiError).
198
+ * Callable multiple times so domain modules can contribute their own;
199
+ * names must not collide.
200
+ */
201
+ defineApiGuards(guards) {
202
+ this.getOrCreatePolicyEngine().addGuards(guards);
203
+ return this;
204
+ }
205
+ getOrCreatePolicyEngine() {
206
+ if (!this.apiPolicyEngine)
207
+ this.apiPolicyEngine = new LambderApiPolicyEngine();
208
+ return this.apiPolicyEngine;
209
+ }
210
+ /** Registration-time checks shared by addApi/addSessionApi. */
211
+ assertApiRegistration(name, mode, options) {
212
+ if (this.registeredApiNames.has(name)) {
213
+ throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
214
+ }
215
+ this.registeredApiNames.add(name);
216
+ const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
217
+ if (!usesPolicies)
218
+ return;
219
+ if (!this.apiPolicyEngine) {
220
+ throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of enableApiRateLimits()/defineApiGuards()/enableApiIdempotency() was called first.`);
221
+ }
222
+ this.apiPolicyEngine.assertRegistration(name, mode, options);
223
+ }
158
224
  addRoute(condition, actionFn) {
159
225
  this.actionList.push({
160
226
  match: compileRouteMatcher(condition),
@@ -173,14 +239,21 @@ export default class Lambder {
173
239
  return this;
174
240
  }
175
241
  // Plugin system
242
+ // The policy generics are `any` in the plugin signature on purpose: a
243
+ // module may annotate its parameter as the bare Lambder<SessionData> or
244
+ // as the app's narrowed alias, and both must chain. Registration-time
245
+ // assertions still verify every referenced policy/guard name at runtime.
176
246
  use(plugin) {
177
247
  return plugin(this);
178
248
  }
179
249
  // Typed API with Zod
180
250
  addApi(name, schema, handler) {
251
+ this.assertApiRegistration(name, "public", schema);
181
252
  this.actionList.push({
182
253
  match: (ctx) => ctx.apiName === name ? {} : false,
183
254
  actionFn: async (ctx, resolver) => {
255
+ if (this.apiPolicyEngine)
256
+ await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
184
257
  const inputResult = schema.input.safeParse(ctx.apiPayload);
185
258
  if (!inputResult.success) {
186
259
  if (this.apiInputValidationErrorHandler) {
@@ -189,17 +262,23 @@ export default class Lambder {
189
262
  return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
190
263
  }
191
264
  ctx.apiPayload = inputResult.data;
192
- return await handler(ctx, resolver);
265
+ const run = async () => await handler(ctx, resolver);
266
+ if (this.apiPolicyEngine && schema.idempotency)
267
+ return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
268
+ return await run();
193
269
  },
194
270
  });
195
271
  return this;
196
272
  }
197
273
  // Typed Session API with Zod
198
274
  addSessionApi(name, schema, handler) {
275
+ this.assertApiRegistration(name, "session", schema);
199
276
  this.actionList.push({
200
277
  match: (ctx) => ctx.apiName === name ? {} : false,
201
278
  actionFn: async (ctx, resolver) => {
202
279
  await this.requireSession(ctx, resolver);
280
+ if (this.apiPolicyEngine)
281
+ await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
203
282
  const inputResult = schema.input.safeParse(ctx.apiPayload);
204
283
  if (!inputResult.success) {
205
284
  if (this.apiInputValidationErrorHandler) {
@@ -208,7 +287,10 @@ export default class Lambder {
208
287
  return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
209
288
  }
210
289
  ctx.apiPayload = inputResult.data;
211
- return await handler(ctx, resolver);
290
+ const run = async () => await handler(ctx, resolver);
291
+ if (this.apiPolicyEngine && schema.idempotency)
292
+ return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
293
+ return await run();
212
294
  }
213
295
  });
214
296
  return this;
@@ -268,6 +350,14 @@ export default class Lambder {
268
350
  });
269
351
  }
270
352
  ;
353
+ /** Map a thrown LambderApiError onto the structured API envelope. */
354
+ apiErrorResponse(err, resolver) {
355
+ return resolver.api(null, {
356
+ ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
357
+ ...(err.notAuthorized ? { notAuthorized: true } : {}),
358
+ ...(err.sessionExpired ? { sessionExpired: true } : {}),
359
+ }, err.statusCode !== undefined ? { statusCode: err.statusCode } : undefined);
360
+ }
271
361
  getHandler() {
272
362
  return ((event, context) => Lambder.isHttpEvent(event)
273
363
  ? this.render(event, context)
@@ -401,6 +491,11 @@ export default class Lambder {
401
491
  if (err instanceof LambderResponse) {
402
492
  response = err;
403
493
  }
494
+ // A thrown LambderApiError on an API call IS a structured refusal
495
+ // (brand-checked, not instanceof, to survive duplicate installs).
496
+ else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
497
+ response = this.apiErrorResponse(err, resolver);
498
+ }
404
499
  else {
405
500
  throw err;
406
501
  }
@@ -417,6 +512,9 @@ export default class Lambder {
417
512
  if (err instanceof LambderResponse) {
418
513
  response = err;
419
514
  }
515
+ else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
516
+ response = this.apiErrorResponse(err, resolver);
517
+ }
420
518
  else {
421
519
  throw err;
422
520
  }
@@ -450,6 +548,14 @@ export default class Lambder {
450
548
  catch { /* fall through */ }
451
549
  }
452
550
  }
551
+ // Last-resort 500. API calls get the JSON envelope so clients can
552
+ // parse a structured failure; everything else keeps plain text.
553
+ if (ctx?._otherInternal.isApiCall) {
554
+ const apiBody = JSON.stringify({ apiVersion: this.apiVersion, payload: null, errorMessage: "Internal server error." });
555
+ return eventFormat === "v2"
556
+ ? { statusCode: 500, headers: { "Content-Type": "application/json; charset=utf-8" }, body: apiBody, isBase64Encoded: false }
557
+ : { statusCode: 500, multiValueHeaders: { "Content-Type": ["application/json; charset=utf-8"] }, body: apiBody, isBase64Encoded: false };
558
+ }
453
559
  return eventFormat === "v2"
454
560
  ? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
455
561
  : { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
@@ -0,0 +1,54 @@
1
+ import type { HttpStatusCode } from "./LambderResponse.js";
2
+ export type LambderApiErrorOptions = {
3
+ /**
4
+ * Structured, user-facing failure detail placed on the API envelope's
5
+ * `errorMessage` field. Any shape the app's errorMessageHandler expects
6
+ * (e.g. `{ type: "warning", content: "..." }`). Defaults to the error
7
+ * message string, so a bare `throw new LambderApiError("...")` is still
8
+ * visible to the client.
9
+ */
10
+ errorMessage?: any;
11
+ /** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
12
+ notAuthorized?: boolean;
13
+ /** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler). */
14
+ sessionExpired?: boolean;
15
+ /**
16
+ * HTTP status of the refusal response. Default 200: the envelope is the
17
+ * semantic channel. Avoid 5xx (LambderCaller treats those as crashes) and
18
+ * 422 (reserved for input validation).
19
+ */
20
+ statusCode?: HttpStatusCode;
21
+ /** Underlying cause, preserved on the standard Error `cause` property. */
22
+ cause?: unknown;
23
+ };
24
+ /**
25
+ * A typed refusal: "this request is denied/invalid" as opposed to "the server
26
+ * crashed". Throw it from anywhere in an API call's call stack — handlers,
27
+ * hooks, or nested helpers that have no access to the per-request resolver —
28
+ * and the render pipeline maps it onto the structured API envelope
29
+ * (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
30
+ * of routing it through setGlobalErrorHandler. Refusals therefore never reach
31
+ * crash logging, and clients receive a parseable response they can surface.
32
+ *
33
+ * Thrown outside an API call (e.g. in a route handler) it behaves like any
34
+ * other error: global error handler, then the default 500.
35
+ *
36
+ * Isomorphic and dependency-free, so shared code (validators, permission
37
+ * checks) may import and throw it from packages used by both server and
38
+ * browser builds; in the browser it is just an Error.
39
+ */
40
+ export declare class LambderApiError extends Error {
41
+ /**
42
+ * Brand for detection across duplicate lambder installs: when two copies
43
+ * of the package coexist in one bundle, `instanceof LambderApiError` fails
44
+ * across them while this marker does not. The pipeline checks the brand.
45
+ */
46
+ readonly isLambderApiError = true;
47
+ readonly errorMessage?: any;
48
+ readonly notAuthorized?: boolean;
49
+ readonly sessionExpired?: boolean;
50
+ readonly statusCode?: HttpStatusCode;
51
+ constructor(message: string, options?: LambderApiErrorOptions);
52
+ }
53
+ /** Brand-based type guard (see LambderApiError.isLambderApiError). */
54
+ export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A typed refusal: "this request is denied/invalid" as opposed to "the server
3
+ * crashed". Throw it from anywhere in an API call's call stack — handlers,
4
+ * hooks, or nested helpers that have no access to the per-request resolver —
5
+ * and the render pipeline maps it onto the structured API envelope
6
+ * (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
7
+ * of routing it through setGlobalErrorHandler. Refusals therefore never reach
8
+ * crash logging, and clients receive a parseable response they can surface.
9
+ *
10
+ * Thrown outside an API call (e.g. in a route handler) it behaves like any
11
+ * other error: global error handler, then the default 500.
12
+ *
13
+ * Isomorphic and dependency-free, so shared code (validators, permission
14
+ * checks) may import and throw it from packages used by both server and
15
+ * browser builds; in the browser it is just an Error.
16
+ */
17
+ export class LambderApiError extends Error {
18
+ /**
19
+ * Brand for detection across duplicate lambder installs: when two copies
20
+ * of the package coexist in one bundle, `instanceof LambderApiError` fails
21
+ * across them while this marker does not. The pipeline checks the brand.
22
+ */
23
+ isLambderApiError = true;
24
+ errorMessage;
25
+ notAuthorized;
26
+ sessionExpired;
27
+ statusCode;
28
+ constructor(message, options = {}) {
29
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
30
+ this.name = "LambderApiError";
31
+ this.errorMessage = options.errorMessage ?? message;
32
+ this.notAuthorized = options.notAuthorized;
33
+ this.sessionExpired = options.sessionExpired;
34
+ this.statusCode = options.statusCode;
35
+ }
36
+ }
37
+ /** Brand-based type guard (see LambderApiError.isLambderApiError). */
38
+ export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;
@@ -0,0 +1,95 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ import type LambderResolver from "./LambderResolver.js";
3
+ import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
4
+ import type { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
5
+ import { LambderResponse } from "./LambderResponse.js";
6
+ /**
7
+ * What one rate-limit counter tracks: the client IP, the session identity, or
8
+ * a custom key derived from the request (e.g. a normalized email). Custom
9
+ * functions run before input validation, so they read the raw payload.
10
+ */
11
+ export type LambderRateLimitPer = "ip" | "session" | ((ctx: LambderRenderContext) => string | Promise<string>);
12
+ /** A named rate-limit policy: fixed windows plus the key one counter tracks. */
13
+ export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
14
+ per: LambderRateLimitPer;
15
+ /** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
16
+ errorMessage?: any;
17
+ };
18
+ export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
19
+ /** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
20
+ limiter: LambderDdbRateLimiter;
21
+ /** Named policies referenced (typed) from addApi/addSessionApi. */
22
+ policies: TPolicies;
23
+ };
24
+ export type LambderApiIdempotencyConfig = {
25
+ /** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
26
+ store: LambderDdbIdempotency;
27
+ /** Seconds a stored response replays for. Default: 86400 (24h). Per-API override: idempotency: { ttlSeconds }. */
28
+ defaultTtlSeconds?: number;
29
+ /** Skip idempotency (execute normally) when DynamoDB errors, instead of failing the request. Default: true. */
30
+ failOpen?: boolean;
31
+ };
32
+ /**
33
+ * A named guard, run before input validation. Refuse by throwing (typically a
34
+ * LambderApiError, or res.die.*); return normally to let the request through.
35
+ * ctx.apiPayload is unvalidated at this point.
36
+ */
37
+ export type LambderApiGuardFunction = (ctx: LambderRenderContext, res: LambderResolver) => void | Promise<void>;
38
+ /** Names of policies usable on public APIs: everything not keyed per "session". */
39
+ export type LambderPublicRateLimitNames<TPolicies> = {
40
+ [K in keyof TPolicies]: TPolicies[K] extends {
41
+ per: "session";
42
+ } ? never : K;
43
+ }[keyof TPolicies] & string;
44
+ /** Declarative per-API options carried in the addApi/addSessionApi schema object. */
45
+ export type LambderApiRegistrationOptions<TRateLimitName extends string, TGuardName extends string, TIdempotencyEnabled extends boolean> = {
46
+ /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
47
+ rateLimit?: TRateLimitName | readonly TRateLimitName[];
48
+ /** Named guards, run in declared order before input validation; refuse by throwing. */
49
+ guards?: TGuardName | readonly TGuardName[];
50
+ /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
51
+ idempotency?: TIdempotencyEnabled extends true ? (boolean | {
52
+ ttlSeconds?: number;
53
+ }) : never;
54
+ };
55
+ /**
56
+ * Runtime side of the declarative API options: holds what the enable/define
57
+ * calls declared, asserts registrations against it at startup, and executes
58
+ * rate limits, guards, and idempotency around handlers at request time.
59
+ * Internal to Lambder; apps interact through enableApiRateLimits(),
60
+ * enableApiIdempotency(), defineApiGuards() and the per-API options.
61
+ */
62
+ export declare class LambderApiPolicyEngine {
63
+ private limiter;
64
+ private rateLimitPolicies;
65
+ private guards;
66
+ private idempotencyStore;
67
+ private idempotencyDefaultTtlSeconds;
68
+ private idempotencyFailOpen;
69
+ setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
70
+ addGuards(guards: Record<string, LambderApiGuardFunction>): void;
71
+ setIdempotency(config: LambderApiIdempotencyConfig): void;
72
+ /** Startup validation of one API registration's declarative options. */
73
+ assertRegistration(apiName: string, mode: "public" | "session", options: {
74
+ rateLimit?: string | readonly string[];
75
+ guards?: string | readonly string[];
76
+ idempotency?: unknown;
77
+ }): void;
78
+ /** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
79
+ runPreflight(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, options: {
80
+ rateLimit?: string | readonly string[];
81
+ guards?: string | readonly string[];
82
+ }): Promise<void>;
83
+ private resolveRateLimitKey;
84
+ /**
85
+ * Idempotency wrapper around validation-passed handler execution. Without
86
+ * a client idempotencyKey the handler just runs; with one, the scope
87
+ * (identity + api + key) is claimed atomically: duplicates of an
88
+ * in-flight original refuse with 409, replays of a completed one return
89
+ * the stored response verbatim, and a crashed original releases its claim
90
+ * so a retry actually retries.
91
+ */
92
+ withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
93
+ ttlSeconds?: number;
94
+ }, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
95
+ }