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
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { joinKeyFields } from "../shared/util/
|
|
2
|
-
import {
|
|
1
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
2
|
+
import { boundKeyField } from "../shared/util/boundKeyField.js";
|
|
3
3
|
import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
4
4
|
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
5
5
|
import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
|
|
6
6
|
import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js";
|
|
7
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
8
|
+
import { DEFAULT_IPV6_RATE_LIMIT_PREFIX, rateLimitSubjectOf } from "../shared/util/LambderClientIp.js";
|
|
7
9
|
const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
8
10
|
/** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
|
|
9
11
|
export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
|
|
@@ -26,11 +28,10 @@ export const rateLimitRefusal = (detail, retryAfterSeconds, message) => new Lamb
|
|
|
26
28
|
* exposes it as `rateLimitKey`.
|
|
27
29
|
*
|
|
28
30
|
* Bound rather than left open because the engine hands the handler whatever
|
|
29
|
-
* context the adapter runs on, and the two adapters
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* written to prove silently proved nothing.
|
|
31
|
+
* context the adapter runs on, and the two adapters differ. A builder pinned
|
|
32
|
+
* to the server's context type would compile against the mock, then receive
|
|
33
|
+
* a context with no `ip`, `method` or `path`: every caller would share one
|
|
34
|
+
* counter and a test of the limit would silently prove nothing.
|
|
34
35
|
*/
|
|
35
36
|
export const lambderRateLimitKeyBuilder = () => ((key) => key);
|
|
36
37
|
/** Normalize the three rateLimit-option forms into ordered entries; an explicit `undefined` map value declares nothing. */
|
|
@@ -45,11 +46,10 @@ const toRateLimitEntries = (value) => {
|
|
|
45
46
|
};
|
|
46
47
|
/**
|
|
47
48
|
* A limiter is handed only limits it can act on, so no implementation has to
|
|
48
|
-
* invent an answer for a nonsense one
|
|
49
|
-
* one refusing the first attempt against a negative cap and
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* positive one.
|
|
49
|
+
* invent an answer for a nonsense one (where two limiters would drift apart,
|
|
50
|
+
* one refusing the first attempt against a negative cap and one allowing it).
|
|
51
|
+
* Zero is legal and leaves the window unenforced, hence non-negative rather
|
|
52
|
+
* than positive.
|
|
53
53
|
*/
|
|
54
54
|
const assertWindowLimits = (subject, windows) => {
|
|
55
55
|
for (const key of RATE_LIMIT_WINDOW_KEYS) {
|
|
@@ -60,91 +60,119 @@ const assertWindowLimits = (subject, windows) => {
|
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
62
|
const hasWindowOverride = (override) => RATE_LIMIT_WINDOW_KEYS.some((key) => override[key] !== undefined);
|
|
63
|
-
const phaseOf = (per) =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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}`;
|
|
63
|
+
const phaseOf = (per, chargeAt) => {
|
|
64
|
+
if (per === "ip")
|
|
65
|
+
return "beforeSession";
|
|
66
|
+
if (per === "session")
|
|
67
|
+
return "beforeGuards";
|
|
68
|
+
return chargeAt ?? "afterGuards";
|
|
69
|
+
};
|
|
93
70
|
/** The windows one check ran against, for a log line that must not carry the tracker key. */
|
|
94
71
|
const describeWindows = (limits) => RATE_LIMIT_WINDOW_KEYS.filter((key) => limits[key] !== undefined).map((key) => `${key}: ${String(limits[key])}`).join(", ") || "no window";
|
|
72
|
+
/** The windows a check runs against: the policy's own, each replaced by the API's override where it names one. */
|
|
73
|
+
const windowsOf = (policy, override) => {
|
|
74
|
+
const limits = {};
|
|
75
|
+
for (const windowKey of RATE_LIMIT_WINDOW_KEYS) {
|
|
76
|
+
const limit = override?.[windowKey] ?? policy[windowKey];
|
|
77
|
+
if (limit !== undefined)
|
|
78
|
+
limits[windowKey] = limit;
|
|
79
|
+
}
|
|
80
|
+
return limits;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The counter one check charges. "perPolicy" shares one counter across every
|
|
84
|
+
* API referencing the policy; "perApi" keys each API separately, which is
|
|
85
|
+
* also what lets an API override the windows without colliding. A charge
|
|
86
|
+
* made outside an API call (a route's handler) has no API to be counted
|
|
87
|
+
* under, so it counts against the policy's shared counter.
|
|
88
|
+
*/
|
|
89
|
+
const trackerKeyOf = (name, policy, apiName, key) => policy.budget === "perPolicy" || apiName === null
|
|
90
|
+
? joinKeyFields("policy", name, key)
|
|
91
|
+
: joinKeyFields("api", apiName, name, key);
|
|
95
92
|
/**
|
|
96
93
|
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
97
94
|
* policies, asserts API registrations against them at startup, and checks an
|
|
98
95
|
* API's declared policies during preflight. Composed into
|
|
99
|
-
*
|
|
100
|
-
* and
|
|
101
|
-
*
|
|
96
|
+
* LambderApiPipeline. Reads the request (its ip, and a key's payload
|
|
97
|
+
* slice) and hands the context to a custom key's handler, reading nothing
|
|
98
|
+
* of the context itself but its session, so it runs unchanged under the
|
|
99
|
+
* server and the mock runtime.
|
|
102
100
|
*/
|
|
103
101
|
export class LambderApiRateLimitsEngine {
|
|
104
102
|
limiter = null;
|
|
105
103
|
failOpen = true;
|
|
106
|
-
|
|
107
|
-
// answers for "toString" and
|
|
108
|
-
//
|
|
109
|
-
// fail on every request
|
|
104
|
+
ipv6PrefixLength = DEFAULT_IPV6_RATE_LIMIT_PREFIX;
|
|
105
|
+
// A Map, not a plain object: an object answers for "toString" and
|
|
106
|
+
// "constructor" through its prototype, so a policy named one of those
|
|
107
|
+
// would slip past the registration check and fail on every request.
|
|
110
108
|
policies = new Map();
|
|
109
|
+
/**
|
|
110
|
+
* The limiter failures already logged. A limiter answering a run of
|
|
111
|
+
* requests with one continuing failure throws the same error for each
|
|
112
|
+
* (see LambderRateLimiter), and a flood of a few thousand requests a
|
|
113
|
+
* second would otherwise be as many identical log lines.
|
|
114
|
+
*/
|
|
115
|
+
loggedFailures = new WeakSet();
|
|
111
116
|
/** True once rateLimits were configured. */
|
|
112
117
|
get isConfigured() { return this.limiter !== null; }
|
|
113
118
|
configure(config) {
|
|
114
119
|
if (this.limiter)
|
|
115
120
|
throw new Error("Lambder: rateLimits were already configured.");
|
|
116
|
-
//
|
|
117
|
-
// `rateLimit: {}
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// had never been given, which sends the reader to the wrong line.
|
|
121
|
+
// Declaring the option is declaring a limit, as for the guards option
|
|
122
|
+
// and an API's own `rateLimit: {}`. An empty map would configure a
|
|
123
|
+
// limiter with nothing to enforce, and every API naming a policy would
|
|
124
|
+
// be reported as if the option were missing: the wrong line to fix.
|
|
121
125
|
if (Object.keys(config.policies).length === 0) {
|
|
122
126
|
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
127
|
}
|
|
124
128
|
for (const [name, policy] of Object.entries(config.policies)) {
|
|
125
129
|
const per = policy.per;
|
|
126
|
-
if (
|
|
127
|
-
throw new Error(`Lambder: rate-limit policy "${name}"
|
|
130
|
+
if (per !== undefined && per !== "ip" && per !== "session" && typeof per?.handler !== "function") {
|
|
131
|
+
throw new Error(`Lambder: rate-limit policy "${name}" has a per that is not "ip", "session", or a { apiInput?, handler } key. Leave per out for a policy the handler charges with its own key.`);
|
|
128
132
|
}
|
|
129
133
|
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
130
134
|
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
131
135
|
}
|
|
132
136
|
assertWindowLimits(`rate-limit policy "${name}"`, policy);
|
|
137
|
+
const chargeAt = policy.chargeAt;
|
|
138
|
+
if (chargeAt !== undefined) {
|
|
139
|
+
if (chargeAt !== "beforeGuards" && chargeAt !== "afterGuards") {
|
|
140
|
+
throw new Error(`Lambder: rate-limit policy "${name}" has chargeAt "${String(chargeAt)}"; use "afterGuards" (default) or "beforeGuards".`);
|
|
141
|
+
}
|
|
142
|
+
if (per === undefined || per === "ip" || per === "session") {
|
|
143
|
+
throw new Error(`Lambder: rate-limit policy "${name}" sets chargeAt, which only a policy keyed by a { apiInput?, handler } key takes: per "ip" and per "session" each run at one fixed place, and a policy without per is charged by the code that names it.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
const budget = policy.budget;
|
|
134
147
|
if (budget !== undefined && budget !== "perApi" && budget !== "perPolicy") {
|
|
135
148
|
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
149
|
}
|
|
137
150
|
}
|
|
151
|
+
const ipv6PrefixLength = config.ipv6PrefixLength ?? DEFAULT_IPV6_RATE_LIMIT_PREFIX;
|
|
152
|
+
if (!Number.isInteger(ipv6PrefixLength) || ipv6PrefixLength < 1 || ipv6PrefixLength > 128) {
|
|
153
|
+
throw new Error(`Lambder: rateLimits.ipv6PrefixLength must be a whole number from 1 to 128, got ${String(ipv6PrefixLength)}.`);
|
|
154
|
+
}
|
|
138
155
|
this.limiter = config.limiter;
|
|
139
156
|
this.failOpen = config.failOpen ?? true;
|
|
157
|
+
this.ipv6PrefixLength = ipv6PrefixLength;
|
|
140
158
|
this.policies = new Map(Object.entries(config.policies));
|
|
141
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Puts the engine over another limiter, for `lambder/testing`; the named
|
|
162
|
+
* policies and failOpen stay as configured. False when rateLimits were
|
|
163
|
+
* never configured: there is nothing for a limiter to sit under.
|
|
164
|
+
*/
|
|
165
|
+
[LAMBDER_BACKEND_SWAP](limiter) {
|
|
166
|
+
if (!this.limiter)
|
|
167
|
+
return false;
|
|
168
|
+
this.limiter = limiter;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
142
171
|
/** Startup validation of one API registration's rateLimit option. */
|
|
143
172
|
assertRegistration(apiName, mode, rateLimitOption) {
|
|
144
173
|
const entries = toRateLimitEntries(rateLimitOption);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// present-but-empty, and would otherwise register an API that
|
|
174
|
+
// Declaring the option is declaring a limit, as for guards: `{}`,
|
|
175
|
+
// `[]` and `{ name: undefined }` would register an API that
|
|
148
176
|
// announces a rate limit and enforces none.
|
|
149
177
|
if (rateLimitOption !== undefined && entries.length === 0) {
|
|
150
178
|
throw new Error(`Lambder: API "${apiName}" declares an empty rateLimit option, which limits nothing. ` +
|
|
@@ -155,6 +183,9 @@ export class LambderApiRateLimitsEngine {
|
|
|
155
183
|
if (!policy) {
|
|
156
184
|
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
|
|
157
185
|
}
|
|
186
|
+
if (policy.per === undefined) {
|
|
187
|
+
throw new Error(`Lambder: API "${apiName}" references rate-limit policy "${name}", which declares no per: its key is the one a handler passes to ctx.rateLimit("${name}", key), so the request alone cannot be counted against it.`);
|
|
188
|
+
}
|
|
158
189
|
if (policy.per === "session" && mode !== "session") {
|
|
159
190
|
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
|
|
160
191
|
}
|
|
@@ -176,64 +207,122 @@ export class LambderApiRateLimitsEngine {
|
|
|
176
207
|
* counter, when a later guard or validation refuses) keeps its increment,
|
|
177
208
|
* so list first the policy you want charged on refusals.
|
|
178
209
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
210
|
+
* Runs once per phase (see LambderRateLimitPhase), keeping declared order
|
|
211
|
+
* within each: `per: "ip"` before the session read, so a flood of bogus
|
|
212
|
+
* session cookies never reaches the session store; `per: "session"` after
|
|
213
|
+
* it; a custom key after the guards unless its policy's `chargeAt` says
|
|
214
|
+
* "beforeGuards", so a caller they refuse cannot spend somebody else's
|
|
215
|
+
* budget.
|
|
185
216
|
*/
|
|
186
217
|
async run(apiName, request, ctx, rateLimitOption, phase) {
|
|
187
218
|
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
219
|
+
// Registration refused an unknown policy and one without per.
|
|
188
220
|
const policy = this.policies.get(name);
|
|
189
|
-
|
|
190
|
-
|
|
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);
|
|
221
|
+
const per = policy.per;
|
|
222
|
+
if (phaseOf(per, policy.chargeAt) !== phase)
|
|
219
223
|
continue;
|
|
220
|
-
|
|
224
|
+
const key = await this.resolveKey(name, request, ctx, per);
|
|
225
|
+
const limits = windowsOf(policy, override);
|
|
226
|
+
const exceeded = await this.countAttempt(name, trackerKeyOf(name, policy, apiName, key), limits, `API "${apiName}"`);
|
|
221
227
|
if (exceeded) {
|
|
222
|
-
|
|
223
|
-
throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, override?.errorMessage ?? policy.errorMessage);
|
|
228
|
+
throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, this.retryAfterOf(exceeded), override?.errorMessage ?? policy.errorMessage);
|
|
224
229
|
}
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
/**
|
|
233
|
+
* One policy charged by code rather than by a declaration, for
|
|
234
|
+
* `ctx.rateLimit` and `ctx.isRateLimited`. Counters, failOpen, key
|
|
235
|
+
* bounding and refusal are those of a declared limit; only who knows the
|
|
236
|
+
* key differs. A policy without `per` takes the key the code passes, a
|
|
237
|
+
* `per: "ip"` or `per: "session"` one reads it off the request, and one
|
|
238
|
+
* keyed by an API payload is refused, since only its APIs know the key.
|
|
239
|
+
*/
|
|
240
|
+
async chargePolicy(name, subject) {
|
|
241
|
+
if (!this.limiter)
|
|
242
|
+
throw new Error(`Lambder: charging rate-limit policy "${name}" needs the rateLimits option at creation.`);
|
|
243
|
+
const policy = this.policies.get(name);
|
|
244
|
+
if (!policy)
|
|
245
|
+
throw new Error(`Lambder: unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
|
|
246
|
+
const key = await this.chargeKeyOf(name, policy, subject);
|
|
247
|
+
const limits = windowsOf(policy);
|
|
248
|
+
const where = subject.apiName === null ? "a route" : `API "${subject.apiName}"`;
|
|
249
|
+
const exceeded = await this.countAttempt(name, trackerKeyOf(name, policy, subject.apiName, key), limits, where);
|
|
250
|
+
if (!exceeded)
|
|
251
|
+
return { checkResult: false, refusal: null };
|
|
252
|
+
const retryAfterSeconds = this.retryAfterOf(exceeded);
|
|
253
|
+
return {
|
|
254
|
+
checkResult: { ...exceeded, retryAfterSeconds },
|
|
255
|
+
refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.errorMessage),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Seconds until the refusing window resets, read against the limiter's
|
|
260
|
+
* own clock when it keeps one: `resetAt` is a second on that clock, and a
|
|
261
|
+
* limiter under a test clock would otherwise be told a Retry-After
|
|
262
|
+
* measured from another time entirely.
|
|
263
|
+
*/
|
|
264
|
+
retryAfterOf(exceeded) {
|
|
265
|
+
const nowMilliseconds = this.limiter?.clockMilliseconds?.() ?? Date.now();
|
|
266
|
+
return Math.max(1, exceeded.resetAt - Math.floor(nowMilliseconds / 1000));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Counts one attempt, or lets it through when the limiter itself fails
|
|
270
|
+
* and failOpen is on. The log line names the policy and its windows,
|
|
271
|
+
* never the tracker key: the key carries whatever a custom handler
|
|
272
|
+
* returned, which the docs' own example makes an email address. A
|
|
273
|
+
* failure the limiter throws again (see loggedFailures) is not logged
|
|
274
|
+
* again.
|
|
275
|
+
*/
|
|
276
|
+
async countAttempt(name, trackerKey, limits, where) {
|
|
277
|
+
try {
|
|
278
|
+
return await this.limiter.isRateLimited(trackerKey, limits);
|
|
235
279
|
}
|
|
236
|
-
|
|
237
|
-
|
|
280
|
+
catch (limiterErr) {
|
|
281
|
+
if (!this.failOpen)
|
|
282
|
+
throw limiterErr;
|
|
283
|
+
if (typeof limiterErr === "object" && limiterErr !== null) {
|
|
284
|
+
if (this.loggedFailures.has(limiterErr))
|
|
285
|
+
return false;
|
|
286
|
+
this.loggedFailures.add(limiterErr);
|
|
287
|
+
}
|
|
288
|
+
console.error(`Lambder rate limits: policy "${name}" (${describeWindows(limits)}) could not be checked for ${where}; ` +
|
|
289
|
+
"the request is being allowed through. Set rateLimits.failOpen: false to refuse instead.", limiterErr);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async chargeKeyOf(name, policy, subject) {
|
|
294
|
+
const per = policy.per;
|
|
295
|
+
if (per === undefined) {
|
|
296
|
+
if (subject.key === undefined)
|
|
297
|
+
throw new Error(`Lambder: rate-limit policy "${name}" declares no per, so the code charging it passes the key: ctx.rateLimit("${name}", key).`);
|
|
298
|
+
return await boundKeyField("custom", subject.key);
|
|
299
|
+
}
|
|
300
|
+
if (per !== "ip" && per !== "session") {
|
|
301
|
+
throw new Error(`Lambder: rate-limit policy "${name}" derives its key from an API's payload, so only the APIs declaring it can charge it.`);
|
|
302
|
+
}
|
|
303
|
+
if (subject.key !== undefined)
|
|
304
|
+
throw new Error(`Lambder: rate-limit policy "${name}" is keyed per "${per}", so charging it takes no key.`);
|
|
305
|
+
return await this.requestKeyOf(name, per, subject.ip, subject.session);
|
|
306
|
+
}
|
|
307
|
+
async resolveKey(name, request, ctx, per) {
|
|
308
|
+
if (per === "ip" || per === "session")
|
|
309
|
+
return await this.requestKeyOf(name, per, request.ip, ctx.session);
|
|
310
|
+
const payload = per.apiInput ? await parsePreflightSlice(per.apiInput, request.payload) : undefined;
|
|
311
|
+
return await boundKeyField("custom", await per.handler(ctx, payload));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The key a `per: "ip"` or `per: "session"` policy counts under: what the
|
|
315
|
+
* request carries, however the policy is charged. A session key is
|
|
316
|
+
* bounded like a custom one (see boundKeyField); an address needs no
|
|
317
|
+
* bound, since normalizeClientIp caps it at 45 characters whichever
|
|
318
|
+
* header or gateway field named it.
|
|
319
|
+
*/
|
|
320
|
+
async requestKeyOf(name, per, ip, session) {
|
|
321
|
+
if (per === "ip")
|
|
322
|
+
return `ip:${rateLimitSubjectOf(ip, this.ipv6PrefixLength)}`;
|
|
323
|
+
const sessionKey = session?.sessionKey;
|
|
324
|
+
if (!sessionKey)
|
|
325
|
+
throw new Error(`Lambder: rate-limit policy "${name}" is keyed per "session", and the request charging it has no session.`);
|
|
326
|
+
return await boundKeyField("session", sessionKey);
|
|
238
327
|
}
|
|
239
328
|
}
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
* Cookie header pairs (`name=value`) as every value per name, in header
|
|
3
3
|
* order. Parsed pair by pair, because a whole-header parse keeps only the
|
|
4
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* request's cookies through this one function.
|
|
5
|
+
* controller weighs every copy. The map has no prototype: a cookie named
|
|
6
|
+
* `__proto__` or `constructor` must land as its own key rather than resolve
|
|
7
|
+
* to Object.prototype, or one planted cookie would 500 every request. Every
|
|
8
|
+
* adapter builds its request's cookies through this one function.
|
|
10
9
|
*/
|
|
11
10
|
export declare const cookieValuesByName: (pairs: readonly string[]) => Record<string, string[]>;
|
|
12
11
|
/** Request headers under lowercased names, so a lookup never depends on how the gateway spelled them. */
|
|
@@ -34,10 +33,9 @@ export type LambderApiRequest = {
|
|
|
34
33
|
guardInputs: Record<string, unknown> | undefined;
|
|
35
34
|
/**
|
|
36
35
|
* The idempotency key exactly as posted, so `unknown`: it is client data,
|
|
37
|
-
* and the shape check
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* there is had to widen it back before it could check anything.
|
|
36
|
+
* and the shape check and its client-facing 400 belong to the idempotency
|
|
37
|
+
* engine. A string type here would entitle every reader to treat a
|
|
38
|
+
* posted number or object as a key.
|
|
41
39
|
*/
|
|
42
40
|
idempotencyKey: unknown;
|
|
43
41
|
/** Request headers, names lowercased. */
|
|
@@ -49,10 +47,9 @@ export type LambderApiRequest = {
|
|
|
49
47
|
/** The Host the request was made to. */
|
|
50
48
|
host: string;
|
|
51
49
|
/**
|
|
52
|
-
* The caller's abort signal,
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* adapter that does own one is the layer that knows what abandoning a
|
|
50
|
+
* The caller's abort signal, for adapters that have one (the mock runtime
|
|
51
|
+
* aborts its latency wait with it). The pipeline ignores it: a Lambda
|
|
52
|
+
* invocation has no signal, and only the adapter knows what abandoning a
|
|
56
53
|
* half-run call means for it.
|
|
57
54
|
*/
|
|
58
55
|
signal?: AbortSignal;
|
|
@@ -71,6 +68,16 @@ export type LambderApiRequestInfo = {
|
|
|
71
68
|
host: string;
|
|
72
69
|
signal?: AbortSignal;
|
|
73
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether a POST's Content-Type makes it an API call: application/json,
|
|
73
|
+
* which a browser sends cross-origin only after a CORS preflight. A form
|
|
74
|
+
* (text/plain, urlencoded, multipart) needs none, so any website could post
|
|
75
|
+
* one and, read as an API call, call a public login and plant the
|
|
76
|
+
* attacker's session cookies in a visitor's browser. Every Lambder caller
|
|
77
|
+
* sends application/json; the server and the mock's HTTP-shaped adapters
|
|
78
|
+
* read the same rule here.
|
|
79
|
+
*/
|
|
80
|
+
export declare const isApiCallContentType: (lowercasedHeaders: Record<string, string | undefined>) => boolean;
|
|
74
81
|
/**
|
|
75
82
|
* Reads the posted envelope into a request. Null when the body carries no
|
|
76
83
|
* apiName, which is how the server tells an API call from a route with a
|
|
@@ -90,14 +97,13 @@ export type LambderRestorePayloadResult = {
|
|
|
90
97
|
* Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
|
|
91
98
|
* beside `payloadBytes`) onto request.payload, so every later stage
|
|
92
99
|
* (rate-limit key slices, guards, input validation, the handler) reads an
|
|
93
|
-
* ordinary payload
|
|
94
|
-
*
|
|
95
|
-
* sent a plain payload passes through untouched.
|
|
100
|
+
* ordinary payload. The field names the encoding; a request carrying both is
|
|
101
|
+
* refused. A plain payload passes through untouched.
|
|
96
102
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
103
|
+
* Failures return a message instead of throwing: a malformed body is a
|
|
104
|
+
* client error, not a crash. The declared byte length both bounds and
|
|
105
|
+
* verifies the decompression, so an over-large or tampered body is refused
|
|
106
|
+
* rather than expanded. Runs on Node (zlib) and in a browser
|
|
107
|
+
* (DecompressionStream) under the same bound.
|
|
102
108
|
*/
|
|
103
109
|
export declare const restoreCompressedPayload: (request: LambderApiRequest, maxPayloadBytes: number) => Promise<LambderRestorePayloadResult>;
|
|
@@ -6,11 +6,10 @@ import { restoreText, LambderCompressionError, LAMBDER_RESTORE_FAILURES } from "
|
|
|
6
6
|
* Cookie header pairs (`name=value`) as every value per name, in header
|
|
7
7
|
* order. Parsed pair by pair, because a whole-header parse keeps only the
|
|
8
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* request's cookies through this one function.
|
|
9
|
+
* controller weighs every copy. The map has no prototype: a cookie named
|
|
10
|
+
* `__proto__` or `constructor` must land as its own key rather than resolve
|
|
11
|
+
* to Object.prototype, or one planted cookie would 500 every request. Every
|
|
12
|
+
* adapter builds its request's cookies through this one function.
|
|
14
13
|
*/
|
|
15
14
|
export const cookieValuesByName = (pairs) => {
|
|
16
15
|
const cookies = Object.create(null);
|
|
@@ -24,9 +23,8 @@ export const cookieValuesByName = (pairs) => {
|
|
|
24
23
|
};
|
|
25
24
|
/** Request headers under lowercased names, so a lookup never depends on how the gateway spelled them. */
|
|
26
25
|
export const lowercaseHeaderNames = (headers) => {
|
|
27
|
-
// Prototype-free
|
|
28
|
-
//
|
|
29
|
-
// than reach Object.prototype's setter and vanish.
|
|
26
|
+
// Prototype-free like the cookie map: a header named `__proto__` must
|
|
27
|
+
// land as a key rather than reach Object.prototype's setter and vanish.
|
|
30
28
|
const lowered = Object.create(null);
|
|
31
29
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
32
30
|
if (value !== undefined)
|
|
@@ -34,6 +32,16 @@ export const lowercaseHeaderNames = (headers) => {
|
|
|
34
32
|
}
|
|
35
33
|
return lowered;
|
|
36
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Whether a POST's Content-Type makes it an API call: application/json,
|
|
37
|
+
* which a browser sends cross-origin only after a CORS preflight. A form
|
|
38
|
+
* (text/plain, urlencoded, multipart) needs none, so any website could post
|
|
39
|
+
* one and, read as an API call, call a public login and plant the
|
|
40
|
+
* attacker's session cookies in a visitor's browser. Every Lambder caller
|
|
41
|
+
* sends application/json; the server and the mock's HTTP-shaped adapters
|
|
42
|
+
* read the same rule here.
|
|
43
|
+
*/
|
|
44
|
+
export const isApiCallContentType = (lowercasedHeaders) => (lowercasedHeaders["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
|
|
37
45
|
/**
|
|
38
46
|
* Reads the posted envelope into a request. Null when the body carries no
|
|
39
47
|
* apiName, which is how the server tells an API call from a route with a
|
|
@@ -57,9 +65,9 @@ export const readApiEnvelope = (post, info) => {
|
|
|
57
65
|
compressedPayload: hasGzip || hasBrotli
|
|
58
66
|
? { gzip: post[COMPRESSED_PAYLOAD_GZ_FIELD], brotli: post[COMPRESSED_PAYLOAD_BR_FIELD], declaredBytes: post[COMPRESSED_PAYLOAD_BYTES_FIELD] }
|
|
59
67
|
: null,
|
|
60
|
-
// Arrays are
|
|
61
|
-
//
|
|
62
|
-
//
|
|
68
|
+
// Arrays are refused: an array answers for its own properties, so a
|
|
69
|
+
// guard named "length" would receive a number the client never sent.
|
|
70
|
+
// Same reasoning as reading the map with hasOwnProperty.
|
|
63
71
|
guardInputs: guardInputs !== null && typeof guardInputs === "object" && !Array.isArray(guardInputs) ? guardInputs : undefined,
|
|
64
72
|
idempotencyKey: post.idempotencyKey,
|
|
65
73
|
headers: info.headers,
|
|
@@ -73,15 +81,14 @@ export const readApiEnvelope = (post, info) => {
|
|
|
73
81
|
* Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
|
|
74
82
|
* beside `payloadBytes`) onto request.payload, so every later stage
|
|
75
83
|
* (rate-limit key slices, guards, input validation, the handler) reads an
|
|
76
|
-
* ordinary payload
|
|
77
|
-
*
|
|
78
|
-
* sent a plain payload passes through untouched.
|
|
84
|
+
* ordinary payload. The field names the encoding; a request carrying both is
|
|
85
|
+
* refused. A plain payload passes through untouched.
|
|
79
86
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
* Failures return a message instead of throwing: a malformed body is a
|
|
88
|
+
* client error, not a crash. The declared byte length both bounds and
|
|
89
|
+
* verifies the decompression, so an over-large or tampered body is refused
|
|
90
|
+
* rather than expanded. Runs on Node (zlib) and in a browser
|
|
91
|
+
* (DecompressionStream) under the same bound.
|
|
85
92
|
*/
|
|
86
93
|
export const restoreCompressedPayload = async (request, maxPayloadBytes) => {
|
|
87
94
|
const fields = request.compressedPayload;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { LambderApiDefinition } from "./LambderApiDefinition.js";
|
|
2
2
|
import { type LambderApiGuard } from "./LambderApiGuards.js";
|
|
3
3
|
/**
|
|
4
|
-
* One endpoint as the generator sees it: the key
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* view of the same data, and the only place a generated map can be diffed by
|
|
4
|
+
* One endpoint as the generator sees it: the map key, the signature, and the
|
|
5
|
+
* name both were computed from. The shipped map deliberately omits the name,
|
|
6
|
+
* so this build-time view is the only place a generated map can be diffed by
|
|
8
7
|
* endpoint rather than by opaque key.
|
|
9
8
|
*/
|
|
10
9
|
export type LambderApiSignatureEntry = {
|
|
@@ -18,17 +17,15 @@ export type LambderApiSignatureEntry = {
|
|
|
18
17
|
* input and output schemas as JSON Schema, every guard it declares with the
|
|
19
18
|
* schema that guard validates (the guardInput the client sends separately,
|
|
20
19
|
* or the apiInput slice of the payload), and whether it demands an
|
|
21
|
-
* idempotency key. Anything else
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* idempotency key. Anything else (rate limits, a guard's parameter, the
|
|
21
|
+
* handler) changes nothing for a client and is left out, so changing it never
|
|
22
|
+
* forces a reload.
|
|
24
23
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* at construction digests differently in the generator's process and on the
|
|
32
|
-
* server.
|
|
24
|
+
* Schemas are hashed as built, descriptions and titles included, so a client
|
|
25
|
+
* built against a different one reloads once. The exception is an output
|
|
26
|
+
* extensibleEnum()'s values (see keepShapeOnly). Schemas must be built from
|
|
27
|
+
* static values: one that reads the clock, a random source or the
|
|
28
|
+
* environment at construction digests differently in the generator's process
|
|
29
|
+
* and on the server.
|
|
33
30
|
*/
|
|
34
31
|
export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
|