lambder 6.0.1 → 7.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/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { LambderApiError, LAMBDER_REFUSAL_CODES } 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: idempotency was already configured.");
|
|
24
|
-
this.store = config.store;
|
|
25
|
-
this.defaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
|
|
26
|
-
this.failOpen = config.failOpen ?? true;
|
|
27
|
-
}
|
|
28
|
-
/** True once the idempotency option was configured; 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
|
-
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
|
-
});
|
|
45
|
-
}
|
|
46
|
-
return rawKey;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* The record's scope. Session APIs scope per session, so even a leaked
|
|
50
|
-
* key cannot cross users. Public APIs scope by the key alone: the key is
|
|
51
|
-
* required to be long (and documented to be random), and identity proxies
|
|
52
|
-
* like the client IP are deliberately NOT part of the scope, because the
|
|
53
|
-
* retry idempotency exists for (a timeout followed by a network change)
|
|
54
|
-
* frequently arrives from a different IP.
|
|
55
|
-
*/
|
|
56
|
-
scopeOf(apiName, ctx, key) {
|
|
57
|
-
const sessionKey = ctx.session?.sessionKey;
|
|
58
|
-
return `${sessionKey ? `s:${sessionKey}` : "k"}|${apiName}|${key}`;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Replay fast path, run BEFORE rate limits and guards: a completed record
|
|
62
|
-
* answers with its stored response so a legitimate retry neither burns
|
|
63
|
-
* rate-limit quota nor re-runs guards (the original already passed them,
|
|
64
|
-
* and no handler executes). Misses fall through to the normal pipeline;
|
|
65
|
-
* store errors follow the failOpen setting.
|
|
66
|
-
*/
|
|
67
|
-
async findReplay(apiName, ctx) {
|
|
68
|
-
const store = this.store;
|
|
69
|
-
if (!store)
|
|
70
|
-
return null;
|
|
71
|
-
const key = this.readKey(ctx);
|
|
72
|
-
if (key === null)
|
|
73
|
-
return null;
|
|
74
|
-
try {
|
|
75
|
-
const done = await store.peek(this.scopeOf(apiName, ctx, key));
|
|
76
|
-
if (!done)
|
|
77
|
-
return null;
|
|
78
|
-
return new LambderResponse({
|
|
79
|
-
statusCode: done.statusCode,
|
|
80
|
-
headers: done.headers,
|
|
81
|
-
body: done.body,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
if (this.failOpen)
|
|
86
|
-
return null;
|
|
87
|
-
throw err;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Idempotency wrapper around validation-passed handler execution. Without
|
|
92
|
-
* a client idempotencyKey the handler just runs; with one, the scope
|
|
93
|
-
* (identity + api + key) is claimed atomically: duplicates of an
|
|
94
|
-
* in-flight original refuse with 409, replays of a completed one return
|
|
95
|
-
* the stored response verbatim, and a crashed original releases its claim
|
|
96
|
-
* so a retry actually retries.
|
|
97
|
-
*/
|
|
98
|
-
async withIdempotency(apiName, ctx, config, exec) {
|
|
99
|
-
const store = this.store;
|
|
100
|
-
if (!store)
|
|
101
|
-
return await exec();
|
|
102
|
-
const rawKey = this.readKey(ctx);
|
|
103
|
-
if (rawKey === null)
|
|
104
|
-
return await exec();
|
|
105
|
-
const ttlSeconds = (typeof config === "object" ? config.ttlSeconds : undefined) ?? this.defaultTtlSeconds;
|
|
106
|
-
const scopeKey = this.scopeOf(apiName, ctx, rawKey);
|
|
107
|
-
let begun;
|
|
108
|
-
try {
|
|
109
|
-
begun = await store.begin(scopeKey, { pendingTtlSeconds: IDEMPOTENCY_PENDING_TTL_SECONDS });
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
if (this.failOpen)
|
|
113
|
-
return await exec();
|
|
114
|
-
throw err;
|
|
115
|
-
}
|
|
116
|
-
if (begun.state === "pending") {
|
|
117
|
-
throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
118
|
-
statusCode: 409,
|
|
119
|
-
errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, content: "This request is already being processed." },
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
if (begun.state === "done") {
|
|
123
|
-
return new LambderResponse({
|
|
124
|
-
statusCode: begun.statusCode,
|
|
125
|
-
headers: begun.headers,
|
|
126
|
-
body: begun.body,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
const ownerToken = begun.ownerToken;
|
|
130
|
-
// Headers pushed via res.setHeader/res.addHeader (and session cookie
|
|
131
|
-
// writes) land on ctx accumulators and are applied AFTER this wrapper
|
|
132
|
-
// returns, so snapshot the baseline: entries added during exec belong
|
|
133
|
-
// to this response and must be stored with it, and a Set-Cookie among
|
|
134
|
-
// them makes the response uncacheable (replaying another request's
|
|
135
|
-
// cookies, e.g. session tokens, would be wrong).
|
|
136
|
-
const setBaseline = ctx._otherInternal.setHeaderFnAccumulator.length;
|
|
137
|
-
const addBaseline = ctx._otherInternal.addHeaderFnAccumulator.length;
|
|
138
|
-
// Store the response for replays when it qualifies, release the claim
|
|
139
|
-
// otherwise. Settle failures only surface when failing closed.
|
|
140
|
-
const settleClaim = async (response) => {
|
|
141
|
-
const execSetHeaders = ctx._otherInternal.setHeaderFnAccumulator.slice(setBaseline);
|
|
142
|
-
const execAddHeaders = ctx._otherInternal.addHeaderFnAccumulator.slice(addBaseline);
|
|
143
|
-
const setsCookie = response.getHeader("Set-Cookie") !== undefined
|
|
144
|
-
|| [...execSetHeaders, ...execAddHeaders].some((h) => h.key.toLowerCase() === "set-cookie");
|
|
145
|
-
// The store owns the size decision: bodies are Brotli-compressed
|
|
146
|
-
// there, and only ones exceeding the item budget even compressed
|
|
147
|
-
// come back as "too-large".
|
|
148
|
-
const cacheable = response.statusCode < 500
|
|
149
|
-
&& !setsCookie
|
|
150
|
-
&& typeof response.body === "string"
|
|
151
|
-
&& !response.isBodyBase64;
|
|
152
|
-
try {
|
|
153
|
-
if (cacheable) {
|
|
154
|
-
// Merge during-exec accumulator headers into the stored
|
|
155
|
-
// copy (same replace/append semantics the render pipeline
|
|
156
|
-
// applies), so a replay reproduces the full header set.
|
|
157
|
-
const storedResponse = new LambderResponse({
|
|
158
|
-
statusCode: response.statusCode,
|
|
159
|
-
headers: normalizeHeaders(response.headers),
|
|
160
|
-
body: response.body,
|
|
161
|
-
});
|
|
162
|
-
for (const header of execSetHeaders)
|
|
163
|
-
storedResponse.setHeader(header.key, header.value);
|
|
164
|
-
for (const header of execAddHeaders)
|
|
165
|
-
storedResponse.addHeader(header.key, header.value);
|
|
166
|
-
const completion = await store.complete(scopeKey, ownerToken, {
|
|
167
|
-
statusCode: response.statusCode,
|
|
168
|
-
headers: storedResponse.headers,
|
|
169
|
-
body: response.body,
|
|
170
|
-
ttlSeconds,
|
|
171
|
-
});
|
|
172
|
-
if (completion !== "too-large")
|
|
173
|
-
return;
|
|
174
|
-
// Too large to replay: fall through to release the claim
|
|
175
|
-
// so retries re-execute instead of 409ing.
|
|
176
|
-
}
|
|
177
|
-
await store.abandon(scopeKey, ownerToken);
|
|
178
|
-
}
|
|
179
|
-
catch (storeErr) {
|
|
180
|
-
// A failed complete() must not leave the pending claim
|
|
181
|
-
// dangling (it would 409 retries until the pending TTL).
|
|
182
|
-
try {
|
|
183
|
-
await store.abandon(scopeKey, ownerToken);
|
|
184
|
-
}
|
|
185
|
-
catch { /* claim expires on its own */ }
|
|
186
|
-
if (!this.failOpen)
|
|
187
|
-
throw storeErr;
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
try {
|
|
191
|
-
const response = await exec();
|
|
192
|
-
await settleClaim(response);
|
|
193
|
-
return response;
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
// A thrown LambderResponse IS the response (res.die.*, throw
|
|
197
|
-
// res.api(...)): settle the claim like a returned one so its side
|
|
198
|
-
// effect replays, then rethrow so the pipeline emits it.
|
|
199
|
-
if (err instanceof LambderResponse) {
|
|
200
|
-
await settleClaim(err);
|
|
201
|
-
throw err;
|
|
202
|
-
}
|
|
203
|
-
// A real crash (or a thrown refusal like LambderApiError /
|
|
204
|
-
// refuse()) releases the claim so a retry actually retries. This
|
|
205
|
-
// is the deliberate rule: RESPONSES are stored and replayed,
|
|
206
|
-
// refusals delivered as returned envelopes included; EXCEPTIONS
|
|
207
|
-
// are not, so a thrown refusal re-executes on retry and the
|
|
208
|
-
// handler decides afresh. Pick the idiom accordingly.
|
|
209
|
-
try {
|
|
210
|
-
await store.abandon(scopeKey, ownerToken);
|
|
211
|
-
}
|
|
212
|
-
catch (cleanupErr) {
|
|
213
|
-
if (!this.failOpen)
|
|
214
|
-
throw cleanupErr;
|
|
215
|
-
}
|
|
216
|
-
throw err;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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, type LambderInputValidationRefusal } from "./LambderApiGuards.js";
|
|
5
|
-
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitOptionValue } from "./LambderApiRateLimits.js";
|
|
6
|
-
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
7
|
-
/** The declarative options one API registration may carry. */
|
|
8
|
-
type LambderApiPolicyOptions = {
|
|
9
|
-
rateLimit?: LambderRateLimitOptionValue;
|
|
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
|
-
* the create() options (rateLimits, guards, idempotency) and the
|
|
20
|
-
* per-API options.
|
|
21
|
-
*/
|
|
22
|
-
export declare class LambderApiPolicyEngine {
|
|
23
|
-
private rateLimits;
|
|
24
|
-
private guards;
|
|
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);
|
|
28
|
-
setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
29
|
-
addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
|
|
30
|
-
setIdempotency(config: LambderApiIdempotencyConfig): void;
|
|
31
|
-
/** Startup validation of one API registration's declarative options. */
|
|
32
|
-
assertRegistration(apiName: string, mode: "public" | "session", options: LambderApiPolicyOptions): void;
|
|
33
|
-
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|
|
34
|
-
runPreflight(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, options: LambderApiPolicyOptions): Promise<void>;
|
|
35
|
-
/** Idempotency replay fast path, run before the preflight: see LambderApiIdempotencyEngine.findReplay. */
|
|
36
|
-
findReplay(apiName: string, ctx: LambderRenderContext): Promise<LambderResponse | null>;
|
|
37
|
-
/** Idempotency claim/replay wrapper around handler execution: see LambderApiIdempotencyEngine.withIdempotency. */
|
|
38
|
-
withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
|
|
39
|
-
ttlSeconds?: number;
|
|
40
|
-
}, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
|
|
41
|
-
}
|
|
42
|
-
export {};
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
* the create() options (rateLimits, guards, idempotency) and the
|
|
11
|
-
* per-API options.
|
|
12
|
-
*/
|
|
13
|
-
export class LambderApiPolicyEngine {
|
|
14
|
-
rateLimits;
|
|
15
|
-
guards;
|
|
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
|
-
}
|
|
22
|
-
setRateLimits(config) {
|
|
23
|
-
this.rateLimits.configure(config);
|
|
24
|
-
}
|
|
25
|
-
addGuards(guards) {
|
|
26
|
-
this.guards.addGuards(guards);
|
|
27
|
-
}
|
|
28
|
-
setIdempotency(config) {
|
|
29
|
-
this.idempotency.configure(config);
|
|
30
|
-
}
|
|
31
|
-
/** Startup validation of one API registration's declarative options. */
|
|
32
|
-
assertRegistration(apiName, mode, options) {
|
|
33
|
-
this.rateLimits.assertRegistration(apiName, mode, options.rateLimit);
|
|
34
|
-
this.guards.assertRegistration(apiName, mode, options.guards);
|
|
35
|
-
if (options.idempotency !== undefined && !this.idempotency.isConfigured) {
|
|
36
|
-
throw new Error(`Lambder: API "${apiName}" declares idempotency but no idempotency store was configured at creation.`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|
|
40
|
-
async runPreflight(apiName, ctx, resolver, options) {
|
|
41
|
-
await this.rateLimits.run(apiName, ctx, resolver, options.rateLimit);
|
|
42
|
-
await this.guards.run(ctx, resolver, options.guards);
|
|
43
|
-
}
|
|
44
|
-
/** Idempotency replay fast path, run before the preflight: see LambderApiIdempotencyEngine.findReplay. */
|
|
45
|
-
async findReplay(apiName, ctx) {
|
|
46
|
-
return await this.idempotency.findReplay(apiName, ctx);
|
|
47
|
-
}
|
|
48
|
-
/** Idempotency claim/replay wrapper around handler execution: see LambderApiIdempotencyEngine.withIdempotency. */
|
|
49
|
-
async withIdempotency(apiName, ctx, config, exec) {
|
|
50
|
-
return await this.idempotency.withIdempotency(apiName, ctx, config, exec);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
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, type LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
|
|
5
|
-
import { type LambderRefusalMessage } from "../shared/LambderApiError.js";
|
|
6
|
-
import { type LambderInputValidationRefusal } from "./LambderApiGuards.js";
|
|
7
|
-
/**
|
|
8
|
-
* A custom rate-limit key. `apiInput` names the fields of the API's OWN
|
|
9
|
-
* payload the key derives from: the slice is validated against the raw
|
|
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`.
|
|
16
|
-
*/
|
|
17
|
-
export type LambderRateLimitKeyFn<TInput extends z.ZodType = z.ZodType> = {
|
|
18
|
-
apiInput: TInput;
|
|
19
|
-
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
20
|
-
} | {
|
|
21
|
-
apiInput?: undefined;
|
|
22
|
-
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* Builder that ties the handler's payload type to the `apiInput` schema
|
|
26
|
-
* inside one literal. Returns the exact union member so type extraction can
|
|
27
|
-
* see the schema.
|
|
28
|
-
*/
|
|
29
|
-
export declare function lambderRateLimitKey<TInput extends z.ZodType>(key: {
|
|
30
|
-
apiInput: TInput;
|
|
31
|
-
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
32
|
-
}): {
|
|
33
|
-
apiInput: TInput;
|
|
34
|
-
handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
|
|
35
|
-
};
|
|
36
|
-
export declare function lambderRateLimitKey(key: {
|
|
37
|
-
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
38
|
-
}): {
|
|
39
|
-
apiInput?: undefined;
|
|
40
|
-
handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
|
|
41
|
-
};
|
|
42
|
-
/** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
|
|
43
|
-
export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
|
|
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. */
|
|
58
|
-
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
59
|
-
per: LambderRateLimitPer;
|
|
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;
|
|
64
|
-
};
|
|
65
|
-
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
66
|
-
/** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
|
|
67
|
-
limiter: LambderDdbRateLimiter;
|
|
68
|
-
/** Named policies referenced (typed) from addApi/addSessionApi. */
|
|
69
|
-
policies: TPolicies;
|
|
70
|
-
};
|
|
71
|
-
/**
|
|
72
|
-
* Policy names an API may reference: session-keyed policies only on session
|
|
73
|
-
* APIs, and apiInput-keyed policies only when the API's payload carries the
|
|
74
|
-
* key's fields.
|
|
75
|
-
*/
|
|
76
|
-
export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession extends boolean> = {
|
|
77
|
-
[K in keyof TPolicies]: TPolicies[K] extends {
|
|
78
|
-
per: "session";
|
|
79
|
-
} ? (TIncludeSession extends true ? K : never) : TPolicies[K] extends {
|
|
80
|
-
per: {
|
|
81
|
-
apiInput: infer S extends z.ZodType;
|
|
82
|
-
};
|
|
83
|
-
} ? (TPayload extends z.output<S> ? K : never) : K;
|
|
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>>;
|
|
108
|
-
/**
|
|
109
|
-
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
110
|
-
* policies, asserts API registrations against them at startup, and checks an
|
|
111
|
-
* API's declared policies during preflight. Composed into
|
|
112
|
-
* LambderApiPolicyEngine.
|
|
113
|
-
*/
|
|
114
|
-
export declare class LambderApiRateLimitsEngine {
|
|
115
|
-
private readonly onInvalidInput;
|
|
116
|
-
private limiter;
|
|
117
|
-
private policies;
|
|
118
|
-
constructor(onInvalidInput: LambderInputValidationRefusal);
|
|
119
|
-
configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
120
|
-
/** Startup validation of one API registration's rateLimit option. */
|
|
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>;
|
|
130
|
-
private resolveKey;
|
|
131
|
-
}
|
|
132
|
-
export {};
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { RATE_LIMIT_WINDOWS } from "../stores/LambderDdbRateLimiter.js";
|
|
2
|
-
import { LambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
3
|
-
import { parsePreflightSlice } from "./LambderApiGuards.js";
|
|
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." };
|
|
7
|
-
export function lambderRateLimitKey(key) { return key; }
|
|
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);
|
|
19
|
-
/**
|
|
20
|
-
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
21
|
-
* policies, asserts API registrations against them at startup, and checks an
|
|
22
|
-
* API's declared policies during preflight. Composed into
|
|
23
|
-
* LambderApiPolicyEngine.
|
|
24
|
-
*/
|
|
25
|
-
export class LambderApiRateLimitsEngine {
|
|
26
|
-
onInvalidInput;
|
|
27
|
-
limiter = null;
|
|
28
|
-
policies = {};
|
|
29
|
-
constructor(onInvalidInput) {
|
|
30
|
-
this.onInvalidInput = onInvalidInput;
|
|
31
|
-
}
|
|
32
|
-
configure(config) {
|
|
33
|
-
if (this.limiter)
|
|
34
|
-
throw new Error("Lambder: rateLimits were already configured.");
|
|
35
|
-
for (const [name, policy] of Object.entries(config.policies)) {
|
|
36
|
-
const per = policy.per;
|
|
37
|
-
if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
|
|
38
|
-
throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { apiInput?, handler } key.`);
|
|
39
|
-
}
|
|
40
|
-
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
41
|
-
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
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
|
-
}
|
|
47
|
-
}
|
|
48
|
-
this.limiter = config.limiter;
|
|
49
|
-
this.policies = { ...config.policies };
|
|
50
|
-
}
|
|
51
|
-
/** Startup validation of one API registration's rateLimit option. */
|
|
52
|
-
assertRegistration(apiName, mode, rateLimitOption) {
|
|
53
|
-
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
54
|
-
const policy = this.policies[name];
|
|
55
|
-
if (!policy) {
|
|
56
|
-
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
|
|
57
|
-
}
|
|
58
|
-
if (policy.per === "session" && mode !== "session") {
|
|
59
|
-
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
|
|
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
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
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
|
-
*/
|
|
73
|
-
async run(apiName, ctx, resolver, rateLimitOption) {
|
|
74
|
-
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
75
|
-
const policy = this.policies[name];
|
|
76
|
-
if (!policy || !this.limiter)
|
|
77
|
-
throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
|
|
78
|
-
const key = await this.resolveKey(ctx, resolver, policy.per);
|
|
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"
|
|
83
|
-
? `policy|${name}|${key}`
|
|
84
|
-
: `api|${apiName}|${name}|${key}`;
|
|
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,
|
|
99
|
-
statusCode: 429,
|
|
100
|
-
headers: { "Retry-After": String(retryAfterSeconds) },
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
async resolveKey(ctx, resolver, per) {
|
|
106
|
-
if (per === "ip")
|
|
107
|
-
return `ip:${ctx.ip}`;
|
|
108
|
-
if (per === "session") {
|
|
109
|
-
const sessionKey = ctx.session?.sessionKey;
|
|
110
|
-
if (!sessionKey)
|
|
111
|
-
throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
|
|
112
|
-
return `session:${sessionKey}`;
|
|
113
|
-
}
|
|
114
|
-
const payload = per.apiInput
|
|
115
|
-
? await parsePreflightSlice(per.apiInput, ctx.post?.payload, ctx, resolver, this.onInvalidInput)
|
|
116
|
-
: undefined;
|
|
117
|
-
return `custom:${await per.handler(ctx, payload)}`;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
@@ -1,57 +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
|
-
/** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
|
|
13
|
-
guardInputs?: any;
|
|
14
|
-
/**
|
|
15
|
-
* Present when the API declares guards: the `guards` option exactly as
|
|
16
|
-
* written at registration, so a client-side copy of "what does this API
|
|
17
|
-
* need" can be pinned to the server's own declaration with `satisfies`
|
|
18
|
-
* rather than kept honest by a test that reads the source.
|
|
19
|
-
*/
|
|
20
|
-
guards?: any;
|
|
21
|
-
}>;
|
|
22
|
-
import type { LambderCrashDetail } from "./LambderCrashDetail.js";
|
|
23
|
-
/** Envelope flags/channels the server may set beside (or instead of) the payload. */
|
|
24
|
-
export type LambderApiResponseConfig = {
|
|
25
|
-
versionExpired?: boolean;
|
|
26
|
-
sessionExpired?: boolean;
|
|
27
|
-
notAuthorized?: boolean;
|
|
28
|
-
message?: any;
|
|
29
|
-
errorMessage?: any;
|
|
30
|
-
logList?: any[];
|
|
31
|
-
/**
|
|
32
|
-
* A crash described in full (name, message, stack, cause chain, where it
|
|
33
|
-
* happened), for a caller that is allowed to see it: a global error
|
|
34
|
-
* handler answering a trusted invoker sets it with describeCrash().
|
|
35
|
-
* LambderInvokeCaller reads it back as the cause of the error it throws;
|
|
36
|
-
* the browser caller ignores it.
|
|
37
|
-
*/
|
|
38
|
-
crash?: LambderCrashDetail;
|
|
39
|
-
};
|
|
40
|
-
/** The API wire envelope both sides speak: res.api() emits it, LambderCaller parses it. */
|
|
41
|
-
export type LambderApiResponse<T> = LambderApiResponseConfig & {
|
|
42
|
-
apiVersion?: string | null;
|
|
43
|
-
payload?: T | null;
|
|
44
|
-
};
|
|
45
|
-
/**
|
|
46
|
-
* Helper type for merging new API into existing contract during chaining
|
|
47
|
-
*/
|
|
48
|
-
export type MergeContract<Old, Name extends string, In, Out, GuardInputs = never, Guards = never> = Old & {
|
|
49
|
-
[K in Name]: ([GuardInputs] extends [never] ? {} : {
|
|
50
|
-
guardInputs: GuardInputs;
|
|
51
|
-
}) & ([Guards] extends [never] ? {} : {
|
|
52
|
-
guards: Guards;
|
|
53
|
-
}) & {
|
|
54
|
-
input: In;
|
|
55
|
-
output: Out;
|
|
56
|
-
};
|
|
57
|
-
};
|