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