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.
Files changed (63) hide show
  1. package/Readme.md +92 -24
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +50 -14
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +29 -17
  4. package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
  5. package/dist/client.d.ts +16 -0
  6. package/dist/client.js +17 -0
  7. package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -19
  8. package/dist/{Lambder.js → core/Lambder.js} +28 -12
  9. package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
  10. package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
  11. package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
  12. package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
  13. package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
  14. package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
  15. package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
  16. package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
  17. package/dist/index.d.ts +36 -35
  18. package/dist/index.js +21 -20
  19. package/dist/policies/LambderApiGuards.d.ts +221 -0
  20. package/dist/policies/LambderApiGuards.js +79 -0
  21. package/dist/policies/LambderApiIdempotency.d.ts +58 -0
  22. package/dist/policies/LambderApiIdempotency.js +215 -0
  23. package/dist/policies/LambderApiPolicies.d.ts +40 -0
  24. package/dist/policies/LambderApiPolicies.js +47 -0
  25. package/dist/policies/LambderApiRateLimits.d.ts +90 -0
  26. package/dist/policies/LambderApiRateLimits.js +77 -0
  27. package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
  28. package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
  29. package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
  30. package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
  31. package/dist/shared/LambderApiContract.d.ts +41 -0
  32. package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
  33. package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
  34. package/dist/stores/LambderDdbCompression.d.ts +3 -0
  35. package/dist/stores/LambderDdbCompression.js +39 -0
  36. package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
  37. package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
  38. package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
  39. package/dist/testing.d.ts +9 -0
  40. package/dist/testing.js +8 -0
  41. package/package.json +19 -1
  42. package/dist/LambderApiContract.d.ts +0 -21
  43. package/dist/LambderApiPolicies.d.ts +0 -177
  44. package/dist/LambderApiPolicies.js +0 -219
  45. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  46. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  47. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  48. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  49. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  50. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  51. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  52. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  53. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  54. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  55. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  56. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  57. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  58. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  59. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  60. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  61. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  62. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  63. /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
@@ -0,0 +1,58 @@
1
+ import type { LambderRenderContext } from "../core/LambderContext.js";
2
+ import type { LambderDdbIdempotency } from "../stores/LambderDdbIdempotency.js";
3
+ import { LambderResponse } from "../core/LambderResponse.js";
4
+ export type LambderApiIdempotencyConfig = {
5
+ /** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
6
+ store: LambderDdbIdempotency;
7
+ /** Seconds a stored response replays for. Default: 86400 (24h). Per-API override: idempotency: { ttlSeconds }. */
8
+ defaultTtlSeconds?: number;
9
+ /** Skip idempotency (execute normally) when DynamoDB errors, instead of failing the request. Default: true. */
10
+ failOpen?: boolean;
11
+ };
12
+ /**
13
+ * Runtime side of the idempotency subsystem: claims a per-operation scope
14
+ * around handler execution, replays stored responses, and settles claims.
15
+ * Composed into LambderApiPolicyEngine.
16
+ */
17
+ export declare class LambderApiIdempotencyEngine {
18
+ private store;
19
+ private defaultTtlSeconds;
20
+ private failOpen;
21
+ configure(config: LambderApiIdempotencyConfig): void;
22
+ /** True once enableApiIdempotency() ran; registration asserts check it. */
23
+ get isConfigured(): boolean;
24
+ /**
25
+ * The request's idempotencyKey: null when absent, the key when valid, a
26
+ * 400 refusal when malformed. The minimum length matters for security:
27
+ * see IDEMPOTENCY_MIN_KEY_LENGTH.
28
+ */
29
+ private readKey;
30
+ /**
31
+ * The record's scope. Session APIs scope per session, so even a leaked
32
+ * key cannot cross users. Public APIs scope by the key alone: the key is
33
+ * required to be long (and documented to be random), and identity proxies
34
+ * like the client IP are deliberately NOT part of the scope, because the
35
+ * retry idempotency exists for (a timeout followed by a network change)
36
+ * frequently arrives from a different IP.
37
+ */
38
+ private scopeOf;
39
+ /**
40
+ * Replay fast path, run BEFORE rate limits and guards: a completed record
41
+ * answers with its stored response so a legitimate retry neither burns
42
+ * rate-limit quota nor re-runs guards (the original already passed them,
43
+ * and no handler executes). Misses fall through to the normal pipeline;
44
+ * store errors follow the failOpen setting.
45
+ */
46
+ findReplay(apiName: string, ctx: LambderRenderContext): Promise<LambderResponse | null>;
47
+ /**
48
+ * Idempotency wrapper around validation-passed handler execution. Without
49
+ * a client idempotencyKey the handler just runs; with one, the scope
50
+ * (identity + api + key) is claimed atomically: duplicates of an
51
+ * in-flight original refuse with 409, replays of a completed one return
52
+ * the stored response verbatim, and a crashed original releases its claim
53
+ * so a retry actually retries.
54
+ */
55
+ withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
56
+ ttlSeconds?: number;
57
+ }, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
58
+ }
@@ -0,0 +1,215 @@
1
+ import { LambderApiError } from "../shared/LambderApiError.js";
2
+ import { LambderResponse, normalizeHeaders } from "../core/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
+ /**
6
+ * Keys must be unguessable: without a session, the replay scope is the key
7
+ * itself, so a guessable key would let one client read another's stored
8
+ * response. LambderCaller.createIdempotencyKey() returns 36 chars.
9
+ */
10
+ const IDEMPOTENCY_MIN_KEY_LENGTH = 16;
11
+ const IDEMPOTENCY_MAX_KEY_LENGTH = 200;
12
+ /**
13
+ * Runtime side of the idempotency subsystem: claims a per-operation scope
14
+ * around handler execution, replays stored responses, and settles claims.
15
+ * Composed into LambderApiPolicyEngine.
16
+ */
17
+ export class LambderApiIdempotencyEngine {
18
+ store = null;
19
+ defaultTtlSeconds = 24 * 3600;
20
+ failOpen = true;
21
+ configure(config) {
22
+ if (this.store)
23
+ throw new Error("Lambder: enableApiIdempotency() was already called.");
24
+ this.store = config.store;
25
+ this.defaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
26
+ this.failOpen = config.failOpen ?? true;
27
+ }
28
+ /** True once enableApiIdempotency() ran; registration asserts check it. */
29
+ get isConfigured() { return this.store !== null; }
30
+ /**
31
+ * The request's idempotencyKey: null when absent, the key when valid, a
32
+ * 400 refusal when malformed. The minimum length matters for security:
33
+ * see IDEMPOTENCY_MIN_KEY_LENGTH.
34
+ */
35
+ readKey(ctx) {
36
+ const rawKey = ctx.post?.idempotencyKey;
37
+ if (rawKey === undefined || rawKey === null)
38
+ return null;
39
+ if (typeof rawKey !== "string" || rawKey.length < IDEMPOTENCY_MIN_KEY_LENGTH || rawKey.length > IDEMPOTENCY_MAX_KEY_LENGTH) {
40
+ throw new LambderApiError(`Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`, { statusCode: 400 });
41
+ }
42
+ return rawKey;
43
+ }
44
+ /**
45
+ * The record's scope. Session APIs scope per session, so even a leaked
46
+ * key cannot cross users. Public APIs scope by the key alone: the key is
47
+ * required to be long (and documented to be random), and identity proxies
48
+ * like the client IP are deliberately NOT part of the scope, because the
49
+ * retry idempotency exists for (a timeout followed by a network change)
50
+ * frequently arrives from a different IP.
51
+ */
52
+ scopeOf(apiName, ctx, key) {
53
+ const sessionKey = ctx.session?.sessionKey;
54
+ return `${sessionKey ? `s:${sessionKey}` : "k"}|${apiName}|${key}`;
55
+ }
56
+ /**
57
+ * Replay fast path, run BEFORE rate limits and guards: a completed record
58
+ * answers with its stored response so a legitimate retry neither burns
59
+ * rate-limit quota nor re-runs guards (the original already passed them,
60
+ * and no handler executes). Misses fall through to the normal pipeline;
61
+ * store errors follow the failOpen setting.
62
+ */
63
+ async findReplay(apiName, ctx) {
64
+ const store = this.store;
65
+ if (!store)
66
+ return null;
67
+ const key = this.readKey(ctx);
68
+ if (key === null)
69
+ return null;
70
+ try {
71
+ const done = await store.peek(this.scopeOf(apiName, ctx, key));
72
+ if (!done)
73
+ return null;
74
+ return new LambderResponse({
75
+ statusCode: done.statusCode,
76
+ headers: done.headers,
77
+ body: done.body,
78
+ });
79
+ }
80
+ catch (err) {
81
+ if (this.failOpen)
82
+ return null;
83
+ throw err;
84
+ }
85
+ }
86
+ /**
87
+ * Idempotency wrapper around validation-passed handler execution. Without
88
+ * a client idempotencyKey the handler just runs; with one, the scope
89
+ * (identity + api + key) is claimed atomically: duplicates of an
90
+ * in-flight original refuse with 409, replays of a completed one return
91
+ * the stored response verbatim, and a crashed original releases its claim
92
+ * so a retry actually retries.
93
+ */
94
+ async withIdempotency(apiName, ctx, config, exec) {
95
+ const store = this.store;
96
+ if (!store)
97
+ return await exec();
98
+ const rawKey = this.readKey(ctx);
99
+ if (rawKey === null)
100
+ return await exec();
101
+ const ttlSeconds = (typeof config === "object" ? config.ttlSeconds : undefined) ?? this.defaultTtlSeconds;
102
+ const scopeKey = this.scopeOf(apiName, ctx, rawKey);
103
+ let begun;
104
+ try {
105
+ begun = await store.begin(scopeKey, { pendingTtlSeconds: IDEMPOTENCY_PENDING_TTL_SECONDS });
106
+ }
107
+ catch (err) {
108
+ if (this.failOpen)
109
+ return await exec();
110
+ throw err;
111
+ }
112
+ if (begun.state === "pending") {
113
+ throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
114
+ statusCode: 409,
115
+ errorMessage: "This request is already being processed.",
116
+ });
117
+ }
118
+ if (begun.state === "done") {
119
+ return new LambderResponse({
120
+ statusCode: begun.statusCode,
121
+ headers: begun.headers,
122
+ body: begun.body,
123
+ });
124
+ }
125
+ const ownerToken = begun.ownerToken;
126
+ // Headers pushed via res.setHeader/res.addHeader (and session cookie
127
+ // writes) land on ctx accumulators and are applied AFTER this wrapper
128
+ // returns, so snapshot the baseline: entries added during exec belong
129
+ // to this response and must be stored with it, and a Set-Cookie among
130
+ // them makes the response uncacheable (replaying another request's
131
+ // cookies, e.g. session tokens, would be wrong).
132
+ const setBaseline = ctx._otherInternal.setHeaderFnAccumulator.length;
133
+ const addBaseline = ctx._otherInternal.addHeaderFnAccumulator.length;
134
+ // Store the response for replays when it qualifies, release the claim
135
+ // otherwise. Settle failures only surface when failing closed.
136
+ const settleClaim = async (response) => {
137
+ const execSetHeaders = ctx._otherInternal.setHeaderFnAccumulator.slice(setBaseline);
138
+ const execAddHeaders = ctx._otherInternal.addHeaderFnAccumulator.slice(addBaseline);
139
+ const setsCookie = response.getHeader("Set-Cookie") !== undefined
140
+ || [...execSetHeaders, ...execAddHeaders].some((h) => h.key.toLowerCase() === "set-cookie");
141
+ // The store owns the size decision: bodies are Brotli-compressed
142
+ // there, and only ones exceeding the item budget even compressed
143
+ // come back as "too-large".
144
+ const cacheable = response.statusCode < 500
145
+ && !setsCookie
146
+ && typeof response.body === "string"
147
+ && !response.isBodyBase64;
148
+ try {
149
+ if (cacheable) {
150
+ // Merge during-exec accumulator headers into the stored
151
+ // copy (same replace/append semantics the render pipeline
152
+ // applies), so a replay reproduces the full header set.
153
+ const storedResponse = new LambderResponse({
154
+ statusCode: response.statusCode,
155
+ headers: normalizeHeaders(response.headers),
156
+ body: response.body,
157
+ });
158
+ for (const header of execSetHeaders)
159
+ storedResponse.setHeader(header.key, header.value);
160
+ for (const header of execAddHeaders)
161
+ storedResponse.addHeader(header.key, header.value);
162
+ const completion = await store.complete(scopeKey, ownerToken, {
163
+ statusCode: response.statusCode,
164
+ headers: storedResponse.headers,
165
+ body: response.body,
166
+ ttlSeconds,
167
+ });
168
+ if (completion !== "too-large")
169
+ return;
170
+ // Too large to replay: fall through to release the claim
171
+ // so retries re-execute instead of 409ing.
172
+ }
173
+ await store.abandon(scopeKey, ownerToken);
174
+ }
175
+ catch (storeErr) {
176
+ // A failed complete() must not leave the pending claim
177
+ // dangling (it would 409 retries until the pending TTL).
178
+ try {
179
+ await store.abandon(scopeKey, ownerToken);
180
+ }
181
+ catch { /* claim expires on its own */ }
182
+ if (!this.failOpen)
183
+ throw storeErr;
184
+ }
185
+ };
186
+ try {
187
+ const response = await exec();
188
+ await settleClaim(response);
189
+ return response;
190
+ }
191
+ catch (err) {
192
+ // A thrown LambderResponse IS the response (res.die.*, throw
193
+ // res.api(...)): settle the claim like a returned one so its side
194
+ // effect replays, then rethrow so the pipeline emits it.
195
+ if (err instanceof LambderResponse) {
196
+ await settleClaim(err);
197
+ throw err;
198
+ }
199
+ // A real crash (or a thrown refusal like LambderApiError /
200
+ // refuse()) releases the claim so a retry actually retries. This
201
+ // is the deliberate rule: RESPONSES are stored and replayed,
202
+ // refusals delivered as returned envelopes included; EXCEPTIONS
203
+ // are not, so a thrown refusal re-executes on retry and the
204
+ // handler decides afresh. Pick the idiom accordingly.
205
+ try {
206
+ await store.abandon(scopeKey, ownerToken);
207
+ }
208
+ catch (cleanupErr) {
209
+ if (!this.failOpen)
210
+ throw cleanupErr;
211
+ }
212
+ throw err;
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,40 @@
1
+ import type { LambderRenderContext } from "../core/LambderContext.js";
2
+ import type LambderResolver from "../core/LambderResolver.js";
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";
6
+ import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
7
+ /** The declarative options one API registration may carry. */
8
+ type LambderApiPolicyOptions = {
9
+ rateLimit?: string | readonly string[];
10
+ guards?: LambderGuardsOptionValue;
11
+ idempotency?: unknown;
12
+ };
13
+ /**
14
+ * Runtime side of the declarative API options: composes the three policy
15
+ * subsystems (rate limits in ./LambderApiRateLimits.ts, guards in
16
+ * ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
17
+ * registrations against them at startup, and executes them around handlers
18
+ * at request time. Internal to Lambder; apps interact through
19
+ * enableApiRateLimits(), enableApiIdempotency(), defineApiGuards() and the
20
+ * per-API options.
21
+ */
22
+ export declare class LambderApiPolicyEngine {
23
+ private rateLimits;
24
+ private guards;
25
+ private idempotency;
26
+ setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
27
+ addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
28
+ setIdempotency(config: LambderApiIdempotencyConfig): void;
29
+ /** Startup validation of one API registration's declarative options. */
30
+ assertRegistration(apiName: string, mode: "public" | "session", options: LambderApiPolicyOptions): void;
31
+ /** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
32
+ runPreflight(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, options: LambderApiPolicyOptions): Promise<void>;
33
+ /** Idempotency replay fast path, run before the preflight: see LambderApiIdempotencyEngine.findReplay. */
34
+ findReplay(apiName: string, ctx: LambderRenderContext): Promise<LambderResponse | null>;
35
+ /** Idempotency claim/replay wrapper around handler execution: see LambderApiIdempotencyEngine.withIdempotency. */
36
+ withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
37
+ ttlSeconds?: number;
38
+ }, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
39
+ }
40
+ export {};
@@ -0,0 +1,47 @@
1
+ import { LambderApiGuardsEngine } from "./LambderApiGuards.js";
2
+ import { LambderApiRateLimitsEngine } from "./LambderApiRateLimits.js";
3
+ import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
4
+ /**
5
+ * Runtime side of the declarative API options: composes the three policy
6
+ * subsystems (rate limits in ./LambderApiRateLimits.ts, guards in
7
+ * ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
8
+ * registrations against them at startup, and executes them around handlers
9
+ * at request time. Internal to Lambder; apps interact through
10
+ * enableApiRateLimits(), enableApiIdempotency(), defineApiGuards() and the
11
+ * per-API options.
12
+ */
13
+ export class LambderApiPolicyEngine {
14
+ rateLimits = new LambderApiRateLimitsEngine();
15
+ guards = new LambderApiGuardsEngine();
16
+ idempotency = new LambderApiIdempotencyEngine();
17
+ setRateLimits(config) {
18
+ this.rateLimits.configure(config);
19
+ }
20
+ addGuards(guards) {
21
+ this.guards.addGuards(guards);
22
+ }
23
+ setIdempotency(config) {
24
+ this.idempotency.configure(config);
25
+ }
26
+ /** Startup validation of one API registration's declarative options. */
27
+ assertRegistration(apiName, mode, options) {
28
+ this.rateLimits.assertRegistration(apiName, mode, options.rateLimit);
29
+ this.guards.assertRegistration(apiName, mode, options.guards);
30
+ if (options.idempotency !== undefined && !this.idempotency.isConfigured) {
31
+ throw new Error(`Lambder: API "${apiName}" declares idempotency but enableApiIdempotency() was not called first.`);
32
+ }
33
+ }
34
+ /** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
35
+ async runPreflight(apiName, ctx, resolver, options) {
36
+ await this.rateLimits.run(apiName, ctx, resolver, options.rateLimit);
37
+ await this.guards.run(ctx, resolver, options.guards);
38
+ }
39
+ /** Idempotency replay fast path, run before the preflight: see LambderApiIdempotencyEngine.findReplay. */
40
+ async findReplay(apiName, ctx) {
41
+ return await this.idempotency.findReplay(apiName, ctx);
42
+ }
43
+ /** Idempotency claim/replay wrapper around handler execution: see LambderApiIdempotencyEngine.withIdempotency. */
44
+ async withIdempotency(apiName, ctx, config, exec) {
45
+ return await this.idempotency.withIdempotency(apiName, ctx, config, exec);
46
+ }
47
+ }
@@ -0,0 +1,90 @@
1
+ import type { z } from "zod";
2
+ import type { LambderRenderContext } from "../core/LambderContext.js";
3
+ import type LambderResolver from "../core/LambderResolver.js";
4
+ import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
5
+ /**
6
+ * A custom rate-limit key. `apiInput` names the fields of the API's OWN
7
+ * payload the key derives from: the slice is validated against the raw
8
+ * payload before `handler` runs (failures answer the standard 422 validation
9
+ * shape) and the handler receives it typed. Referencing the policy from an
10
+ * API whose input schema does not carry those fields is a compile error, so
11
+ * the API's schema stays the single owner of the field. Build with
12
+ * lambderRateLimitKey() so the handler's payload type follows `apiInput`.
13
+ */
14
+ export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
15
+ apiInput: TInput;
16
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
17
+ } | {
18
+ apiInput?: undefined;
19
+ handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
20
+ };
21
+ /**
22
+ * Builder that ties the handler's payload type to the `apiInput` schema
23
+ * inside one literal. Returns the exact union member so type extraction can
24
+ * see the schema.
25
+ */
26
+ export declare function lambderRateLimitKey<TInput extends z.ZodTypeAny>(key: {
27
+ apiInput: TInput;
28
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
29
+ }): {
30
+ apiInput: TInput;
31
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
32
+ };
33
+ export declare function lambderRateLimitKey(key: {
34
+ handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
35
+ }): {
36
+ apiInput?: undefined;
37
+ handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
38
+ };
39
+ /** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
40
+ export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
41
+ /** A named rate-limit policy: fixed windows plus the key one counter tracks. */
42
+ export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
43
+ per: LambderRateLimitPer;
44
+ /**
45
+ * What one counter spans. "api" (default): each API referencing the
46
+ * policy gets its own counter, so the windows are a per-API budget.
47
+ * "policy": every API referencing the policy shares one counter, so the
48
+ * windows are one combined budget (e.g. one per-email allowance across
49
+ * send, register, and reset endpoints).
50
+ */
51
+ scope?: "api" | "policy";
52
+ /** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
53
+ errorMessage?: any;
54
+ };
55
+ export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
56
+ /** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
57
+ limiter: LambderDdbRateLimiter;
58
+ /** Named policies referenced (typed) from addApi/addSessionApi. */
59
+ policies: TPolicies;
60
+ };
61
+ /**
62
+ * Policy names an API may reference: session-keyed policies only on session
63
+ * APIs, and apiInput-keyed policies only when the API's payload carries the
64
+ * key's fields.
65
+ */
66
+ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession extends boolean> = {
67
+ [K in keyof TPolicies]: TPolicies[K] extends {
68
+ per: "session";
69
+ } ? (TIncludeSession extends true ? K : never) : TPolicies[K] extends {
70
+ per: {
71
+ apiInput: infer S extends z.ZodTypeAny;
72
+ };
73
+ } ? (TPayload extends z.output<S> ? K : never) : K;
74
+ }[keyof TPolicies] & string;
75
+ /**
76
+ * Runtime side of the rate-limit subsystem: holds the limiter and its named
77
+ * policies, asserts API registrations against them at startup, and checks an
78
+ * API's declared policies during preflight. Composed into
79
+ * LambderApiPolicyEngine.
80
+ */
81
+ export declare class LambderApiRateLimitsEngine {
82
+ private limiter;
83
+ private policies;
84
+ configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
85
+ /** Startup validation of one API registration's rateLimit option. */
86
+ assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?: string | readonly string[]): void;
87
+ /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
88
+ run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: string | readonly string[]): Promise<void>;
89
+ private resolveKey;
90
+ }
@@ -0,0 +1,77 @@
1
+ import { LambderApiError } from "../shared/LambderApiError.js";
2
+ import { parsePreflightSlice } from "./LambderApiGuards.js";
3
+ const RATE_LIMIT_WINDOW_KEYS = ["perMin", "per10Min", "perHour", "perDay", "perWeek", "perMonth"];
4
+ export function lambderRateLimitKey(key) { return key; }
5
+ const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
6
+ /**
7
+ * Runtime side of the rate-limit subsystem: holds the limiter and its named
8
+ * policies, asserts API registrations against them at startup, and checks an
9
+ * API's declared policies during preflight. Composed into
10
+ * LambderApiPolicyEngine.
11
+ */
12
+ export class LambderApiRateLimitsEngine {
13
+ limiter = null;
14
+ policies = {};
15
+ configure(config) {
16
+ if (this.limiter)
17
+ throw new Error("Lambder: enableApiRateLimits() was already called.");
18
+ for (const [name, policy] of Object.entries(config.policies)) {
19
+ const per = policy.per;
20
+ if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
21
+ throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { apiInput?, handler } key.`);
22
+ }
23
+ if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
24
+ throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
25
+ }
26
+ }
27
+ this.limiter = config.limiter;
28
+ this.policies = { ...config.policies };
29
+ }
30
+ /** Startup validation of one API registration's rateLimit option. */
31
+ assertRegistration(apiName, mode, rateLimitOption) {
32
+ for (const name of toList(rateLimitOption)) {
33
+ const policy = this.policies[name];
34
+ if (!policy) {
35
+ throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it via enableApiRateLimits() before registering the API.`);
36
+ }
37
+ if (policy.per === "session" && mode !== "session") {
38
+ throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
39
+ }
40
+ }
41
+ }
42
+ /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
43
+ async run(apiName, ctx, resolver, rateLimitOption) {
44
+ for (const name of toList(rateLimitOption)) {
45
+ const policy = this.policies[name];
46
+ if (!policy || !this.limiter)
47
+ throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
48
+ const key = await this.resolveKey(ctx, resolver, policy.per);
49
+ // scope "policy" shares one counter across every API referencing
50
+ // the policy; the default gives each API its own budget.
51
+ const trackerKey = policy.scope === "policy"
52
+ ? `policy|${name}|${key}`
53
+ : `api|${apiName}|${name}|${key}`;
54
+ const limited = await this.limiter.isRateLimited(trackerKey, policy);
55
+ if (limited) {
56
+ throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
57
+ errorMessage: policy.errorMessage ?? "Too many requests. Please try again later.",
58
+ statusCode: 429,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ async resolveKey(ctx, resolver, per) {
64
+ if (per === "ip")
65
+ return `ip:${ctx.ip}`;
66
+ if (per === "session") {
67
+ const sessionKey = ctx.session?.sessionKey;
68
+ if (!sessionKey)
69
+ throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
70
+ return `session:${sessionKey}`;
71
+ }
72
+ const payload = per.apiInput
73
+ ? parsePreflightSlice(per.apiInput, ctx.post?.payload, resolver)
74
+ : undefined;
75
+ return `custom:${await per.handler(ctx, payload)}`;
76
+ }
77
+ }
@@ -1,4 +1,4 @@
1
- import { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
1
+ import { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
2
2
  import type LambderSessionManager from "./LambderSessionManager.js";
3
3
  import { type LambderSessionContext } from "./LambderSessionManager.js";
4
4
  export type LambderSessionCookieOptions = {
@@ -26,6 +26,7 @@ export default class LambderSessionController<TSessionData = any> {
26
26
  ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
27
27
  });
28
28
  private buildCookie;
29
+ /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
29
30
  private setSessionCookies;
30
31
  private clearSessionCookies;
31
32
  private areRequestSessionTokensValid;
@@ -1,4 +1,4 @@
1
- import { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
1
+ import { LambderSessionDataRefreshError, LambderSessionReadError } from "./LambderSessionManager.js";
2
2
  export default class LambderSessionController {
3
3
  lambderSessionManager;
4
4
  sessionTokenCookieKey;
@@ -30,9 +30,11 @@ export default class LambderSessionController {
30
30
  return parts.join("; ");
31
31
  }
32
32
  ;
33
- setSessionCookies(session) {
34
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, session.sessionToken, session.expiresAt * 1000, true) });
35
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, session.csrfToken, session.expiresAt * 1000, false) });
33
+ /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
34
+ setSessionCookies(created) {
35
+ const expiresAtMs = created.session.expiresAt * 1000;
36
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, created.sessionToken, expiresAtMs, true) });
37
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, created.csrfToken, expiresAtMs, false) });
36
38
  }
37
39
  ;
38
40
  clearSessionCookies() {
@@ -55,18 +57,18 @@ export default class LambderSessionController {
55
57
  }
56
58
  ;
57
59
  async createSession(sessionKey, data, ttlInSeconds) {
58
- const session = await this.lambderSessionManager.createSession(sessionKey, data, ttlInSeconds);
59
- this.setSessionCookies(session);
60
- this.ctx.session = session;
60
+ const created = await this.lambderSessionManager.createSession(sessionKey, data, ttlInSeconds);
61
+ this.setSessionCookies(created);
62
+ this.ctx.session = created.session;
61
63
  return this.ctx.session;
62
64
  }
63
65
  ;
64
66
  async regenerateSession() {
65
67
  if (!this.ctx.session)
66
68
  throw new Error("Session not found.");
67
- const newSession = await this.lambderSessionManager.regenerateSession(this.ctx.session);
68
- this.setSessionCookies(newSession);
69
- this.ctx.session = newSession;
69
+ const created = await this.lambderSessionManager.regenerateSession(this.ctx.session);
70
+ this.setSessionCookies(created);
71
+ this.ctx.session = created.session;
70
72
  return this.ctx.session;
71
73
  }
72
74
  ;
@@ -92,9 +94,12 @@ export default class LambderSessionController {
92
94
  }
93
95
  catch (err) {
94
96
  // Missing or invalid sessions become null, but a failing
95
- // dataRefresh callback must not masquerade as a logout.
97
+ // dataRefresh callback or a DynamoDB read failure must not
98
+ // masquerade as a logout.
96
99
  if (err instanceof LambderSessionDataRefreshError)
97
100
  throw err;
101
+ if (err instanceof LambderSessionReadError)
102
+ throw err;
98
103
  return null;
99
104
  }
100
105
  }