lambder 3.5.2 → 3.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +41 -14
- package/dist/Lambder.d.ts +35 -13
- package/dist/Lambder.js +7 -3
- package/dist/LambderApiError.d.ts +37 -0
- package/dist/LambderApiError.js +25 -0
- package/dist/LambderApiPolicies.d.ts +101 -19
- package/dist/LambderApiPolicies.js +33 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -1
- package/package.json +1 -1
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), 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.
|
|
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; 3.7 makes guards and custom rate-limit keys payload-sliced ({ input, handler }): the slice is validated pre-run, typed in the handler, and force-merged into the contract input.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -428,9 +428,20 @@ 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)
|
|
431
|
+
### Typed API Refusals (refuse / LambderApiError)
|
|
432
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.
|
|
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. 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:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
import { refuse } from "lambder";
|
|
437
|
+
|
|
438
|
+
if (!row) refuse("Record not found."); // { type: "warning", content }
|
|
439
|
+
if (!isAdmin) refuse("Admins only.", { notAuthorized: true }); // + envelope flag
|
|
440
|
+
refuse("Too many attempts.", { type: "error", statusCode: 429 }); // custom rendering intent + status
|
|
441
|
+
// TypeScript applies never-return narrowing: after `if (!row) refuse(...)`, row is defined.
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
For full control of the errorMessage payload (apps with their own message vocabulary), throw `LambderApiError` directly; `refuse()` is sugar over it:
|
|
434
445
|
|
|
435
446
|
```typescript
|
|
436
447
|
import { LambderApiError } from "lambder";
|
|
@@ -455,7 +466,7 @@ Related: when an API call crashes with no `setGlobalErrorHandler` (or the handle
|
|
|
455
466
|
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
467
|
|
|
457
468
|
```typescript
|
|
458
|
-
import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency,
|
|
469
|
+
import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
|
|
459
470
|
|
|
460
471
|
const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
461
472
|
// 1. Rate limiting: your limiter instance + named policies. Each policy
|
|
@@ -465,8 +476,17 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
465
476
|
policies: {
|
|
466
477
|
authPerIp: { perMin: 5, perHour: 30, per: "ip" },
|
|
467
478
|
writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
|
|
468
|
-
codePerEmail: {
|
|
469
|
-
|
|
479
|
+
codePerEmail: {
|
|
480
|
+
perMin: 3,
|
|
481
|
+
// The key declares the payload slice it needs: validated before
|
|
482
|
+
// it runs, handed to the handler typed, and merged into the
|
|
483
|
+
// contract input of every API referencing this policy.
|
|
484
|
+
per: lambderRateLimitKey({
|
|
485
|
+
input: z.object({ email: z.string() }),
|
|
486
|
+
handler: (_ctx, { email }) => email.trim().toLowerCase(),
|
|
487
|
+
}),
|
|
488
|
+
errorMessage: { type: "warning", content: "Too many attempts for this address." },
|
|
489
|
+
},
|
|
470
490
|
},
|
|
471
491
|
})
|
|
472
492
|
// 2. Idempotency: a store instance + replay defaults. May share the rate
|
|
@@ -476,18 +496,25 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
476
496
|
defaultTtlSeconds: 24 * 3600,
|
|
477
497
|
failOpen: true, // DynamoDB down => execute without dedupe instead of failing
|
|
478
498
|
})
|
|
479
|
-
// 3. Named guards:
|
|
480
|
-
//
|
|
499
|
+
// 3. Named guards: { input?, handler } definitions run before input
|
|
500
|
+
// validation and refuse by throwing. A guard's `input` slice is
|
|
501
|
+
// validated against the raw payload, handed to the handler typed, and
|
|
502
|
+
// merged into the contract input of every API declaring the guard, so
|
|
503
|
+
// forgetting to send captchaToken is a compile error at the call site.
|
|
481
504
|
.defineApiGuards({
|
|
482
|
-
captcha:
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
505
|
+
captcha: lambderGuard({
|
|
506
|
+
input: z.object({ captchaToken: z.string() }),
|
|
507
|
+
handler: async (ctx, { captchaToken }) => {
|
|
508
|
+
if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
|
|
509
|
+
},
|
|
510
|
+
}),
|
|
487
511
|
});
|
|
488
512
|
|
|
489
513
|
lambder.addApi("public.resetPassword", {
|
|
490
|
-
|
|
514
|
+
// captchaToken is NOT declared here: the guard contributes it to the
|
|
515
|
+
// contract, the guard validates and consumes it, and the handler never
|
|
516
|
+
// sees it.
|
|
517
|
+
input: z.object({ email: z.string().email() }),
|
|
491
518
|
output: z.object({ ok: z.boolean() }),
|
|
492
519
|
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
|
|
493
520
|
guards: "captcha",
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -8,7 +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
|
|
11
|
+
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuard, type LambderGuardPayloadMap, type LambderGuardsRequirement, type LambderPoliciesRequirement, type LambderMergedInput, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
|
|
12
12
|
import type { MergeContract } from "./LambderApiContract.js";
|
|
13
13
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
14
14
|
export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
|
|
@@ -81,7 +81,7 @@ export type LambderConstructorOptions = {
|
|
|
81
81
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
82
82
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
83
83
|
* @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
|
|
84
|
-
* @typeParam
|
|
84
|
+
* @typeParam _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
|
|
85
85
|
* @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
|
|
86
86
|
*
|
|
87
87
|
* @example
|
|
@@ -93,7 +93,7 @@ export type LambderConstructorOptions = {
|
|
|
93
93
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
94
94
|
* ```
|
|
95
95
|
*/
|
|
96
|
-
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {},
|
|
96
|
+
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false> {
|
|
97
97
|
apiPath: string;
|
|
98
98
|
apiVersion: null | string;
|
|
99
99
|
publicPath: string;
|
|
@@ -188,7 +188,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
188
188
|
* policies keyed per "session" are only referable from addSessionApi.
|
|
189
189
|
* Callable once; call it before the API registrations that use it.
|
|
190
190
|
*/
|
|
191
|
-
enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies,
|
|
191
|
+
enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
192
192
|
/**
|
|
193
193
|
* Wire declarative idempotency: your LambderDdbIdempotency instance plus
|
|
194
194
|
* replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
|
|
@@ -198,15 +198,19 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
198
198
|
* 409, replays of a completed request return the stored response, and a
|
|
199
199
|
* crashed original releases its claim. Callable once.
|
|
200
200
|
*/
|
|
201
|
-
enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies,
|
|
201
|
+
enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
|
|
202
202
|
/**
|
|
203
203
|
* Define named guards that APIs reference (typed) via the `guards`
|
|
204
|
-
* option.
|
|
205
|
-
*
|
|
204
|
+
* option. Each guard is a { input?, handler } definition (build with
|
|
205
|
+
* lambderGuard()): the input slice is validated against the raw payload
|
|
206
|
+
* before the handler runs, the handler receives it typed, and the
|
|
207
|
+
* requirement merges into the contract input of every API declaring the
|
|
208
|
+
* guard. Guards run before input validation, in the order the API
|
|
209
|
+
* declares them; a handler refuses by throwing (typically refuse()).
|
|
206
210
|
* Callable multiple times so domain modules can contribute their own;
|
|
207
211
|
* names must not collide.
|
|
208
212
|
*/
|
|
209
|
-
defineApiGuards<TGuards extends Record<string,
|
|
213
|
+
defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardPayloadMap<TGuards>, _TIdempotencyEnabled>;
|
|
210
214
|
private getOrCreatePolicyEngine;
|
|
211
215
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
212
216
|
private assertApiRegistration;
|
|
@@ -214,15 +218,33 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
214
218
|
addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
|
|
215
219
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
216
220
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
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,
|
|
218
|
-
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
|
|
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>;
|
|
222
|
+
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderPublicRateLimitNames<_TRateLimitPolicies> | readonly LambderPublicRateLimitNames<_TRateLimitPolicies>[] = never, const TGuardsOpt extends Extract<keyof _TGuards, string> | readonly Extract<keyof _TGuards, string>[] = never>(name: TName, schema: {
|
|
219
223
|
input: TInput;
|
|
220
224
|
output: TOutput;
|
|
221
|
-
} &
|
|
222
|
-
|
|
225
|
+
} & {
|
|
226
|
+
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
227
|
+
rateLimit?: TRateOpt;
|
|
228
|
+
/** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
|
|
229
|
+
guards?: TGuardsOpt;
|
|
230
|
+
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
231
|
+
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
232
|
+
ttlSeconds?: number;
|
|
233
|
+
}) : never;
|
|
234
|
+
}, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, LambderMergedInput<z.infer<TInput>, LambderPoliciesRequirement<_TRateLimitPolicies, TRateOpt>, LambderGuardsRequirement<_TGuards, TGuardsOpt>>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
235
|
+
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends Extract<keyof _TRateLimitPolicies, string> | readonly Extract<keyof _TRateLimitPolicies, string>[] = never, const TGuardsOpt extends Extract<keyof _TGuards, string> | readonly Extract<keyof _TGuards, string>[] = never>(name: TName, schema: {
|
|
223
236
|
input: TInput;
|
|
224
237
|
output: TOutput;
|
|
225
|
-
} &
|
|
238
|
+
} & {
|
|
239
|
+
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
240
|
+
rateLimit?: TRateOpt;
|
|
241
|
+
/** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
|
|
242
|
+
guards?: TGuardsOpt;
|
|
243
|
+
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
244
|
+
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
245
|
+
ttlSeconds?: number;
|
|
246
|
+
}) : never;
|
|
247
|
+
}, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, LambderMergedInput<z.infer<TInput>, LambderPoliciesRequirement<_TRateLimitPolicies, TRateOpt>, LambderGuardsRequirement<_TGuards, TGuardsOpt>>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
226
248
|
/**
|
|
227
249
|
* Fetch the session or short-circuit the request: API calls get the
|
|
228
250
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
package/dist/Lambder.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
|
15
15
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
16
16
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
17
17
|
* @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
|
|
18
|
-
* @typeParam
|
|
18
|
+
* @typeParam _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
|
|
19
19
|
* @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
@@ -193,8 +193,12 @@ export default class Lambder {
|
|
|
193
193
|
}
|
|
194
194
|
/**
|
|
195
195
|
* Define named guards that APIs reference (typed) via the `guards`
|
|
196
|
-
* option.
|
|
197
|
-
*
|
|
196
|
+
* option. Each guard is a { input?, handler } definition (build with
|
|
197
|
+
* lambderGuard()): the input slice is validated against the raw payload
|
|
198
|
+
* before the handler runs, the handler receives it typed, and the
|
|
199
|
+
* requirement merges into the contract input of every API declaring the
|
|
200
|
+
* guard. Guards run before input validation, in the order the API
|
|
201
|
+
* declares them; a handler refuses by throwing (typically refuse()).
|
|
198
202
|
* Callable multiple times so domain modules can contribute their own;
|
|
199
203
|
* names must not collide.
|
|
200
204
|
*/
|
|
@@ -52,3 +52,40 @@ export declare class LambderApiError extends Error {
|
|
|
52
52
|
}
|
|
53
53
|
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
54
54
|
export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
|
|
55
|
+
/**
|
|
56
|
+
* 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.
|
|
59
|
+
*/
|
|
60
|
+
export type LambderRefusalMessage = {
|
|
61
|
+
type: "warning" | "error" | "info";
|
|
62
|
+
title?: string;
|
|
63
|
+
content: string;
|
|
64
|
+
};
|
|
65
|
+
export type LambderRefuseOptions = {
|
|
66
|
+
/** Rendering intent for the client's errorMessageHandler. Default: "warning". */
|
|
67
|
+
type?: LambderRefusalMessage["type"];
|
|
68
|
+
/** Optional heading shown above the content. */
|
|
69
|
+
title?: string;
|
|
70
|
+
/** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). */
|
|
71
|
+
notAuthorized?: boolean;
|
|
72
|
+
/** Sets the envelope's sessionExpired flag. */
|
|
73
|
+
sessionExpired?: boolean;
|
|
74
|
+
/** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). */
|
|
75
|
+
statusCode?: HttpStatusCode;
|
|
76
|
+
/** Underlying cause, preserved on the Error cause property. */
|
|
77
|
+
cause?: unknown;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Refuse the current API call: a routine business "no" (not found, invalid
|
|
81
|
+
* input, not allowed) with a user-facing message. Throws a LambderApiError
|
|
82
|
+
* carrying the standard LambderRefusalMessage shape, so the pipeline maps it
|
|
83
|
+
* onto the structured envelope instead of a 500, and crash logging never
|
|
84
|
+
* sees it. Callable from anywhere in the call stack — handlers, hooks,
|
|
85
|
+
* guards, shared helpers with no resolver access.
|
|
86
|
+
*
|
|
87
|
+
* The const carries the annotation so TypeScript applies never-return
|
|
88
|
+
* control-flow narrowing at call sites (`if (!row) refuse(...)` implies
|
|
89
|
+
* `row` is defined afterwards).
|
|
90
|
+
*/
|
|
91
|
+
export declare const refuse: (content: string, options?: LambderRefuseOptions) => never;
|
package/dist/LambderApiError.js
CHANGED
|
@@ -36,3 +36,28 @@ export class LambderApiError extends Error {
|
|
|
36
36
|
}
|
|
37
37
|
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
38
38
|
export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;
|
|
39
|
+
/**
|
|
40
|
+
* Refuse the current API call: a routine business "no" (not found, invalid
|
|
41
|
+
* input, not allowed) with a user-facing message. Throws a LambderApiError
|
|
42
|
+
* carrying the standard LambderRefusalMessage shape, so the pipeline maps it
|
|
43
|
+
* onto the structured envelope instead of a 500, and crash logging never
|
|
44
|
+
* sees it. Callable from anywhere in the call stack — handlers, hooks,
|
|
45
|
+
* guards, shared helpers with no resolver access.
|
|
46
|
+
*
|
|
47
|
+
* The const carries the annotation so TypeScript applies never-return
|
|
48
|
+
* control-flow narrowing at call sites (`if (!row) refuse(...)` implies
|
|
49
|
+
* `row` is defined afterwards).
|
|
50
|
+
*/
|
|
51
|
+
export const refuse = (content, options = {}) => {
|
|
52
|
+
throw new LambderApiError(content, {
|
|
53
|
+
errorMessage: {
|
|
54
|
+
type: options.type ?? "warning",
|
|
55
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
56
|
+
content,
|
|
57
|
+
},
|
|
58
|
+
notAuthorized: options.notAuthorized,
|
|
59
|
+
sessionExpired: options.sessionExpired,
|
|
60
|
+
statusCode: options.statusCode,
|
|
61
|
+
cause: options.cause,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
@@ -1,14 +1,44 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
1
2
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
3
|
import type LambderResolver from "./LambderResolver.js";
|
|
3
4
|
import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
|
4
5
|
import type { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
|
|
5
6
|
import { LambderResponse } from "./LambderResponse.js";
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* A custom rate-limit key: `input` names the payload fields the key needs.
|
|
9
|
+
* The slice is validated against the raw payload before `handler` runs (a
|
|
10
|
+
* failure answers the standard 422 validation shape), and the requirement is
|
|
11
|
+
* merged into the contract input of every API that references the policy, so
|
|
12
|
+
* clients are forced by the compiler to send those fields. Build with
|
|
13
|
+
* lambderRateLimitKey() so the handler's payload type follows `input`.
|
|
10
14
|
*/
|
|
11
|
-
export type
|
|
15
|
+
export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
|
|
16
|
+
input: TInput;
|
|
17
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
18
|
+
} | {
|
|
19
|
+
input?: undefined;
|
|
20
|
+
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Builder that ties the handler's payload type to the `input` schema inside
|
|
24
|
+
* one literal. Returns the exact union member (not the union), so requirement
|
|
25
|
+
* extraction can see the `input` type.
|
|
26
|
+
*/
|
|
27
|
+
export declare function lambderRateLimitKey<TInput extends z.ZodTypeAny>(key: {
|
|
28
|
+
input: TInput;
|
|
29
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
30
|
+
}): {
|
|
31
|
+
input: TInput;
|
|
32
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
33
|
+
};
|
|
34
|
+
export declare function lambderRateLimitKey(key: {
|
|
35
|
+
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
36
|
+
}): {
|
|
37
|
+
input?: undefined;
|
|
38
|
+
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
39
|
+
};
|
|
40
|
+
/** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
|
|
41
|
+
export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
|
|
12
42
|
/** A named rate-limit policy: fixed windows plus the key one counter tracks. */
|
|
13
43
|
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
14
44
|
per: LambderRateLimitPer;
|
|
@@ -30,28 +60,72 @@ export type LambderApiIdempotencyConfig = {
|
|
|
30
60
|
failOpen?: boolean;
|
|
31
61
|
};
|
|
32
62
|
/**
|
|
33
|
-
* A named guard, run before input validation.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
63
|
+
* A named guard, run before the API's own input validation. `input` names the
|
|
64
|
+
* payload fields the guard requires: the slice is validated against the raw
|
|
65
|
+
* payload before `handler` runs (a failure answers the standard 422
|
|
66
|
+
* validation shape), the handler receives it typed, and the requirement is
|
|
67
|
+
* merged into the contract input of every API that declares the guard, so
|
|
68
|
+
* clients are forced by the compiler to send those fields. The handler
|
|
69
|
+
* refuses by throwing (typically refuse()/LambderApiError). Build with
|
|
70
|
+
* lambderGuard() so the handler's payload type follows `input`.
|
|
36
71
|
*/
|
|
37
|
-
export type
|
|
72
|
+
export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
|
|
73
|
+
input: TInput;
|
|
74
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
|
|
75
|
+
} | {
|
|
76
|
+
input?: undefined;
|
|
77
|
+
handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Builder that ties the handler's payload type to the `input` schema inside
|
|
81
|
+
* one literal. Returns the exact union member (not the union), so requirement
|
|
82
|
+
* extraction can see the `input` type.
|
|
83
|
+
*/
|
|
84
|
+
export declare function lambderGuard<TInput extends z.ZodTypeAny>(guard: {
|
|
85
|
+
input: TInput;
|
|
86
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
|
|
87
|
+
}): {
|
|
88
|
+
input: TInput;
|
|
89
|
+
handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
export declare function lambderGuard(guard: {
|
|
92
|
+
handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
|
|
93
|
+
}): {
|
|
94
|
+
input?: undefined;
|
|
95
|
+
handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
|
|
96
|
+
};
|
|
38
97
|
/** Names of policies usable on public APIs: everything not keyed per "session". */
|
|
39
98
|
export type LambderPublicRateLimitNames<TPolicies> = {
|
|
40
99
|
[K in keyof TPolicies]: TPolicies[K] extends {
|
|
41
100
|
per: "session";
|
|
42
101
|
} ? never : K;
|
|
43
102
|
}[keyof TPolicies] & string;
|
|
44
|
-
/**
|
|
45
|
-
export type
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
idempotency?: TIdempotencyEnabled extends true ? (boolean | {
|
|
52
|
-
ttlSeconds?: number;
|
|
53
|
-
}) : never;
|
|
103
|
+
/** Payload fields a guard requires; {} when it declares no input. */
|
|
104
|
+
export type LambderGuardPayload<G> = G extends {
|
|
105
|
+
input: infer S extends z.ZodTypeAny;
|
|
106
|
+
} ? z.output<S> : {};
|
|
107
|
+
/** Guard name to required-payload map, accumulated on the Lambder instance by defineApiGuards. */
|
|
108
|
+
export type LambderGuardPayloadMap<TGuards> = {
|
|
109
|
+
[K in keyof TGuards]: LambderGuardPayload<TGuards[K]>;
|
|
54
110
|
};
|
|
111
|
+
/** Payload fields a policy's custom key requires; {} for "ip"/"session" or keys with no input. */
|
|
112
|
+
export type LambderPolicyPayload<P> = P extends {
|
|
113
|
+
per: {
|
|
114
|
+
input: infer S extends z.ZodTypeAny;
|
|
115
|
+
};
|
|
116
|
+
} ? z.output<S> : {};
|
|
117
|
+
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
|
118
|
+
type NamesIn<TOpt> = TOpt extends readonly (infer N extends string)[] ? N : TOpt extends string ? TOpt : never;
|
|
119
|
+
/** Intersection of the payload requirements of the referenced guards; never when none are declared. */
|
|
120
|
+
export type LambderGuardsRequirement<TGuardPayloads, TOpt> = [
|
|
121
|
+
NamesIn<TOpt>
|
|
122
|
+
] extends [never] ? never : UnionToIntersection<TGuardPayloads[Extract<NamesIn<TOpt>, keyof TGuardPayloads>]>;
|
|
123
|
+
/** Intersection of the payload requirements of the referenced rate-limit policies; never when none are declared. */
|
|
124
|
+
export type LambderPoliciesRequirement<TPolicies, TOpt> = [
|
|
125
|
+
NamesIn<TOpt>
|
|
126
|
+
] extends [never] ? never : UnionToIntersection<LambderPolicyPayload<TPolicies[Extract<NamesIn<TOpt>, keyof TPolicies>]>>;
|
|
127
|
+
/** Contract-input merge: the API's own input plus everything its rate limits and guards force clients to send. */
|
|
128
|
+
export type LambderMergedInput<TIn, TReqA, TReqB> = ([TReqA] extends [never] ? TIn : TIn & TReqA) extends infer TMid ? ([TReqB] extends [never] ? TMid : TMid & TReqB) : never;
|
|
55
129
|
/**
|
|
56
130
|
* Runtime side of the declarative API options: holds what the enable/define
|
|
57
131
|
* calls declared, asserts registrations against it at startup, and executes
|
|
@@ -67,7 +141,7 @@ export declare class LambderApiPolicyEngine {
|
|
|
67
141
|
private idempotencyDefaultTtlSeconds;
|
|
68
142
|
private idempotencyFailOpen;
|
|
69
143
|
setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
70
|
-
addGuards(guards: Record<string,
|
|
144
|
+
addGuards(guards: Record<string, LambderApiGuard<any>>): void;
|
|
71
145
|
setIdempotency(config: LambderApiIdempotencyConfig): void;
|
|
72
146
|
/** Startup validation of one API registration's declarative options. */
|
|
73
147
|
assertRegistration(apiName: string, mode: "public" | "session", options: {
|
|
@@ -80,6 +154,13 @@ export declare class LambderApiPolicyEngine {
|
|
|
80
154
|
rateLimit?: string | readonly string[];
|
|
81
155
|
guards?: string | readonly string[];
|
|
82
156
|
}): Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Validate a preflight input slice against the raw payload. Runs before
|
|
159
|
+
* the API's own validation, so guard/key requirements hold even when the
|
|
160
|
+
* API schema does not declare (and would strip) those fields. Failures
|
|
161
|
+
* answer the same 422 shape as regular input validation.
|
|
162
|
+
*/
|
|
163
|
+
private parseSlice;
|
|
83
164
|
private resolveRateLimitKey;
|
|
84
165
|
/**
|
|
85
166
|
* Idempotency wrapper around validation-passed handler execution. Without
|
|
@@ -93,3 +174,4 @@ export declare class LambderApiPolicyEngine {
|
|
|
93
174
|
ttlSeconds?: number;
|
|
94
175
|
}, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
|
|
95
176
|
}
|
|
177
|
+
export {};
|
|
@@ -5,6 +5,8 @@ const IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
|
|
|
5
5
|
/** Responses above this size skip replay storage (DynamoDB item limit is 400KB). */
|
|
6
6
|
const IDEMPOTENCY_MAX_STORED_BODY_BYTES = 350_000;
|
|
7
7
|
const RATE_LIMIT_WINDOW_KEYS = ["perMin", "per10Min", "perHour", "perDay", "perWeek", "perMonth"];
|
|
8
|
+
export function lambderRateLimitKey(key) { return key; }
|
|
9
|
+
export function lambderGuard(guard) { return guard; }
|
|
8
10
|
const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
|
|
9
11
|
/**
|
|
10
12
|
* Runtime side of the declarative API options: holds what the enable/define
|
|
@@ -24,8 +26,10 @@ export class LambderApiPolicyEngine {
|
|
|
24
26
|
if (this.limiter)
|
|
25
27
|
throw new Error("Lambder: enableApiRateLimits() was already called.");
|
|
26
28
|
for (const [name, policy] of Object.entries(config.policies)) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const per = policy.per;
|
|
30
|
+
if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
|
|
31
|
+
throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { input?, handler } key.`);
|
|
32
|
+
}
|
|
29
33
|
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
30
34
|
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
31
35
|
}
|
|
@@ -34,10 +38,12 @@ export class LambderApiPolicyEngine {
|
|
|
34
38
|
this.rateLimitPolicies = { ...config.policies };
|
|
35
39
|
}
|
|
36
40
|
addGuards(guards) {
|
|
37
|
-
for (const [name,
|
|
41
|
+
for (const [name, guardDef] of Object.entries(guards)) {
|
|
38
42
|
if (this.guards[name])
|
|
39
43
|
throw new Error(`Lambder: guard "${name}" is already defined.`);
|
|
40
|
-
|
|
44
|
+
if (typeof guardDef?.handler !== "function")
|
|
45
|
+
throw new Error(`Lambder: guard "${name}" has no handler function.`);
|
|
46
|
+
this.guards[name] = guardDef;
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
setIdempotency(config) {
|
|
@@ -73,7 +79,7 @@ export class LambderApiPolicyEngine {
|
|
|
73
79
|
const policy = this.rateLimitPolicies[name];
|
|
74
80
|
if (!policy || !this.limiter)
|
|
75
81
|
throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
|
|
76
|
-
const key = await this.resolveRateLimitKey(ctx, policy.per);
|
|
82
|
+
const key = await this.resolveRateLimitKey(ctx, resolver, policy.per);
|
|
77
83
|
const limited = await this.limiter.isRateLimited(`api|${apiName}|${name}|${key}`, policy);
|
|
78
84
|
if (limited) {
|
|
79
85
|
throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
|
|
@@ -83,13 +89,29 @@ export class LambderApiPolicyEngine {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
for (const name of toList(options.guards)) {
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
92
|
+
const guardDef = this.guards[name];
|
|
93
|
+
if (!guardDef)
|
|
88
94
|
throw new Error(`Lambder: guard "${name}" is not configured.`);
|
|
89
|
-
|
|
95
|
+
const payload = this.parseSlice(guardDef.input, ctx, resolver);
|
|
96
|
+
await guardDef.handler(ctx, payload, resolver);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Validate a preflight input slice against the raw payload. Runs before
|
|
101
|
+
* the API's own validation, so guard/key requirements hold even when the
|
|
102
|
+
* API schema does not declare (and would strip) those fields. Failures
|
|
103
|
+
* answer the same 422 shape as regular input validation.
|
|
104
|
+
*/
|
|
105
|
+
parseSlice(input, ctx, resolver) {
|
|
106
|
+
if (!input)
|
|
107
|
+
return undefined;
|
|
108
|
+
const parsed = input.safeParse(ctx.post?.payload);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
throw resolver.json({ error: "Input validation failed", zodError: parsed.error }, { statusCode: 422 });
|
|
90
111
|
}
|
|
112
|
+
return parsed.data;
|
|
91
113
|
}
|
|
92
|
-
async resolveRateLimitKey(ctx, per) {
|
|
114
|
+
async resolveRateLimitKey(ctx, resolver, per) {
|
|
93
115
|
if (per === "ip")
|
|
94
116
|
return `ip:${ctx.ip}`;
|
|
95
117
|
if (per === "session") {
|
|
@@ -98,7 +120,8 @@ export class LambderApiPolicyEngine {
|
|
|
98
120
|
throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
|
|
99
121
|
return `session:${sessionKey}`;
|
|
100
122
|
}
|
|
101
|
-
|
|
123
|
+
const payload = this.parseSlice(per.input, ctx, resolver);
|
|
124
|
+
return `custom:${await per.handler(ctx, payload)}`;
|
|
102
125
|
}
|
|
103
126
|
/**
|
|
104
127
|
* Idempotency wrapper around validation-passed handler execution. Without
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import Lambder from './Lambder.js';
|
|
|
2
2
|
export default Lambder;
|
|
3
3
|
export { default as LambderCaller } from "./LambderCaller.js";
|
|
4
4
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions } from "./LambderCaller.js";
|
|
5
|
-
export { LambderApiError, isLambderApiError } from "./LambderApiError.js";
|
|
6
|
-
export type { LambderApiErrorOptions } from "./LambderApiError.js";
|
|
5
|
+
export { LambderApiError, isLambderApiError, refuse } from "./LambderApiError.js";
|
|
6
|
+
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./LambderApiError.js";
|
|
7
7
|
export { default as LambderResponseBuilder } from "./LambderResponseBuilder.js";
|
|
8
8
|
export { default as LambderResolver } from "./LambderResolver.js";
|
|
9
9
|
export { default as LambderSessionManager } from "./LambderSessionManager.js";
|
|
@@ -27,7 +27,8 @@ export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
|
|
27
27
|
export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy, LambderRateLimitExceededMap, LambderRateLimitResult, } from "./LambderDdbRateLimiter.js";
|
|
28
28
|
export { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
|
|
29
29
|
export type { LambderDdbIdempotencyOptions, LambderIdempotencyBeginResult, } from "./LambderDdbIdempotency.js";
|
|
30
|
-
export
|
|
30
|
+
export { lambderGuard, lambderRateLimitKey } from "./LambderApiPolicies.js";
|
|
31
|
+
export type { LambderApiGuard, LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderApiIdempotencyConfig, LambderPublicRateLimitNames, LambderGuardPayload, LambderGuardPayloadMap, LambderPolicyPayload, LambderGuardsRequirement, LambderPoliciesRequirement, LambderMergedInput, } from "./LambderApiPolicies.js";
|
|
31
32
|
export { createLambderI18n } from "./LambderI18n.js";
|
|
32
33
|
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./LambderI18n.js";
|
|
33
34
|
export { type ApiContractShape, } from "./LambderApiContract.js";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import Lambder from './Lambder.js';
|
|
|
2
2
|
export default Lambder;
|
|
3
3
|
export { default as LambderCaller } from "./LambderCaller.js";
|
|
4
4
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere)
|
|
5
|
-
export { LambderApiError, isLambderApiError } from "./LambderApiError.js";
|
|
5
|
+
export { LambderApiError, isLambderApiError, refuse } from "./LambderApiError.js";
|
|
6
6
|
export { default as LambderResponseBuilder } from "./LambderResponseBuilder.js";
|
|
7
7
|
export { default as LambderResolver } from "./LambderResolver.js";
|
|
8
8
|
export { default as LambderSessionManager } from "./LambderSessionManager.js";
|
|
@@ -23,6 +23,8 @@ export { LambderDdbCache } from "./LambderDdbCache.js";
|
|
|
23
23
|
export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
|
24
24
|
// DynamoDB-backed idempotency records (standalone, server-only)
|
|
25
25
|
export { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
|
|
26
|
+
// Declarative per-API policies (rate limits, guards, idempotency)
|
|
27
|
+
export { lambderGuard, lambderRateLimitKey } from "./LambderApiPolicies.js";
|
|
26
28
|
// Typed translations (standalone, isomorphic)
|
|
27
29
|
export { createLambderI18n } from "./LambderI18n.js";
|
|
28
30
|
export { createContext, isV2HttpEvent } from "./LambderContext.js";
|