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.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /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
+ };