lambder 3.7.1 → 4.0.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 +92 -24
- package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +50 -14
- package/dist/{LambderCaller.js → client/LambderCaller.js} +29 -17
- package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
- package/dist/client.d.ts +16 -0
- package/dist/client.js +17 -0
- package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -19
- package/dist/{Lambder.js → core/Lambder.js} +28 -12
- package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
- package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
- package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
- package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
- package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
- package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
- package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
- package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
- package/dist/index.d.ts +36 -35
- package/dist/index.js +21 -20
- package/dist/policies/LambderApiGuards.d.ts +221 -0
- package/dist/policies/LambderApiGuards.js +79 -0
- package/dist/policies/LambderApiIdempotency.d.ts +58 -0
- package/dist/policies/LambderApiIdempotency.js +215 -0
- package/dist/policies/LambderApiPolicies.d.ts +40 -0
- package/dist/policies/LambderApiPolicies.js +47 -0
- package/dist/policies/LambderApiRateLimits.d.ts +90 -0
- package/dist/policies/LambderApiRateLimits.js +77 -0
- package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
- package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
- package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
- package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
- package/dist/shared/LambderApiContract.d.ts +41 -0
- package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
- package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
- package/dist/stores/LambderDdbCompression.d.ts +3 -0
- package/dist/stores/LambderDdbCompression.js +39 -0
- package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
- package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
- package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
- package/dist/testing.d.ts +9 -0
- package/dist/testing.js +8 -0
- package/package.json +19 -1
- package/dist/LambderApiContract.d.ts +0 -21
- package/dist/LambderApiPolicies.d.ts +0 -177
- package/dist/LambderApiPolicies.js +0 -219
- /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
- /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
- /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
- /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
- /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
- /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
- /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
- /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
- /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
- /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
- /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
- /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
- /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
- /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
- /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
- /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
- /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
- /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
- /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing entry point (`import ... from "lambder/testing"`).
|
|
3
|
+
*
|
|
4
|
+
* Mock tooling that neither the server nor the production client bundle
|
|
5
|
+
* should carry: the MSW adapter that serves an app's typed API contract
|
|
6
|
+
* from in-browser mock handlers during development and tests.
|
|
7
|
+
*/
|
|
8
|
+
export { default as LambderMSW } from "./client/LambderMSW.js";
|
|
9
|
+
export type { LambderMswModule } from "./client/LambderMSW.js";
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing entry point (`import ... from "lambder/testing"`).
|
|
3
|
+
*
|
|
4
|
+
* Mock tooling that neither the server nor the production client bundle
|
|
5
|
+
* should carry: the MSW adapter that serves an app's typed API contract
|
|
6
|
+
* from in-browser mock handlers during development and tests.
|
|
7
|
+
*/
|
|
8
|
+
export { default as LambderMSW } from "./client/LambderMSW.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/client.d.ts",
|
|
15
|
+
"default": "./dist/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./dist/testing.d.ts",
|
|
19
|
+
"default": "./dist/testing.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"typesVersions": {
|
|
23
|
+
"*": {
|
|
24
|
+
"client": [
|
|
25
|
+
"./dist/client.d.ts"
|
|
26
|
+
],
|
|
27
|
+
"testing": [
|
|
28
|
+
"./dist/testing.d.ts"
|
|
29
|
+
]
|
|
12
30
|
}
|
|
13
31
|
},
|
|
14
32
|
"browser": {
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lambder API Contract System
|
|
3
|
-
*
|
|
4
|
-
* Contracts are built via method chaining and inferred using typeof lambder.ApiContract
|
|
5
|
-
*/
|
|
6
|
-
/**
|
|
7
|
-
* Base shape for API contracts - used by LambderCaller and LambderMSW
|
|
8
|
-
*/
|
|
9
|
-
export type ApiContractShape = Record<string, {
|
|
10
|
-
input: any;
|
|
11
|
-
output: any;
|
|
12
|
-
}>;
|
|
13
|
-
/**
|
|
14
|
-
* Helper type for merging new API into existing contract during chaining
|
|
15
|
-
*/
|
|
16
|
-
export type MergeContract<Old, Name extends string, In, Out> = Old & {
|
|
17
|
-
[K in Name]: {
|
|
18
|
-
input: In;
|
|
19
|
-
output: Out;
|
|
20
|
-
};
|
|
21
|
-
};
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import type { z } from "zod";
|
|
2
|
-
import type { LambderRenderContext } from "./LambderContext.js";
|
|
3
|
-
import type LambderResolver from "./LambderResolver.js";
|
|
4
|
-
import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
|
5
|
-
import type { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
|
|
6
|
-
import { LambderResponse } from "./LambderResponse.js";
|
|
7
|
-
/**
|
|
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`.
|
|
14
|
-
*/
|
|
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>;
|
|
42
|
-
/** A named rate-limit policy: fixed windows plus the key one counter tracks. */
|
|
43
|
-
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
44
|
-
per: LambderRateLimitPer;
|
|
45
|
-
/** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
|
|
46
|
-
errorMessage?: any;
|
|
47
|
-
};
|
|
48
|
-
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
49
|
-
/** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
|
|
50
|
-
limiter: LambderDdbRateLimiter;
|
|
51
|
-
/** Named policies referenced (typed) from addApi/addSessionApi. */
|
|
52
|
-
policies: TPolicies;
|
|
53
|
-
};
|
|
54
|
-
export type LambderApiIdempotencyConfig = {
|
|
55
|
-
/** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
|
|
56
|
-
store: LambderDdbIdempotency;
|
|
57
|
-
/** Seconds a stored response replays for. Default: 86400 (24h). Per-API override: idempotency: { ttlSeconds }. */
|
|
58
|
-
defaultTtlSeconds?: number;
|
|
59
|
-
/** Skip idempotency (execute normally) when DynamoDB errors, instead of failing the request. Default: true. */
|
|
60
|
-
failOpen?: boolean;
|
|
61
|
-
};
|
|
62
|
-
/**
|
|
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`.
|
|
71
|
-
*/
|
|
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
|
-
};
|
|
97
|
-
/** Names of policies usable on public APIs: everything not keyed per "session". */
|
|
98
|
-
export type LambderPublicRateLimitNames<TPolicies> = {
|
|
99
|
-
[K in keyof TPolicies]: TPolicies[K] extends {
|
|
100
|
-
per: "session";
|
|
101
|
-
} ? never : K;
|
|
102
|
-
}[keyof TPolicies] & string;
|
|
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]>;
|
|
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;
|
|
129
|
-
/**
|
|
130
|
-
* Runtime side of the declarative API options: holds what the enable/define
|
|
131
|
-
* calls declared, asserts registrations against it at startup, and executes
|
|
132
|
-
* rate limits, guards, and idempotency around handlers at request time.
|
|
133
|
-
* Internal to Lambder; apps interact through enableApiRateLimits(),
|
|
134
|
-
* enableApiIdempotency(), defineApiGuards() and the per-API options.
|
|
135
|
-
*/
|
|
136
|
-
export declare class LambderApiPolicyEngine {
|
|
137
|
-
private limiter;
|
|
138
|
-
private rateLimitPolicies;
|
|
139
|
-
private guards;
|
|
140
|
-
private idempotencyStore;
|
|
141
|
-
private idempotencyDefaultTtlSeconds;
|
|
142
|
-
private idempotencyFailOpen;
|
|
143
|
-
setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
144
|
-
addGuards(guards: Record<string, LambderApiGuard<any>>): void;
|
|
145
|
-
setIdempotency(config: LambderApiIdempotencyConfig): void;
|
|
146
|
-
/** Startup validation of one API registration's declarative options. */
|
|
147
|
-
assertRegistration(apiName: string, mode: "public" | "session", options: {
|
|
148
|
-
rateLimit?: string | readonly string[];
|
|
149
|
-
guards?: string | readonly string[];
|
|
150
|
-
idempotency?: unknown;
|
|
151
|
-
}): void;
|
|
152
|
-
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|
|
153
|
-
runPreflight(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, options: {
|
|
154
|
-
rateLimit?: string | readonly string[];
|
|
155
|
-
guards?: string | readonly string[];
|
|
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;
|
|
164
|
-
private resolveRateLimitKey;
|
|
165
|
-
/**
|
|
166
|
-
* Idempotency wrapper around validation-passed handler execution. Without
|
|
167
|
-
* a client idempotencyKey the handler just runs; with one, the scope
|
|
168
|
-
* (identity + api + key) is claimed atomically: duplicates of an
|
|
169
|
-
* in-flight original refuse with 409, replays of a completed one return
|
|
170
|
-
* the stored response verbatim, and a crashed original releases its claim
|
|
171
|
-
* so a retry actually retries.
|
|
172
|
-
*/
|
|
173
|
-
withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
|
|
174
|
-
ttlSeconds?: number;
|
|
175
|
-
}, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
|
|
176
|
-
}
|
|
177
|
-
export {};
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { LambderApiError } from "./LambderApiError.js";
|
|
2
|
-
import { LambderResponse } from "./LambderResponse.js";
|
|
3
|
-
/** A crashed original must not block retries forever: pending claims expire on their own. */
|
|
4
|
-
const IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
|
|
5
|
-
/** Responses above this size skip replay storage (DynamoDB item limit is 400KB). */
|
|
6
|
-
const IDEMPOTENCY_MAX_STORED_BODY_BYTES = 350_000;
|
|
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; }
|
|
10
|
-
const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
|
|
11
|
-
/**
|
|
12
|
-
* Runtime side of the declarative API options: holds what the enable/define
|
|
13
|
-
* calls declared, asserts registrations against it at startup, and executes
|
|
14
|
-
* rate limits, guards, and idempotency around handlers at request time.
|
|
15
|
-
* Internal to Lambder; apps interact through enableApiRateLimits(),
|
|
16
|
-
* enableApiIdempotency(), defineApiGuards() and the per-API options.
|
|
17
|
-
*/
|
|
18
|
-
export class LambderApiPolicyEngine {
|
|
19
|
-
limiter = null;
|
|
20
|
-
rateLimitPolicies = {};
|
|
21
|
-
guards = {};
|
|
22
|
-
idempotencyStore = null;
|
|
23
|
-
idempotencyDefaultTtlSeconds = 24 * 3600;
|
|
24
|
-
idempotencyFailOpen = true;
|
|
25
|
-
setRateLimits(config) {
|
|
26
|
-
if (this.limiter)
|
|
27
|
-
throw new Error("Lambder: enableApiRateLimits() was already called.");
|
|
28
|
-
for (const [name, policy] of Object.entries(config.policies)) {
|
|
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
|
-
}
|
|
33
|
-
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
34
|
-
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
this.limiter = config.limiter;
|
|
38
|
-
this.rateLimitPolicies = { ...config.policies };
|
|
39
|
-
}
|
|
40
|
-
addGuards(guards) {
|
|
41
|
-
for (const [name, guardDef] of Object.entries(guards)) {
|
|
42
|
-
if (this.guards[name])
|
|
43
|
-
throw new Error(`Lambder: guard "${name}" is already defined.`);
|
|
44
|
-
if (typeof guardDef?.handler !== "function")
|
|
45
|
-
throw new Error(`Lambder: guard "${name}" has no handler function.`);
|
|
46
|
-
this.guards[name] = guardDef;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
setIdempotency(config) {
|
|
50
|
-
if (this.idempotencyStore)
|
|
51
|
-
throw new Error("Lambder: enableApiIdempotency() was already called.");
|
|
52
|
-
this.idempotencyStore = config.store;
|
|
53
|
-
this.idempotencyDefaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
|
|
54
|
-
this.idempotencyFailOpen = config.failOpen ?? true;
|
|
55
|
-
}
|
|
56
|
-
/** Startup validation of one API registration's declarative options. */
|
|
57
|
-
assertRegistration(apiName, mode, options) {
|
|
58
|
-
for (const name of toList(options.rateLimit)) {
|
|
59
|
-
const policy = this.rateLimitPolicies[name];
|
|
60
|
-
if (!policy) {
|
|
61
|
-
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it via enableApiRateLimits() before registering the API.`);
|
|
62
|
-
}
|
|
63
|
-
if (policy.per === "session" && mode !== "session") {
|
|
64
|
-
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
for (const name of toList(options.guards)) {
|
|
68
|
-
if (!this.guards[name]) {
|
|
69
|
-
throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Define it via defineApiGuards() before registering the API.`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
if (options.idempotency !== undefined && !this.idempotencyStore) {
|
|
73
|
-
throw new Error(`Lambder: API "${apiName}" declares idempotency but enableApiIdempotency() was not called first.`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|
|
77
|
-
async runPreflight(apiName, ctx, resolver, options) {
|
|
78
|
-
for (const name of toList(options.rateLimit)) {
|
|
79
|
-
const policy = this.rateLimitPolicies[name];
|
|
80
|
-
if (!policy || !this.limiter)
|
|
81
|
-
throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
|
|
82
|
-
const key = await this.resolveRateLimitKey(ctx, resolver, policy.per);
|
|
83
|
-
const limited = await this.limiter.isRateLimited(`api|${apiName}|${name}|${key}`, policy);
|
|
84
|
-
if (limited) {
|
|
85
|
-
throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
|
|
86
|
-
errorMessage: policy.errorMessage ?? "Too many requests. Please try again later.",
|
|
87
|
-
statusCode: 429,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
for (const name of toList(options.guards)) {
|
|
92
|
-
const guardDef = this.guards[name];
|
|
93
|
-
if (!guardDef)
|
|
94
|
-
throw new Error(`Lambder: guard "${name}" is not configured.`);
|
|
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 });
|
|
111
|
-
}
|
|
112
|
-
return parsed.data;
|
|
113
|
-
}
|
|
114
|
-
async resolveRateLimitKey(ctx, resolver, per) {
|
|
115
|
-
if (per === "ip")
|
|
116
|
-
return `ip:${ctx.ip}`;
|
|
117
|
-
if (per === "session") {
|
|
118
|
-
const sessionKey = ctx.session?.sessionKey;
|
|
119
|
-
if (!sessionKey)
|
|
120
|
-
throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
|
|
121
|
-
return `session:${sessionKey}`;
|
|
122
|
-
}
|
|
123
|
-
const payload = this.parseSlice(per.input, ctx, resolver);
|
|
124
|
-
return `custom:${await per.handler(ctx, payload)}`;
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Idempotency wrapper around validation-passed handler execution. Without
|
|
128
|
-
* a client idempotencyKey the handler just runs; with one, the scope
|
|
129
|
-
* (identity + api + key) is claimed atomically: duplicates of an
|
|
130
|
-
* in-flight original refuse with 409, replays of a completed one return
|
|
131
|
-
* the stored response verbatim, and a crashed original releases its claim
|
|
132
|
-
* so a retry actually retries.
|
|
133
|
-
*/
|
|
134
|
-
async withIdempotency(apiName, ctx, config, exec) {
|
|
135
|
-
const store = this.idempotencyStore;
|
|
136
|
-
const rawKey = ctx.post?.idempotencyKey;
|
|
137
|
-
if (!store || rawKey === undefined || rawKey === null)
|
|
138
|
-
return await exec();
|
|
139
|
-
if (typeof rawKey !== "string" || rawKey.length < 1 || rawKey.length > 200) {
|
|
140
|
-
throw new LambderApiError("Invalid idempotency key.", { statusCode: 400 });
|
|
141
|
-
}
|
|
142
|
-
const ttlSeconds = (typeof config === "object" ? config.ttlSeconds : undefined) ?? this.idempotencyDefaultTtlSeconds;
|
|
143
|
-
// Scoped per identity so clients cannot collide with or poison each other's keys.
|
|
144
|
-
const sessionKey = ctx.session?.sessionKey;
|
|
145
|
-
const scopeKey = `${sessionKey ? `s:${sessionKey}` : `ip:${ctx.ip}`}|${apiName}|${rawKey}`;
|
|
146
|
-
let begun;
|
|
147
|
-
try {
|
|
148
|
-
begun = await store.begin(scopeKey, { pendingTtlSeconds: IDEMPOTENCY_PENDING_TTL_SECONDS });
|
|
149
|
-
}
|
|
150
|
-
catch (err) {
|
|
151
|
-
if (this.idempotencyFailOpen)
|
|
152
|
-
return await exec();
|
|
153
|
-
throw err;
|
|
154
|
-
}
|
|
155
|
-
if (begun.state === "pending") {
|
|
156
|
-
throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
157
|
-
statusCode: 409,
|
|
158
|
-
errorMessage: "This request is already being processed.",
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
if (begun.state === "done") {
|
|
162
|
-
return new LambderResponse({
|
|
163
|
-
statusCode: begun.statusCode,
|
|
164
|
-
headers: begun.contentType ? { "Content-Type": begun.contentType } : {},
|
|
165
|
-
body: begun.body,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
const ownerToken = begun.ownerToken;
|
|
169
|
-
// Store the response for replays when it qualifies, release the claim
|
|
170
|
-
// otherwise. Settle failures only surface when failing closed.
|
|
171
|
-
const settleClaim = async (response) => {
|
|
172
|
-
const cacheable = response.statusCode < 500
|
|
173
|
-
&& typeof response.body === "string"
|
|
174
|
-
&& !response.isBodyBase64
|
|
175
|
-
&& response.body.length <= IDEMPOTENCY_MAX_STORED_BODY_BYTES;
|
|
176
|
-
try {
|
|
177
|
-
if (cacheable) {
|
|
178
|
-
await store.complete(scopeKey, ownerToken, {
|
|
179
|
-
statusCode: response.statusCode,
|
|
180
|
-
contentType: response.getHeader("Content-Type")?.[0] ?? null,
|
|
181
|
-
body: response.body,
|
|
182
|
-
ttlSeconds,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
await store.abandon(scopeKey, ownerToken);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
catch (storeErr) {
|
|
190
|
-
if (!this.idempotencyFailOpen)
|
|
191
|
-
throw storeErr;
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
try {
|
|
195
|
-
const response = await exec();
|
|
196
|
-
await settleClaim(response);
|
|
197
|
-
return response;
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
// A thrown LambderResponse IS the response (res.die.*, throw
|
|
201
|
-
// res.api(...)): settle the claim like a returned one so its side
|
|
202
|
-
// effect replays, then rethrow so the pipeline emits it.
|
|
203
|
-
if (err instanceof LambderResponse) {
|
|
204
|
-
await settleClaim(err);
|
|
205
|
-
throw err;
|
|
206
|
-
}
|
|
207
|
-
// A real crash (or a refusal like LambderApiError) releases the
|
|
208
|
-
// claim so a retry actually retries.
|
|
209
|
-
try {
|
|
210
|
-
await store.abandon(scopeKey, ownerToken);
|
|
211
|
-
}
|
|
212
|
-
catch (cleanupErr) {
|
|
213
|
-
if (!this.idempotencyFailOpen)
|
|
214
|
-
throw cleanupErr;
|
|
215
|
-
}
|
|
216
|
-
throw err;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|