lambder 6.0.2 → 7.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 +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 +21 -19
- 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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
|
|
2
|
+
import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
3
|
+
import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
4
|
+
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
5
|
+
import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
|
|
6
|
+
import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js";
|
|
7
|
+
const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
8
|
+
/** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
|
|
9
|
+
export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
|
|
10
|
+
/**
|
|
11
|
+
* The refusal a rate-limited call answers with: a 429 envelope carrying the
|
|
12
|
+
* framework code (a policy's own message inherits it unless it sets a more
|
|
13
|
+
* specific one) and a Retry-After header. The engine throws it; the mock
|
|
14
|
+
* runtime's failure injection throws the same one, so an injected rate
|
|
15
|
+
* limit is indistinguishable from a real one.
|
|
16
|
+
*/
|
|
17
|
+
export const rateLimitRefusal = (detail, retryAfterSeconds, message) => new LambderApiRefusal(detail, {
|
|
18
|
+
errorMessage: message ? { code: LAMBDER_REFUSAL_CODES.rateLimited, ...message } : DEFAULT_RATE_LIMIT_REFUSAL,
|
|
19
|
+
statusCode: 429,
|
|
20
|
+
headers: { "Retry-After": String(Math.max(1, Math.floor(retryAfterSeconds))) },
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* A key builder bound to a context type, the counterpart of
|
|
24
|
+
* lambderGuardBuilder. The server's lambderRateLimitKey() is this bound to
|
|
25
|
+
* the render context; the mock runtime binds it to its own call context and
|
|
26
|
+
* exposes it as `rateLimitKey`.
|
|
27
|
+
*
|
|
28
|
+
* Bound rather than left open because the engine hands the handler whatever
|
|
29
|
+
* context the adapter runs on, and the two adapters run on different ones. A
|
|
30
|
+
* single builder pinned to the server's context type compiled against the
|
|
31
|
+
* mock and then handed the handler a context with no `ip`, `method` or
|
|
32
|
+
* `path`, so every caller collapsed onto one counter and the limit a test was
|
|
33
|
+
* written to prove silently proved nothing.
|
|
34
|
+
*/
|
|
35
|
+
export const lambderRateLimitKeyBuilder = () => ((key) => key);
|
|
36
|
+
/** Normalize the three rateLimit-option forms into ordered entries; an explicit `undefined` map value declares nothing. */
|
|
37
|
+
const toRateLimitEntries = (value) => {
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
return [];
|
|
40
|
+
if (typeof value === "string")
|
|
41
|
+
return [{ name: value }];
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value.map((name) => ({ name }));
|
|
44
|
+
return Object.entries(value).flatMap(([name, override]) => override === undefined ? [] : override === true ? [{ name }] : [{ name, override }]);
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A limiter is handed only limits it can act on, so no implementation has to
|
|
48
|
+
* invent an answer for a nonsense one: that is where two limiters drift apart,
|
|
49
|
+
* one refusing the first attempt against a negative cap and the other allowing
|
|
50
|
+
* it. Zero is legal and leaves the window
|
|
51
|
+
* unenforced, which is why this is the non-negative check and not the
|
|
52
|
+
* positive one.
|
|
53
|
+
*/
|
|
54
|
+
const assertWindowLimits = (subject, windows) => {
|
|
55
|
+
for (const key of RATE_LIMIT_WINDOW_KEYS) {
|
|
56
|
+
const limit = windows[key];
|
|
57
|
+
if (limit === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
assertNonNegativeInteger(limit, `${subject} caps ${key} at ${String(limit)}; a window's limit`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const hasWindowOverride = (override) => RATE_LIMIT_WINDOW_KEYS.some((key) => override[key] !== undefined);
|
|
63
|
+
const phaseOf = (per) => per === "ip" ? "beforeSession" : "afterSession";
|
|
64
|
+
/**
|
|
65
|
+
* The ceiling on the variable half of a tracker key, in UTF-8 bytes, past
|
|
66
|
+
* which that half is replaced by its own digest. 1024 sits comfortably inside
|
|
67
|
+
* every store's key limit (a DynamoDB partition key is 2048 bytes, and the
|
|
68
|
+
* limiter's own prefix plus the api and policy names are joined in front of
|
|
69
|
+
* this half).
|
|
70
|
+
*
|
|
71
|
+
* The bound lives in the engine rather than in a limiter because a store that
|
|
72
|
+
* REFUSES an over-long key refuses it by throwing, and a throw from a limiter
|
|
73
|
+
* is exactly what failOpen swallows: a custom key derived from a payload field
|
|
74
|
+
* (the documented shape, an email address) that a caller posts 3,000
|
|
75
|
+
* characters long makes every window of every policy fail the same way, and
|
|
76
|
+
* the request goes through unmetered with the limit silently off. Folding the
|
|
77
|
+
* over-long half into a digest keeps distinct callers on distinct counters,
|
|
78
|
+
* and a key that fits stays readable in the table.
|
|
79
|
+
*
|
|
80
|
+
* `per: "ip"` needs none of this: normalizeClientIp already caps an address at
|
|
81
|
+
* 45 characters, whichever header or gateway field named it.
|
|
82
|
+
*/
|
|
83
|
+
const MAX_TRACKER_KEY_PART_BYTES = 1024;
|
|
84
|
+
/**
|
|
85
|
+
* The variable half of a tracker key, bounded: `<kind>:<value>` while the
|
|
86
|
+
* value fits, `<kind>:h:<sha256 hex>` once it does not. The api and policy
|
|
87
|
+
* names are joined around it afterwards, so an over-long key still says which
|
|
88
|
+
* policy it belongs to.
|
|
89
|
+
*/
|
|
90
|
+
const boundTrackerKeyPart = async (kind, value) => new TextEncoder().encode(value).length > MAX_TRACKER_KEY_PART_BYTES
|
|
91
|
+
? `${kind}:h:${await sha256HexOf(value)}`
|
|
92
|
+
: `${kind}:${value}`;
|
|
93
|
+
/** The windows one check ran against, for a log line that must not carry the tracker key. */
|
|
94
|
+
const describeWindows = (limits) => RATE_LIMIT_WINDOW_KEYS.filter((key) => limits[key] !== undefined).map((key) => `${key}: ${String(limits[key])}`).join(", ") || "no window";
|
|
95
|
+
/**
|
|
96
|
+
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
97
|
+
* policies, asserts API registrations against them at startup, and checks an
|
|
98
|
+
* API's declared policies during preflight. Composed into
|
|
99
|
+
* LambderApiPolicyEngine. Reads the request's ip and the context's session
|
|
100
|
+
* and nothing else, so it runs unchanged under the server and the mock
|
|
101
|
+
* runtime.
|
|
102
|
+
*/
|
|
103
|
+
export class LambderApiRateLimitsEngine {
|
|
104
|
+
limiter = null;
|
|
105
|
+
failOpen = true;
|
|
106
|
+
// A Map for the same reason the guard registry is one: a plain object
|
|
107
|
+
// answers for "toString" and "constructor" through its prototype, so a
|
|
108
|
+
// policy named one of those would slip past the registration check and
|
|
109
|
+
// fail on every request instead.
|
|
110
|
+
policies = new Map();
|
|
111
|
+
/** True once rateLimits were configured. */
|
|
112
|
+
get isConfigured() { return this.limiter !== null; }
|
|
113
|
+
configure(config) {
|
|
114
|
+
if (this.limiter)
|
|
115
|
+
throw new Error("Lambder: rateLimits were already configured.");
|
|
116
|
+
// The same rule the guards option follows, and the one an API's own
|
|
117
|
+
// `rateLimit: {}` is already held to: declaring the option is
|
|
118
|
+
// declaring a limit. An empty map configured a limiter with nothing to
|
|
119
|
+
// enforce and reported every API that named a policy as if the option
|
|
120
|
+
// had never been given, which sends the reader to the wrong line.
|
|
121
|
+
if (Object.keys(config.policies).length === 0) {
|
|
122
|
+
throw new Error("Lambder: the rateLimits option was declared with no policies in it, which configures nothing. Name the policies APIs will declare, or leave the option off.");
|
|
123
|
+
}
|
|
124
|
+
for (const [name, policy] of Object.entries(config.policies)) {
|
|
125
|
+
const per = policy.per;
|
|
126
|
+
if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
|
|
127
|
+
throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { apiInput?, handler } key.`);
|
|
128
|
+
}
|
|
129
|
+
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
130
|
+
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
131
|
+
}
|
|
132
|
+
assertWindowLimits(`rate-limit policy "${name}"`, policy);
|
|
133
|
+
const budget = policy.budget;
|
|
134
|
+
if (budget !== undefined && budget !== "perApi" && budget !== "perPolicy") {
|
|
135
|
+
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).`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.limiter = config.limiter;
|
|
139
|
+
this.failOpen = config.failOpen ?? true;
|
|
140
|
+
this.policies = new Map(Object.entries(config.policies));
|
|
141
|
+
}
|
|
142
|
+
/** Startup validation of one API registration's rateLimit option. */
|
|
143
|
+
assertRegistration(apiName, mode, rateLimitOption) {
|
|
144
|
+
const entries = toRateLimitEntries(rateLimitOption);
|
|
145
|
+
// The same rule the guards option follows: declaring the option is
|
|
146
|
+
// declaring a limit. `{}`, `[]` and `{ name: undefined }` are
|
|
147
|
+
// present-but-empty, and would otherwise register an API that
|
|
148
|
+
// announces a rate limit and enforces none.
|
|
149
|
+
if (rateLimitOption !== undefined && entries.length === 0) {
|
|
150
|
+
throw new Error(`Lambder: API "${apiName}" declares an empty rateLimit option, which limits nothing. ` +
|
|
151
|
+
`Name the policy that limits it, or omit the option entirely.`);
|
|
152
|
+
}
|
|
153
|
+
for (const { name, override } of entries) {
|
|
154
|
+
const policy = this.policies.get(name);
|
|
155
|
+
if (!policy) {
|
|
156
|
+
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
|
|
157
|
+
}
|
|
158
|
+
if (policy.per === "session" && mode !== "session") {
|
|
159
|
+
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
|
|
160
|
+
}
|
|
161
|
+
if (override)
|
|
162
|
+
assertWindowLimits(`API "${apiName}" override of rate-limit policy "${name}"`, override);
|
|
163
|
+
if (override && hasWindowOverride(override) && !RATE_LIMIT_WINDOW_KEYS.some((key) => (override[key] ?? policy[key]))) {
|
|
164
|
+
throw new Error(`Lambder: API "${apiName}" overrides rate-limit policy "${name}" down to no enforced window, which limits nothing. ` +
|
|
165
|
+
`A policy is required to declare a window; an override may not take the last one away.`);
|
|
166
|
+
}
|
|
167
|
+
if (override && policy.budget === "perPolicy" && hasWindowOverride(override)) {
|
|
168
|
+
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.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Check the API's policies in declared order; the first exceeded one
|
|
174
|
+
* refuses with a 429 envelope and a Retry-After header. Attempts count,
|
|
175
|
+
* not successes: every counter checked before the refusing one (and every
|
|
176
|
+
* counter, when a later guard or validation refuses) keeps its increment,
|
|
177
|
+
* so list first the policy you want charged on refusals.
|
|
178
|
+
*
|
|
179
|
+
* Run twice per call, once per phase: the policies whose key needs no
|
|
180
|
+
* session are checked BEFORE the session is read, so a flood of requests
|
|
181
|
+
* carrying bogus session cookies is refused without touching the session
|
|
182
|
+
* store; the rest are checked after it, since `per: "session"` and a
|
|
183
|
+
* custom key handler may both read ctx.session. Declared order is kept
|
|
184
|
+
* inside each phase.
|
|
185
|
+
*/
|
|
186
|
+
async run(apiName, request, ctx, rateLimitOption, phase) {
|
|
187
|
+
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
188
|
+
const policy = this.policies.get(name);
|
|
189
|
+
if (!policy || !this.limiter)
|
|
190
|
+
throw new Error(`Lambder: rate-limit policy "${name}" is not configured. Declare it in the rateLimits option at creation.`);
|
|
191
|
+
if (phaseOf(policy.per) !== phase)
|
|
192
|
+
continue;
|
|
193
|
+
const key = await this.resolveKey(request, ctx, policy.per);
|
|
194
|
+
// "perPolicy" shares one counter across every API referencing the
|
|
195
|
+
// policy; "perApi" keys each API separately, which is also what
|
|
196
|
+
// lets an API override the windows without colliding.
|
|
197
|
+
const trackerKey = policy.budget === "perPolicy"
|
|
198
|
+
? joinKeyFields("policy", name, key)
|
|
199
|
+
: joinKeyFields("api", apiName, name, key);
|
|
200
|
+
const limits = {};
|
|
201
|
+
for (const windowKey of RATE_LIMIT_WINDOW_KEYS) {
|
|
202
|
+
const limit = override?.[windowKey] ?? policy[windowKey];
|
|
203
|
+
if (limit !== undefined)
|
|
204
|
+
limits[windowKey] = limit;
|
|
205
|
+
}
|
|
206
|
+
let exceeded;
|
|
207
|
+
try {
|
|
208
|
+
exceeded = await this.limiter.isRateLimited(trackerKey, limits);
|
|
209
|
+
}
|
|
210
|
+
catch (limiterErr) {
|
|
211
|
+
if (!this.failOpen)
|
|
212
|
+
throw limiterErr;
|
|
213
|
+
// The policy and its windows, never the tracker key: the key
|
|
214
|
+
// carries whatever a custom handler returned, which the docs'
|
|
215
|
+
// own example makes an email address, and a log line is not
|
|
216
|
+
// the place for it.
|
|
217
|
+
console.error(`Lambder rate limits: policy "${name}" (${describeWindows(limits)}) could not be checked for API "${apiName}"; ` +
|
|
218
|
+
"the request is being allowed through. Set rateLimits.failOpen: false to refuse instead.", limiterErr);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (exceeded) {
|
|
222
|
+
const retryAfterSeconds = Math.max(1, exceeded.resetAt - Math.floor(Date.now() / 1000));
|
|
223
|
+
throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, override?.errorMessage ?? policy.errorMessage);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async resolveKey(request, ctx, per) {
|
|
228
|
+
if (per === "ip")
|
|
229
|
+
return `ip:${request.ip}`;
|
|
230
|
+
if (per === "session") {
|
|
231
|
+
const sessionKey = ctx.session?.sessionKey;
|
|
232
|
+
if (!sessionKey)
|
|
233
|
+
throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
|
|
234
|
+
return await boundTrackerKeyPart("session", sessionKey);
|
|
235
|
+
}
|
|
236
|
+
const payload = per.apiInput ? parsePreflightSlice(per.apiInput, request.payload) : undefined;
|
|
237
|
+
return await boundTrackerKeyPart("custom", await per.handler(ctx, payload));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie header pairs (`name=value`) as every value per name, in header
|
|
3
|
+
* order. Parsed pair by pair, because a whole-header parse keeps only the
|
|
4
|
+
* first value of a name the browser holds at two scopes, and the session
|
|
5
|
+
* controller weighs every copy. The map has no prototype: a cookie is client
|
|
6
|
+
* data, and a name such as `__proto__` or `constructor` must land as a key
|
|
7
|
+
* of its own rather than resolve to Object.prototype, which is what turned
|
|
8
|
+
* one planted cookie into a 500 on every request. Every adapter builds its
|
|
9
|
+
* request's cookies through this one function.
|
|
10
|
+
*/
|
|
11
|
+
export declare const cookieValuesByName: (pairs: readonly string[]) => Record<string, string[]>;
|
|
12
|
+
/** Request headers under lowercased names, so a lookup never depends on how the gateway spelled them. */
|
|
13
|
+
export declare const lowercaseHeaderNames: (headers: Record<string, string | undefined> | undefined) => Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* One API call as the core sees it, whichever adapter parsed it: the fields
|
|
16
|
+
* of the envelope a caller posts (LambderCaller and LambderInvokeCaller send
|
|
17
|
+
* the same one), plus what the transport knew about the request. The server
|
|
18
|
+
* builds it from the Lambda event, the mock runtime from a caller's
|
|
19
|
+
* transport request, and from here on nothing in the pipeline knows which.
|
|
20
|
+
*/
|
|
21
|
+
export type LambderApiRequest = {
|
|
22
|
+
apiName: string;
|
|
23
|
+
/** The caller's apiVersion, for the version gate; null when it sent none. */
|
|
24
|
+
version: string | null;
|
|
25
|
+
/** The CSRF token the caller posted in the envelope; "" when it holds none. */
|
|
26
|
+
token: string;
|
|
27
|
+
siteHost: string;
|
|
28
|
+
/** The payload as posted, or as restored from its compressed form; the validated payload once the pipeline has parsed it. */
|
|
29
|
+
payload: unknown;
|
|
30
|
+
/** The compressed form the caller sent instead of `payload`, until restoreCompressedPayload replaces it; null when it sent the payload plainly. */
|
|
31
|
+
compressedPayload: LambderCompressedPayloadFields | null;
|
|
32
|
+
guardInputs: Record<string, unknown> | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The idempotency key exactly as posted, so `unknown`: it is client data,
|
|
35
|
+
* and the shape check plus the client-facing 400 belong to the
|
|
36
|
+
* idempotency engine. Typed `string | undefined` here, every reader was
|
|
37
|
+
* entitled to treat a number or an object as a key, and the only one
|
|
38
|
+
* there is had to widen it back before it could check anything.
|
|
39
|
+
*/
|
|
40
|
+
idempotencyKey: unknown;
|
|
41
|
+
/** Request headers, names lowercased. */
|
|
42
|
+
headers: Record<string, string>;
|
|
43
|
+
/** Every value the request carried per cookie name, in header order (see LambderRenderContext.cookieList). */
|
|
44
|
+
cookies: Record<string, string[]>;
|
|
45
|
+
/** Client IP as the adapter resolved it; "" when unknown. */
|
|
46
|
+
ip: string;
|
|
47
|
+
/** The Host the request was made to. */
|
|
48
|
+
host: string;
|
|
49
|
+
/**
|
|
50
|
+
* The caller's abort signal, carried for adapters that have one (the mock
|
|
51
|
+
* runtime aborts its own latency wait with it). The pipeline itself
|
|
52
|
+
* neither reads nor honours it: a Lambda invocation has no signal, and an
|
|
53
|
+
* adapter that does own one is the layer that knows what abandoning a
|
|
54
|
+
* half-run call means for it.
|
|
55
|
+
*/
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
};
|
|
58
|
+
/** The compressed-payload fields exactly as posted; validated by restoreCompressedPayload. */
|
|
59
|
+
export type LambderCompressedPayloadFields = {
|
|
60
|
+
gzip: unknown;
|
|
61
|
+
brotli: unknown;
|
|
62
|
+
declaredBytes: unknown;
|
|
63
|
+
};
|
|
64
|
+
/** What the transport knew about the request, beside the envelope. */
|
|
65
|
+
export type LambderApiRequestInfo = {
|
|
66
|
+
headers: Record<string, string>;
|
|
67
|
+
cookies: Record<string, string[]>;
|
|
68
|
+
ip: string;
|
|
69
|
+
host: string;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Reads the posted envelope into a request. Null when the body carries no
|
|
74
|
+
* apiName, which is how the server tells an API call from a route with a
|
|
75
|
+
* JSON body. Everything is taken as posted: a malformed idempotencyKey or
|
|
76
|
+
* guardInputs value is the engines' to refuse, with the client-facing
|
|
77
|
+
* message they already give.
|
|
78
|
+
*/
|
|
79
|
+
export declare const readApiEnvelope: (post: Record<string, unknown> | null | undefined, info: LambderApiRequestInfo) => LambderApiRequest | null;
|
|
80
|
+
/** Outcome of restoring a compressed request payload; the message is client-facing. */
|
|
81
|
+
export type LambderRestorePayloadResult = {
|
|
82
|
+
ok: true;
|
|
83
|
+
} | {
|
|
84
|
+
ok: false;
|
|
85
|
+
message: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
|
|
89
|
+
* beside `payloadBytes`) onto request.payload, so every later stage
|
|
90
|
+
* (rate-limit key slices, guards, input validation, the handler) reads an
|
|
91
|
+
* ordinary payload and needs no awareness of the wire format. The field
|
|
92
|
+
* names the encoding; a request carrying both is refused. A request that
|
|
93
|
+
* sent a plain payload passes through untouched.
|
|
94
|
+
*
|
|
95
|
+
* Every failure answers with a message instead of throwing: a malformed body
|
|
96
|
+
* is a client error, not a crash. The declared byte length both bounds the
|
|
97
|
+
* decompression and verifies it, so an over-large or tampered body is
|
|
98
|
+
* refused rather than expanded. Runs on Node through zlib and in a browser
|
|
99
|
+
* through DecompressionStream, under the same bound.
|
|
100
|
+
*/
|
|
101
|
+
export declare const restoreCompressedPayload: (request: LambderApiRequest, maxPayloadBytes: number) => Promise<LambderRestorePayloadResult>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, } from "../shared/wire/LambderRequestPayload.js";
|
|
2
|
+
import { base64ToBytes } from "../shared/util/LambderBase64.js";
|
|
3
|
+
import cookieParser from "cookie";
|
|
4
|
+
import { restoreText, LambderCompressionError, LAMBDER_RESTORE_FAILURES } from "../shared/wire/LambderCompressionCodec.js";
|
|
5
|
+
/**
|
|
6
|
+
* Cookie header pairs (`name=value`) as every value per name, in header
|
|
7
|
+
* order. Parsed pair by pair, because a whole-header parse keeps only the
|
|
8
|
+
* first value of a name the browser holds at two scopes, and the session
|
|
9
|
+
* controller weighs every copy. The map has no prototype: a cookie is client
|
|
10
|
+
* data, and a name such as `__proto__` or `constructor` must land as a key
|
|
11
|
+
* of its own rather than resolve to Object.prototype, which is what turned
|
|
12
|
+
* one planted cookie into a 500 on every request. Every adapter builds its
|
|
13
|
+
* request's cookies through this one function.
|
|
14
|
+
*/
|
|
15
|
+
export const cookieValuesByName = (pairs) => {
|
|
16
|
+
const cookies = Object.create(null);
|
|
17
|
+
for (const pair of pairs) {
|
|
18
|
+
for (const [name, value] of Object.entries(cookieParser.parse(pair))) {
|
|
19
|
+
if (value !== undefined)
|
|
20
|
+
(cookies[name] ??= []).push(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return cookies;
|
|
24
|
+
};
|
|
25
|
+
/** Request headers under lowercased names, so a lookup never depends on how the gateway spelled them. */
|
|
26
|
+
export const lowercaseHeaderNames = (headers) => {
|
|
27
|
+
// Prototype-free for the same reason the cookie map is: a header is
|
|
28
|
+
// client data, and a name such as `__proto__` must land as a key rather
|
|
29
|
+
// than reach Object.prototype's setter and vanish.
|
|
30
|
+
const lowered = Object.create(null);
|
|
31
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
32
|
+
if (value !== undefined)
|
|
33
|
+
lowered[key.toLowerCase()] = value;
|
|
34
|
+
}
|
|
35
|
+
return lowered;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Reads the posted envelope into a request. Null when the body carries no
|
|
39
|
+
* apiName, which is how the server tells an API call from a route with a
|
|
40
|
+
* JSON body. Everything is taken as posted: a malformed idempotencyKey or
|
|
41
|
+
* guardInputs value is the engines' to refuse, with the client-facing
|
|
42
|
+
* message they already give.
|
|
43
|
+
*/
|
|
44
|
+
export const readApiEnvelope = (post, info) => {
|
|
45
|
+
if (!post || typeof post.apiName !== "string" || !post.apiName)
|
|
46
|
+
return null;
|
|
47
|
+
const hasGzip = post[COMPRESSED_PAYLOAD_GZ_FIELD] !== undefined;
|
|
48
|
+
const hasBrotli = post[COMPRESSED_PAYLOAD_BR_FIELD] !== undefined;
|
|
49
|
+
const guardInputs = post.guardInputs;
|
|
50
|
+
return {
|
|
51
|
+
apiName: post.apiName,
|
|
52
|
+
version: typeof post.version === "string" ? post.version : null,
|
|
53
|
+
token: typeof post.token === "string" ? post.token : "",
|
|
54
|
+
siteHost: typeof post.siteHost === "string" ? post.siteHost : "",
|
|
55
|
+
payload: post.payload,
|
|
56
|
+
compressedPayload: hasGzip || hasBrotli
|
|
57
|
+
? { gzip: post[COMPRESSED_PAYLOAD_GZ_FIELD], brotli: post[COMPRESSED_PAYLOAD_BR_FIELD], declaredBytes: post[COMPRESSED_PAYLOAD_BYTES_FIELD] }
|
|
58
|
+
: null,
|
|
59
|
+
// Arrays are objects, and an array answers for its own properties, so
|
|
60
|
+
// a guard named "length" received a number where the client sent it
|
|
61
|
+
// nothing. Same reasoning as reading the map with hasOwnProperty.
|
|
62
|
+
guardInputs: guardInputs !== null && typeof guardInputs === "object" && !Array.isArray(guardInputs) ? guardInputs : undefined,
|
|
63
|
+
idempotencyKey: post.idempotencyKey,
|
|
64
|
+
headers: info.headers,
|
|
65
|
+
cookies: info.cookies,
|
|
66
|
+
ip: info.ip,
|
|
67
|
+
host: info.host,
|
|
68
|
+
...(info.signal ? { signal: info.signal } : {}),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
|
|
73
|
+
* beside `payloadBytes`) onto request.payload, so every later stage
|
|
74
|
+
* (rate-limit key slices, guards, input validation, the handler) reads an
|
|
75
|
+
* ordinary payload and needs no awareness of the wire format. The field
|
|
76
|
+
* names the encoding; a request carrying both is refused. A request that
|
|
77
|
+
* sent a plain payload passes through untouched.
|
|
78
|
+
*
|
|
79
|
+
* Every failure answers with a message instead of throwing: a malformed body
|
|
80
|
+
* is a client error, not a crash. The declared byte length both bounds the
|
|
81
|
+
* decompression and verifies it, so an over-large or tampered body is
|
|
82
|
+
* refused rather than expanded. Runs on Node through zlib and in a browser
|
|
83
|
+
* through DecompressionStream, under the same bound.
|
|
84
|
+
*/
|
|
85
|
+
export const restoreCompressedPayload = async (request, maxPayloadBytes) => {
|
|
86
|
+
const fields = request.compressedPayload;
|
|
87
|
+
if (!fields)
|
|
88
|
+
return { ok: true };
|
|
89
|
+
const hasGzip = fields.gzip !== undefined;
|
|
90
|
+
const hasBrotli = fields.brotli !== undefined;
|
|
91
|
+
if (hasGzip && hasBrotli) {
|
|
92
|
+
return { ok: false, message: `Request carries both ${COMPRESSED_PAYLOAD_GZ_FIELD} and ${COMPRESSED_PAYLOAD_BR_FIELD}; send one.` };
|
|
93
|
+
}
|
|
94
|
+
const fieldName = hasGzip ? COMPRESSED_PAYLOAD_GZ_FIELD : COMPRESSED_PAYLOAD_BR_FIELD;
|
|
95
|
+
const encoding = hasGzip ? "gzip" : "br";
|
|
96
|
+
const compressed = hasGzip ? fields.gzip : fields.brotli;
|
|
97
|
+
if (typeof compressed !== "string") {
|
|
98
|
+
return { ok: false, message: `Request ${fieldName} must be a base64 string.` };
|
|
99
|
+
}
|
|
100
|
+
const declaredBytes = fields.declaredBytes;
|
|
101
|
+
if (typeof declaredBytes !== "number" || !Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
|
|
102
|
+
return { ok: false, message: `Request ${COMPRESSED_PAYLOAD_BYTES_FIELD} must be the payload's byte length.` };
|
|
103
|
+
}
|
|
104
|
+
if (declaredBytes > maxPayloadBytes) {
|
|
105
|
+
return { ok: false, message: `Request payload of ${declaredBytes} bytes exceeds the ${maxPayloadBytes} byte limit.` };
|
|
106
|
+
}
|
|
107
|
+
// The bound and the exact-length verification are the codec's, the same
|
|
108
|
+
// ones a stored record gets; only the wording of the refusal is ours.
|
|
109
|
+
let json;
|
|
110
|
+
try {
|
|
111
|
+
json = await restoreText(base64ToBytes(compressed), encoding, { declaredBytes });
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const reason = err instanceof LambderCompressionError ? err.reason : null;
|
|
115
|
+
return { ok: false, message: reason === LAMBDER_RESTORE_FAILURES.lengthMismatch
|
|
116
|
+
? "Compressed request payload does not match its declared length."
|
|
117
|
+
: "Compressed request payload could not be decompressed." };
|
|
118
|
+
}
|
|
119
|
+
let payload;
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(json);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { ok: false, message: "Compressed request payload is not valid JSON." };
|
|
125
|
+
}
|
|
126
|
+
request.payload = payload;
|
|
127
|
+
request.compressedPayload = null;
|
|
128
|
+
return { ok: true };
|
|
129
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
3
|
+
/**
|
|
4
|
+
* A rejected input, thrown as a typed refusal rather than built as a
|
|
5
|
+
* response. The API's own schema and every preflight slice (a guard's input,
|
|
6
|
+
* a rate-limit key's fields) throw this when a value fails to parse, and the
|
|
7
|
+
* pipeline renders it in one place: through the app's input validation
|
|
8
|
+
* handler when it set one, otherwise as the standard 422 body. The engines
|
|
9
|
+
* therefore never build a response and never see a resolver, which is what
|
|
10
|
+
* lets them run outside a Lambda.
|
|
11
|
+
*
|
|
12
|
+
* A LambderApiRefusal, so every catch that maps refusals already handles it;
|
|
13
|
+
* the brand tells the pipeline to route it through the validation handler
|
|
14
|
+
* instead of the refusal envelope.
|
|
15
|
+
*/
|
|
16
|
+
export declare class LambderApiValidationRefusal extends LambderApiRefusal {
|
|
17
|
+
/** Brand for detection across duplicate lambder installs, like LambderApiRefusal's. */
|
|
18
|
+
readonly isLambderApiValidationRefusal = true;
|
|
19
|
+
readonly zodError: z.ZodError;
|
|
20
|
+
constructor(zodError: z.ZodError);
|
|
21
|
+
}
|
|
22
|
+
/** Brand-based type guard (see LambderApiValidationRefusal.isLambderApiValidationRefusal). */
|
|
23
|
+
export declare const isLambderApiValidationRefusal: (err: unknown) => err is LambderApiValidationRefusal;
|
|
24
|
+
/**
|
|
25
|
+
* Validate a preflight input slice (an apiInput slice of the raw payload, or
|
|
26
|
+
* a guardInput value from the raw guardInputs map). Runs before the API's
|
|
27
|
+
* own validation; a failure throws the same LambderApiValidationRefusal the
|
|
28
|
+
* API's schema throws, so the pipeline answers every rejected input alike.
|
|
29
|
+
* Shared by the guards engine and the rate-limit engine, and living here
|
|
30
|
+
* beside the error it throws rather than in one of the two.
|
|
31
|
+
*/
|
|
32
|
+
export declare const parsePreflightSlice: (input: z.ZodType, value: unknown) => unknown;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { LambderApiRefusal, isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
2
|
+
/**
|
|
3
|
+
* A rejected input, thrown as a typed refusal rather than built as a
|
|
4
|
+
* response. The API's own schema and every preflight slice (a guard's input,
|
|
5
|
+
* a rate-limit key's fields) throw this when a value fails to parse, and the
|
|
6
|
+
* pipeline renders it in one place: through the app's input validation
|
|
7
|
+
* handler when it set one, otherwise as the standard 422 body. The engines
|
|
8
|
+
* therefore never build a response and never see a resolver, which is what
|
|
9
|
+
* lets them run outside a Lambda.
|
|
10
|
+
*
|
|
11
|
+
* A LambderApiRefusal, so every catch that maps refusals already handles it;
|
|
12
|
+
* the brand tells the pipeline to route it through the validation handler
|
|
13
|
+
* instead of the refusal envelope.
|
|
14
|
+
*/
|
|
15
|
+
export class LambderApiValidationRefusal extends LambderApiRefusal {
|
|
16
|
+
/** Brand for detection across duplicate lambder installs, like LambderApiRefusal's. */
|
|
17
|
+
isLambderApiValidationRefusal = true;
|
|
18
|
+
zodError;
|
|
19
|
+
constructor(zodError) {
|
|
20
|
+
super("Input validation failed", { statusCode: 422 });
|
|
21
|
+
this.name = "LambderApiValidationRefusal";
|
|
22
|
+
this.zodError = zodError;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Brand-based type guard (see LambderApiValidationRefusal.isLambderApiValidationRefusal). */
|
|
26
|
+
export const isLambderApiValidationRefusal = (err) => isLambderApiRefusal(err) && err.isLambderApiValidationRefusal === true;
|
|
27
|
+
/**
|
|
28
|
+
* Validate a preflight input slice (an apiInput slice of the raw payload, or
|
|
29
|
+
* a guardInput value from the raw guardInputs map). Runs before the API's
|
|
30
|
+
* own validation; a failure throws the same LambderApiValidationRefusal the
|
|
31
|
+
* API's schema throws, so the pipeline answers every rejected input alike.
|
|
32
|
+
* Shared by the guards engine and the rate-limit engine, and living here
|
|
33
|
+
* beside the error it throws rather than in one of the two.
|
|
34
|
+
*/
|
|
35
|
+
export const parsePreflightSlice = (input, value) => {
|
|
36
|
+
const parsed = input.safeParse(value);
|
|
37
|
+
if (!parsed.success)
|
|
38
|
+
throw new LambderApiValidationRefusal(parsed.error);
|
|
39
|
+
return parsed.data;
|
|
40
|
+
};
|