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 +33 -10
- package/dist/client/LambderCaller.d.ts +2 -0
- package/dist/client/LambderCaller.js +8 -4
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +12 -5
- package/dist/core/Lambder.js +24 -16
- package/dist/core/LambderCors.d.ts +6 -0
- package/dist/core/LambderCors.js +5 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/policies/LambderApiGuards.d.ts +17 -6
- package/dist/policies/LambderApiGuards.js +12 -8
- package/dist/policies/LambderApiIdempotency.js +7 -3
- package/dist/policies/LambderApiPolicies.d.ts +5 -3
- package/dist/policies/LambderApiPolicies.js +7 -2
- package/dist/policies/LambderApiRateLimits.d.ts +62 -20
- package/dist/policies/LambderApiRateLimits.js +56 -14
- package/dist/shared/LambderApiError.d.ts +33 -2
- package/dist/shared/LambderApiError.js +20 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +45 -14
- package/dist/stores/LambderDdbRateLimiter.js +19 -10
- package/package.json +1 -1
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 (`
|
|
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
|
|
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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
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
|
-
|
|
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)
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/core/Lambder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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. */
|
package/dist/core/LambderCors.js
CHANGED
|
@@ -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,
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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;
|
|
205
|
-
* validation. Shared with the rate-limit
|
|
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;
|
|
18
|
-
* validation. Shared with the rate-limit
|
|
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
|
|
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
|
-
|
|
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?:
|
|
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
|
|
15
|
-
guards
|
|
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
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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?:
|
|
87
|
-
/**
|
|
88
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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,
|