lambder 7.2.5 → 8.0.2
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 +1021 -3
- package/README.md +43 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +74 -61
- package/dist/api/LambderApiIdempotency.js +226 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +77 -39
- package/dist/api/LambderApiPipeline.js +135 -62
- package/dist/api/LambderApiRateLimits.d.ts +208 -54
- package/dist/api/LambderApiRateLimits.js +197 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +161 -69
- package/dist/core/Lambder.js +370 -226
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +28 -7
- package/dist/core/LambderFiles.js +73 -33
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
- package/dist/invoke/LambderLambdaEvent.js +80 -37
- package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
- package/dist/invoke/lambderHandlerTransport.js +16 -19
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +3 -1
- package/dist/mock.js +5 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +136 -47
- package/dist/session/LambderSessionManager.js +280 -139
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +176 -0
- package/dist/testing/LambderTestApp.js +204 -0
- package/dist/testing/LambderTestVisitor.d.ts +153 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +24 -0
- package/package.json +20 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
- package/dist/api/LambderApiPolicyEngine.js +0 -77
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -5,20 +5,22 @@ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
|
5
5
|
import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
|
|
6
6
|
import type { LambderApiDefinition } from "./LambderApiDefinition.js";
|
|
7
7
|
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
|
|
8
|
-
import type
|
|
9
|
-
import type
|
|
10
|
-
import type
|
|
11
|
-
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
8
|
+
import { type LambderApiGuard } from "./LambderApiGuards.js";
|
|
9
|
+
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitChargeResult, type LambderRateLimitChargeSubject } from "./LambderApiRateLimits.js";
|
|
10
|
+
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
11
|
+
import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
12
|
+
import type { LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
|
|
13
|
+
import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
|
|
14
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
12
15
|
import type LambderSessionManager from "../session/LambderSessionManager.js";
|
|
13
16
|
import LambderSessionController, { type LambderSessionCookieOptions, type LambderSessionRequestInfo } from "../session/LambderSessionController.js";
|
|
14
17
|
import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
15
18
|
/**
|
|
16
19
|
* The app's own answer for a rejected input (setApiInputValidationErrorHandler
|
|
17
|
-
* on the server). Returning null asks for the standard 422 body, which
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* through here, so one failure has one shape.
|
|
20
|
+
* on the server). Returning null asks for the standard 422 body, which only
|
|
21
|
+
* the pipeline writes, so every adapter without a handler answers alike. The
|
|
22
|
+
* API's schema and every preflight slice (guard inputs, rate-limit keys)
|
|
23
|
+
* answer through here, so one failure has one shape.
|
|
22
24
|
*/
|
|
23
25
|
export type LambderApiInputRefusal<TCtx> = (zodError: z.ZodError, ctx: TCtx, request: LambderApiRequest) => MaybePromise<LambderApiAnswer | null>;
|
|
24
26
|
/** The session subsystem as the pipeline runs it: the manager plus the cookie names and scope the controller writes. */
|
|
@@ -57,6 +59,26 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
|
|
|
57
59
|
}>>;
|
|
58
60
|
idempotency?: LambderApiIdempotencyConfig;
|
|
59
61
|
};
|
|
62
|
+
/** The stores `lambder/testing` puts under a built pipeline. One the pipeline has no subsystem for is left aside. */
|
|
63
|
+
export type LambderPipelineBackends = {
|
|
64
|
+
sessionStore?: LambderSessionStore<any>;
|
|
65
|
+
rateLimiter?: LambderRateLimiter;
|
|
66
|
+
idempotencyStore?: LambderIdempotencyStore;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* What a backend swap found: whether the rate-limit and idempotency
|
|
70
|
+
* subsystems exist to take a store, and, when sessions are configured, the
|
|
71
|
+
* cookie names a caller over this pipeline has to be told (they are the
|
|
72
|
+
* app's own choice and nothing else hands them out).
|
|
73
|
+
*/
|
|
74
|
+
export type LambderPipelineBackendSwap = {
|
|
75
|
+
sessions: {
|
|
76
|
+
tokenCookieKey: string;
|
|
77
|
+
csrfCookieKey: string;
|
|
78
|
+
} | null;
|
|
79
|
+
rateLimits: boolean;
|
|
80
|
+
idempotency: boolean;
|
|
81
|
+
};
|
|
60
82
|
/** What one run produced, beside the answer: what an adapter may want to report. */
|
|
61
83
|
export type LambderApiRunResult = LambderApiCallTrace & {
|
|
62
84
|
answer: LambderApiAnswer;
|
|
@@ -69,16 +91,22 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
69
91
|
* are adapters over this class; neither reimplements a step of it.
|
|
70
92
|
*
|
|
71
93
|
* ```
|
|
72
|
-
* version floor → signature gate → restore payload → rate limits
|
|
73
|
-
* → session (session mode) → idempotency replay →
|
|
74
|
-
* → guards → input validation →
|
|
75
|
-
* →
|
|
94
|
+
* version floor → signature gate → restore payload → rate limits keyed per ip
|
|
95
|
+
* → session (session mode) → idempotency replay → rate limits keyed per session
|
|
96
|
+
* (and custom keys charged beforeGuards) → guards → input validation → guards
|
|
97
|
+
* placed after it → rate limits keyed by a custom key → exec, inside the
|
|
98
|
+
* idempotency claim → drain response headers → answer
|
|
76
99
|
* ```
|
|
77
100
|
*
|
|
101
|
+
* Each policy subsystem (rate limits, guards, idempotency) is its own
|
|
102
|
+
* engine, held here and called at its step, so the order above can be read
|
|
103
|
+
* directly off execute().
|
|
104
|
+
*
|
|
78
105
|
* Steps whose subsystem is not configured are skipped. A LambderApiRefusal
|
|
79
106
|
* thrown by any step, guard or handler is rendered here, in one place: a
|
|
80
107
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
81
|
-
* envelope
|
|
108
|
+
* envelope, and a session ended while the handler held it
|
|
109
|
+
* (LambderSessionNotFoundError) as sessionExpired. Anything else propagates, because only the adapter knows what a
|
|
82
110
|
* crash means (a global error handler, a mock event).
|
|
83
111
|
*
|
|
84
112
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
@@ -89,7 +117,9 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
89
117
|
export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> {
|
|
90
118
|
readonly apiVersion: string | null;
|
|
91
119
|
readonly minApiVersion: string | null;
|
|
92
|
-
private readonly
|
|
120
|
+
private readonly rateLimits;
|
|
121
|
+
private readonly guards;
|
|
122
|
+
private readonly idempotency;
|
|
93
123
|
private readonly maxRequestPayloadBytes;
|
|
94
124
|
private readonly onInvalidInput;
|
|
95
125
|
private readonly sessions;
|
|
@@ -105,8 +135,20 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
105
135
|
* (its cookies and posted CSRF token) or a route's (cookies and no CSRF).
|
|
106
136
|
*/
|
|
107
137
|
sessionController(ctx: TCtx, request: LambderSessionRequestInfo): LambderSessionController<TSessionData>;
|
|
138
|
+
/**
|
|
139
|
+
* The backend swap: each store given goes under the subsystem that holds
|
|
140
|
+
* one, and everything the app configured around it (the session model,
|
|
141
|
+
* the named policies, the replay TTLs) stays in force.
|
|
142
|
+
*/
|
|
143
|
+
[LAMBDER_BACKEND_SWAP](backends: LambderPipelineBackends): LambderPipelineBackendSwap;
|
|
108
144
|
/** The session request info of an API request: its cookies, and the CSRF token it posted. */
|
|
109
145
|
static sessionInfoOf(request: LambderApiRequest): LambderSessionRequestInfo;
|
|
146
|
+
/**
|
|
147
|
+
* One named rate-limit policy charged by code: what an adapter's
|
|
148
|
+
* `ctx.rateLimit` and `ctx.isRateLimited` run. The adapter supplies who is
|
|
149
|
+
* being counted, since only it knows whether the request is an API call.
|
|
150
|
+
*/
|
|
151
|
+
chargeRateLimit(name: string, subject: LambderRateLimitChargeSubject): Promise<LambderRateLimitChargeResult>;
|
|
110
152
|
/** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
|
|
111
153
|
assertRegistration(definition: LambderApiDefinition): void;
|
|
112
154
|
/**
|
|
@@ -117,32 +159,28 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
117
159
|
* client built against a contract that had it) has already been answered
|
|
118
160
|
* versionExpired by the time anything asks for an unknown name.
|
|
119
161
|
*/
|
|
120
|
-
answerUnknownApi(
|
|
162
|
+
answerUnknownApi(ctx?: TCtx): LambderApiAnswer;
|
|
121
163
|
/**
|
|
122
164
|
* The steps that come before anything may read the request: the version
|
|
123
165
|
* floor, the signature gate, then the compressed-payload restore that
|
|
124
166
|
* every later reader (a rate-limit key slice, a guard, the input schema)
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* The floor answers versionExpired to a request naming a version below
|
|
128
|
-
* minApiVersion whatever its signature says: the lever for a change the
|
|
129
|
-
* digest cannot see (a security fix, a field whose meaning changed under
|
|
130
|
-
* the same shape). A request naming no version is not judged by it, as
|
|
131
|
-
* one carrying no signature is not gated.
|
|
167
|
+
* relies on.
|
|
132
168
|
*
|
|
133
|
-
* The
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
169
|
+
* The floor refuses a request naming a version below minApiVersion,
|
|
170
|
+
* whatever its signature says: the lever for a change the digest cannot
|
|
171
|
+
* see (a security fix, a field whose meaning changed under the same
|
|
172
|
+
* shape). The gate refuses a signature that is not the map's entry for
|
|
173
|
+
* the endpoint named (another entry, or none): a client built against
|
|
174
|
+
* another shape of this endpoint, or against one that no longer exists.
|
|
175
|
+
* Both answer versionExpired. A request naming no version skips the
|
|
176
|
+
* floor, and one carrying no signature skips the gate.
|
|
138
177
|
*
|
|
139
|
-
* Public
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* fields it reads.
|
|
178
|
+
* Public because the server runs it earlier, on the way in, so its hooks
|
|
179
|
+
* see a plain payload and a stale client is answered before any of them,
|
|
180
|
+
* whether or not the name it asked for exists. run() calls it too, so an
|
|
181
|
+
* adapter without that step still gets the whole protocol. Calling it
|
|
182
|
+
* twice is safe: the gates are comparisons, and the restore has already
|
|
183
|
+
* removed the wire fields it reads.
|
|
146
184
|
*
|
|
147
185
|
* Returns the answer that ends the call, or null when the request is
|
|
148
186
|
* ready to dispatch.
|
|
@@ -153,10 +191,10 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
153
191
|
*
|
|
154
192
|
* An adapter that wants to report what the call did even when it crashed
|
|
155
193
|
* passes its own trace object: the pipeline writes into that one, so a
|
|
156
|
-
* handler that threw still leaves the guards it ran
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
194
|
+
* handler that threw still leaves the guards it ran for the adapter's
|
|
195
|
+
* catch. A trace created here would be lost with the throw, and the
|
|
196
|
+
* mock's call log would show no guards on exactly the calls a developer
|
|
197
|
+
* opens it for.
|
|
160
198
|
*/
|
|
161
199
|
run(request: LambderApiRequest, ctx: TCtx, definition: LambderApiDefinition, exec: LambderApiExec<TCtx>, trace?: LambderApiCallTrace): Promise<LambderApiRunResult>;
|
|
162
200
|
private execute;
|
|
@@ -6,25 +6,36 @@ import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
|
6
6
|
import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/wire/LambderRequestPayload.js";
|
|
7
7
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
8
8
|
import { compareDottedVersions, isDottedVersion } from "../shared/wire/LambderVersionOrder.js";
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
9
|
+
import { LambderApiGuardsEngine } from "./LambderApiGuards.js";
|
|
10
|
+
import { LambderApiRateLimitsEngine, } from "./LambderApiRateLimits.js";
|
|
11
|
+
import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
|
|
12
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
13
|
+
import LambderSessionController, { assertSessionCookiePrefixes, LambderSessionNotFoundError, } from "../session/LambderSessionController.js";
|
|
11
14
|
import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
|
|
15
|
+
/** An API that asks for idempotency: declared, and not the explicit `false` opt-out. */
|
|
16
|
+
const usesIdempotency = (definition) => definition.idempotency !== undefined && definition.idempotency !== false;
|
|
12
17
|
/**
|
|
13
18
|
* The API pipeline: one API call from a parsed request to a plain answer,
|
|
14
19
|
* in the order the protocol defines. The Lambda server and the mock runtime
|
|
15
20
|
* are adapters over this class; neither reimplements a step of it.
|
|
16
21
|
*
|
|
17
22
|
* ```
|
|
18
|
-
* version floor → signature gate → restore payload → rate limits
|
|
19
|
-
* → session (session mode) → idempotency replay →
|
|
20
|
-
* → guards → input validation →
|
|
21
|
-
* →
|
|
23
|
+
* version floor → signature gate → restore payload → rate limits keyed per ip
|
|
24
|
+
* → session (session mode) → idempotency replay → rate limits keyed per session
|
|
25
|
+
* (and custom keys charged beforeGuards) → guards → input validation → guards
|
|
26
|
+
* placed after it → rate limits keyed by a custom key → exec, inside the
|
|
27
|
+
* idempotency claim → drain response headers → answer
|
|
22
28
|
* ```
|
|
23
29
|
*
|
|
30
|
+
* Each policy subsystem (rate limits, guards, idempotency) is its own
|
|
31
|
+
* engine, held here and called at its step, so the order above can be read
|
|
32
|
+
* directly off execute().
|
|
33
|
+
*
|
|
24
34
|
* Steps whose subsystem is not configured are skipped. A LambderApiRefusal
|
|
25
35
|
* thrown by any step, guard or handler is rendered here, in one place: a
|
|
26
36
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
27
|
-
* envelope
|
|
37
|
+
* envelope, and a session ended while the handler held it
|
|
38
|
+
* (LambderSessionNotFoundError) as sessionExpired. Anything else propagates, because only the adapter knows what a
|
|
28
39
|
* crash means (a global error handler, a mock event).
|
|
29
40
|
*
|
|
30
41
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
@@ -35,7 +46,9 @@ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } fro
|
|
|
35
46
|
export class LambderApiPipeline {
|
|
36
47
|
apiVersion;
|
|
37
48
|
minApiVersion;
|
|
38
|
-
|
|
49
|
+
rateLimits = new LambderApiRateLimitsEngine();
|
|
50
|
+
guards = new LambderApiGuardsEngine();
|
|
51
|
+
idempotency;
|
|
39
52
|
maxRequestPayloadBytes;
|
|
40
53
|
onInvalidInput;
|
|
41
54
|
sessions;
|
|
@@ -54,16 +67,16 @@ export class LambderApiPipeline {
|
|
|
54
67
|
if (!isDottedVersion(this.minApiVersion)) {
|
|
55
68
|
throw new Error(`Lambder: minApiVersion must be a dotted version such as "1.2.10", got ${JSON.stringify(this.minApiVersion)}.`);
|
|
56
69
|
}
|
|
57
|
-
// A floor above the version this server stamps
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// the mistake is said once at creation.
|
|
70
|
+
// A floor above the version this server stamps would refuse this
|
|
71
|
+
// build's own clients, and the first symptom would be every tab
|
|
72
|
+
// reloading. The floor is clamped to apiVersion and the mistake
|
|
73
|
+
// reported once, at creation.
|
|
62
74
|
if (this.apiVersion !== null && compareDottedVersions(this.minApiVersion, this.apiVersion) > 0) {
|
|
63
75
|
console.warn(`Lambder: minApiVersion ${this.minApiVersion} is above apiVersion ${this.apiVersion}; the floor is taken as ${this.apiVersion}.`);
|
|
64
76
|
this.minApiVersion = this.apiVersion;
|
|
65
77
|
}
|
|
66
78
|
}
|
|
79
|
+
this.idempotency = new LambderApiIdempotencyEngine(this.apiVersion);
|
|
67
80
|
this.apiSignatures = options.apiSignatures ?? null;
|
|
68
81
|
this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
|
|
69
82
|
this.onInvalidInput = options.onInvalidInput ?? null;
|
|
@@ -78,11 +91,11 @@ export class LambderApiPipeline {
|
|
|
78
91
|
if (this.sessions)
|
|
79
92
|
assertSessionCookiePrefixes(this.sessions);
|
|
80
93
|
if (options.rateLimits)
|
|
81
|
-
this.
|
|
94
|
+
this.rateLimits.configure(options.rateLimits);
|
|
82
95
|
if (options.guards)
|
|
83
|
-
this.
|
|
96
|
+
this.guards.configure(options.guards);
|
|
84
97
|
if (options.idempotency)
|
|
85
|
-
this.
|
|
98
|
+
this.idempotency.configure(options.idempotency);
|
|
86
99
|
}
|
|
87
100
|
/** True when a session manager was configured. */
|
|
88
101
|
get hasSessions() { return this.sessions !== null; }
|
|
@@ -109,13 +122,53 @@ export class LambderApiPipeline {
|
|
|
109
122
|
request,
|
|
110
123
|
});
|
|
111
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The backend swap: each store given goes under the subsystem that holds
|
|
127
|
+
* one, and everything the app configured around it (the session model,
|
|
128
|
+
* the named policies, the replay TTLs) stays in force.
|
|
129
|
+
*/
|
|
130
|
+
[LAMBDER_BACKEND_SWAP](backends) {
|
|
131
|
+
if (this.sessions && backends.sessionStore)
|
|
132
|
+
this.sessions.manager[LAMBDER_BACKEND_SWAP](backends.sessionStore);
|
|
133
|
+
return {
|
|
134
|
+
sessions: this.sessions ? { tokenCookieKey: this.sessions.tokenCookieKey, csrfCookieKey: this.sessions.csrfCookieKey } : null,
|
|
135
|
+
rateLimits: backends.rateLimiter ? this.rateLimits[LAMBDER_BACKEND_SWAP](backends.rateLimiter) : false,
|
|
136
|
+
idempotency: backends.idempotencyStore ? this.idempotency[LAMBDER_BACKEND_SWAP](backends.idempotencyStore) : false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
112
139
|
/** The session request info of an API request: its cookies, and the CSRF token it posted. */
|
|
113
140
|
static sessionInfoOf(request) {
|
|
114
141
|
return { host: request.host, cookies: request.cookies, csrfToken: request.token };
|
|
115
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* One named rate-limit policy charged by code: what an adapter's
|
|
145
|
+
* `ctx.rateLimit` and `ctx.isRateLimited` run. The adapter supplies who is
|
|
146
|
+
* being counted, since only it knows whether the request is an API call.
|
|
147
|
+
*/
|
|
148
|
+
async chargeRateLimit(name, subject) {
|
|
149
|
+
return await this.rateLimits.chargePolicy(name, subject);
|
|
150
|
+
}
|
|
116
151
|
/** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
|
|
117
152
|
assertRegistration(definition) {
|
|
118
|
-
|
|
153
|
+
const { name, mode } = definition;
|
|
154
|
+
// Each subsystem reports its own absence: one combined message would
|
|
155
|
+
// name all three when only one of them is missing.
|
|
156
|
+
if (definition.rateLimit !== undefined && !this.rateLimits.isConfigured) {
|
|
157
|
+
throw new Error(`Lambder: API "${name}" declares rateLimit but no rateLimits option was configured at creation.`);
|
|
158
|
+
}
|
|
159
|
+
if (definition.guards !== undefined && !this.guards.isConfigured) {
|
|
160
|
+
throw new Error(`Lambder: API "${name}" declares guards but no guards option was configured at creation.`);
|
|
161
|
+
}
|
|
162
|
+
this.rateLimits.assertRegistration(name, mode, definition.rateLimit);
|
|
163
|
+
this.guards.assertRegistration(name, mode, definition.guards);
|
|
164
|
+
// `idempotency: false` is an explicit opt-out, not a use: it asks for
|
|
165
|
+
// nothing and so needs no store behind it.
|
|
166
|
+
if (usesIdempotency(definition)) {
|
|
167
|
+
if (!this.idempotency.isConfigured) {
|
|
168
|
+
throw new Error(`Lambder: API "${name}" declares idempotency but no idempotency store was configured at creation.`);
|
|
169
|
+
}
|
|
170
|
+
this.idempotency.assertRegistration(name, definition.idempotency);
|
|
171
|
+
}
|
|
119
172
|
}
|
|
120
173
|
/**
|
|
121
174
|
* The answer for a request naming no registered API: the apiNotFound
|
|
@@ -125,7 +178,7 @@ export class LambderApiPipeline {
|
|
|
125
178
|
* client built against a contract that had it) has already been answered
|
|
126
179
|
* versionExpired by the time anything asks for an unknown name.
|
|
127
180
|
*/
|
|
128
|
-
answerUnknownApi(
|
|
181
|
+
answerUnknownApi(ctx) {
|
|
129
182
|
const answer = apiNotFoundAnswer(this.apiVersion, ctx?.logList);
|
|
130
183
|
ctx?.responseHeaders.applyInto(answer.headers);
|
|
131
184
|
return answer;
|
|
@@ -134,27 +187,23 @@ export class LambderApiPipeline {
|
|
|
134
187
|
* The steps that come before anything may read the request: the version
|
|
135
188
|
* floor, the signature gate, then the compressed-payload restore that
|
|
136
189
|
* every later reader (a rate-limit key slice, a guard, the input schema)
|
|
137
|
-
*
|
|
190
|
+
* relies on.
|
|
138
191
|
*
|
|
139
|
-
* The floor
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
192
|
+
* The floor refuses a request naming a version below minApiVersion,
|
|
193
|
+
* whatever its signature says: the lever for a change the digest cannot
|
|
194
|
+
* see (a security fix, a field whose meaning changed under the same
|
|
195
|
+
* shape). The gate refuses a signature that is not the map's entry for
|
|
196
|
+
* the endpoint named (another entry, or none): a client built against
|
|
197
|
+
* another shape of this endpoint, or against one that no longer exists.
|
|
198
|
+
* Both answer versionExpired. A request naming no version skips the
|
|
199
|
+
* floor, and one carrying no signature skips the gate.
|
|
144
200
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* Public and named because the server runs them earlier than run() does,
|
|
152
|
-
* on the way in, so that its hooks see a plain payload and a stale client
|
|
153
|
-
* is answered before any of them, whether or not the name it asked for
|
|
154
|
-
* exists. run() calls it too, so an adapter that has no such step still
|
|
155
|
-
* gets the whole protocol. Calling it twice is safe by construction: the
|
|
156
|
-
* gates are comparisons and the restore has already removed the wire
|
|
157
|
-
* fields it reads.
|
|
201
|
+
* Public because the server runs it earlier, on the way in, so its hooks
|
|
202
|
+
* see a plain payload and a stale client is answered before any of them,
|
|
203
|
+
* whether or not the name it asked for exists. run() calls it too, so an
|
|
204
|
+
* adapter without that step still gets the whole protocol. Calling it
|
|
205
|
+
* twice is safe: the gates are comparisons, and the restore has already
|
|
206
|
+
* removed the wire fields it reads.
|
|
158
207
|
*
|
|
159
208
|
* Returns the answer that ends the call, or null when the request is
|
|
160
209
|
* ready to dispatch.
|
|
@@ -178,10 +227,10 @@ export class LambderApiPipeline {
|
|
|
178
227
|
*
|
|
179
228
|
* An adapter that wants to report what the call did even when it crashed
|
|
180
229
|
* passes its own trace object: the pipeline writes into that one, so a
|
|
181
|
-
* handler that threw still leaves the guards it ran
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
230
|
+
* handler that threw still leaves the guards it ran for the adapter's
|
|
231
|
+
* catch. A trace created here would be lost with the throw, and the
|
|
232
|
+
* mock's call log would show no guards on exactly the calls a developer
|
|
233
|
+
* opens it for.
|
|
185
234
|
*/
|
|
186
235
|
async run(request, ctx, definition, exec, trace = { guardsRun: [], replayed: false }) {
|
|
187
236
|
let answer;
|
|
@@ -195,6 +244,14 @@ export class LambderApiPipeline {
|
|
|
195
244
|
else if (isLambderApiRefusal(err)) {
|
|
196
245
|
answer = refusalAnswer(err, this.apiVersion, ctx.logList);
|
|
197
246
|
}
|
|
247
|
+
else if (err instanceof LambderSessionNotFoundError) {
|
|
248
|
+
// No usable session: it ended while the handler held it (a
|
|
249
|
+
// logout or a password change landed mid-request), or a read
|
|
250
|
+
// the handler made found none or several
|
|
251
|
+
// (LambderSessionAmbiguousError is one of these). The same
|
|
252
|
+
// answer a session read that found none gives.
|
|
253
|
+
answer = sessionExpiredAnswer(this.apiVersion, ctx.logList);
|
|
254
|
+
}
|
|
198
255
|
else {
|
|
199
256
|
throw err;
|
|
200
257
|
}
|
|
@@ -213,11 +270,11 @@ export class LambderApiPipeline {
|
|
|
213
270
|
// The limits whose key is known from the request alone, before the
|
|
214
271
|
// session store is asked anything: a request carrying bogus session
|
|
215
272
|
// cookies costs up to four store reads, and answering it
|
|
216
|
-
// sessionExpired
|
|
273
|
+
// sessionExpired before the limiter runs would let one address spend
|
|
217
274
|
// the session store's read budget freely. A replay costs the same
|
|
218
|
-
// reads, so an ip-limited replay counts
|
|
219
|
-
//
|
|
220
|
-
await this.
|
|
275
|
+
// reads, so an ip-limited replay counts too: the limit protects the
|
|
276
|
+
// stores, not the handler.
|
|
277
|
+
await this.rateLimits.run(definition.name, request, ctx, definition.rateLimit, "beforeSession");
|
|
221
278
|
if (definition.mode === "session") {
|
|
222
279
|
if (!this.sessions)
|
|
223
280
|
throw new Error(`Lambder: API "${definition.name}" is a session API, but no session store was configured at creation.`);
|
|
@@ -228,33 +285,49 @@ export class LambderApiPipeline {
|
|
|
228
285
|
// Replay fast path: a completed idempotent request answers its stored
|
|
229
286
|
// answer without burning the remaining rate-limit quota or re-running
|
|
230
287
|
// guards. After the session read, because the replay scope is keyed
|
|
231
|
-
// per session.
|
|
232
|
-
const
|
|
288
|
+
// per user, by the session's sessionKey.
|
|
289
|
+
const keyedCall = usesIdempotency(definition) ? await this.idempotency.resolveKeyedCall(definition.name, request, ctx) : null;
|
|
290
|
+
const replay = keyedCall ? await this.idempotency.findReplay(definition.name, keyedCall, trace) : null;
|
|
233
291
|
if (replay)
|
|
234
292
|
return replay;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
293
|
+
// What refuses a request without spending anything on it comes first:
|
|
294
|
+
// the limits keyed per session, the guards, the input. What spends
|
|
295
|
+
// something comes last: a guard placed after validation (a
|
|
296
|
+
// single-use captcha a mistyped field would otherwise waste), and the
|
|
297
|
+
// limits keyed by a caller-chosen value (an email in the payload),
|
|
298
|
+
// which charged earlier would let a caller who never passes the
|
|
299
|
+
// captcha spend a victim's budget. Refusals throw; the trace records
|
|
300
|
+
// each guard as it runs.
|
|
301
|
+
await this.rateLimits.run(definition.name, request, ctx, definition.rateLimit, "beforeGuards");
|
|
302
|
+
await this.guards.run(request, ctx, definition.guards, trace, "beforeInputValidation");
|
|
303
|
+
// Asynchronously, so an input schema with an async refinement
|
|
304
|
+
// validates instead of making zod throw on every call. The parse is
|
|
305
|
+
// handed to the handler only after the late guards and the custom
|
|
306
|
+
// keys, which read their slices from the payload as it was sent.
|
|
307
|
+
const parsed = definition.input ? await definition.input.safeParseAsync(request.payload) : null;
|
|
308
|
+
if (parsed && !parsed.success)
|
|
309
|
+
throw new LambderApiValidationRefusal(parsed.error);
|
|
310
|
+
await this.guards.run(request, ctx, definition.guards, trace, "afterInputValidation");
|
|
311
|
+
await this.rateLimits.run(definition.name, request, ctx, definition.rateLimit, "afterGuards");
|
|
312
|
+
if (parsed)
|
|
240
313
|
request.payload = parsed.data;
|
|
241
|
-
}
|
|
242
314
|
// The handler's own answer, and only that: what it wrote into
|
|
243
|
-
// responseHeaders during the call
|
|
244
|
-
// engine judges and stores it, while a header written
|
|
245
|
-
// call
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// run() applies them to the answer on the way out.
|
|
315
|
+
// responseHeaders during the call goes on before the idempotency
|
|
316
|
+
// engine judges and stores it, while a header written earlier in the
|
|
317
|
+
// call does not. The engine refuses to store an answer carrying a
|
|
318
|
+
// Set-Cookie, so the stale-session cookie the session read evicted
|
|
319
|
+
// would silently make an idempotent operation re-execute on every
|
|
320
|
+
// retry. The earlier headers still reach the client: run() applies
|
|
321
|
+
// them on the way out.
|
|
251
322
|
const runHandler = async () => {
|
|
252
323
|
const handlerFirstHeader = ctx.responseHeaders.size;
|
|
253
324
|
const produced = await exec(ctx);
|
|
254
325
|
ctx.responseHeaders.applyInto(produced.headers, handlerFirstHeader);
|
|
255
326
|
return produced;
|
|
256
327
|
};
|
|
257
|
-
return
|
|
328
|
+
return keyedCall && usesIdempotency(definition)
|
|
329
|
+
? await this.idempotency.withIdempotency(definition.name, keyedCall, definition.idempotency, trace, runHandler)
|
|
330
|
+
: await runHandler();
|
|
258
331
|
}
|
|
259
332
|
async refuseInput(err, ctx, request) {
|
|
260
333
|
const custom = this.onInvalidInput ? await this.onInvalidInput(err.zodError, ctx, request) : null;
|