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
@@ -2,12 +2,14 @@ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyEvent
2
2
  import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
3
3
  import { type LambderApiRequest } from "../api/LambderApiRequest.js";
4
4
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
5
+ import type LambderSessionController from "../session/LambderSessionController.js";
6
+ import type { LambderApiRateLimitPolicyConfig, LambderContextRateLimit, LambderContextRateLimitCheck, LambderRateLimitCheckResult } from "../api/LambderApiRateLimits.js";
5
7
  export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
6
8
  /**
7
9
  * Which API Gateway payload format an event arrived in, and the format its
8
- * response leaves in. Declared here, beside the detection it comes from:
9
- * LambderResponse holds the emitters that read it, and having the type there
10
- * as well made the two core modules import each other.
10
+ * response leaves in. Declared here, beside the detection it comes from,
11
+ * rather than beside LambderResponse's emitters that read it: there, the two
12
+ * core modules would import each other.
11
13
  */
12
14
  export type LambderHttpEventFormat = "v1" | "v2";
13
15
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
@@ -16,11 +18,32 @@ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxy
16
18
  * Everything a route or API handler knows about the request. Extends the
17
19
  * API core's call context (session, guardData, responseHeaders, logList),
18
20
  * which is the part the pipeline and the session controller work on; the
19
- * rest is the HTTP request as the Lambda event delivered it.
21
+ * rest is the HTTP request as the Lambda event delivered it, plus the tools
22
+ * the instance rendering it binds on (sessionController, rateLimit, isRateLimited).
23
+ *
24
+ * TRateLimitPolicies is the app's policies map on a handler registered with
25
+ * addApi, addSessionApi, addRoute or addSessionRoute, so a policy name is
26
+ * checked where it is charged; anywhere else (a hook, a guard) the names are
27
+ * any string.
20
28
  */
21
- export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}, TSessionData = any> = {
29
+ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}, TSessionData = any, TRateLimitPolicies = Record<string, LambderApiRateLimitPolicyConfig>> = {
30
+ /**
31
+ * The Host the gateway received, or the first header named in
32
+ * `trustedHostHeaders` that carries a well-formed host. On a direct
33
+ * invoke, the invoking caller's `host`, whatever headers it forwarded.
34
+ */
22
35
  host: string;
36
+ /**
37
+ * The request path, decoded exactly once whichever gateway delivered it
38
+ * (a REST API and a Function URL deliver it encoded, an HTTP API
39
+ * decoded, in either payload format), with two escapes kept: a slash inside a segment stays `%2F`,
40
+ * so it cannot become a separator, and a percent sign stays `%25`, so no
41
+ * decoded text passes for an escape. What routes match and files are
42
+ * looked up by (see LambderRequestPath).
43
+ */
23
44
  path: string;
45
+ /** The path as the gateway delivered it, stage stripped: encoded or not, depending on the gateway. */
46
+ rawPath: string;
24
47
  pathParams: TPathParams;
25
48
  method: string;
26
49
  get: Record<string, string | undefined>;
@@ -36,13 +59,12 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
36
59
  */
37
60
  cookieList: Record<string, string[]>;
38
61
  /**
39
- * The session, once something read or created one: the pipeline sets it on
40
- * a session API, and getSessionController(ctx).createSession writes it
41
- * here too. Null everywhere else, which is why a route or public API reads
42
- * it as `ctx.session?.data`. Typing it as the literal `null` said the
43
- * opposite of what the code does: after createSession the field was still
44
- * `never`, and addSessionRoute needed a double cast to hand the handler
45
- * the same object it already held.
62
+ * The session, once something read or created one: the pipeline sets it
63
+ * on a session API, and ctx.sessionController.createSession writes it here too.
64
+ * Null everywhere else, which is why a route or public API reads it as
65
+ * `ctx.session?.data`. Not typed as the literal `null`, since
66
+ * createSession fills it and addSessionRoute hands the handler this same
67
+ * object.
46
68
  */
47
69
  session: LambderSessionRecord<TSessionData> | null;
48
70
  /**
@@ -68,7 +90,8 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
68
90
  /**
69
91
  * The address the gateway observed, or the leftmost entry of the first
70
92
  * header named in `trustedClientIpHeaders` that carries one; nothing is
71
- * trusted by default. One spelling per address (port and brackets
93
+ * trusted by default, and no header on a direct invoke, whose address is
94
+ * the invoking caller's `clientIp`. One spelling per address (port and brackets
72
95
  * stripped, lowercased, length-bounded), so a `per: "ip"` limit keys one
73
96
  * counter per client.
74
97
  */
@@ -83,9 +106,53 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
83
106
  responseHeaders: LambderAnswerHeaders;
84
107
  /** Entries for the API envelope's logList channel (res.logToApiResponse). */
85
108
  logList: unknown[];
109
+ /**
110
+ * Sessions for this request: read the one it carries
111
+ * (fetchSessionIfExists), create, rotate, refresh and end them. An API
112
+ * call presents its posted CSRF token and a route its cookies alone, the
113
+ * same controller `lambder.getSessionController(ctx)` hands out. Throws
114
+ * when the instance was created without the session option.
115
+ */
116
+ sessionController: LambderSessionController<TSessionData>;
117
+ /**
118
+ * Counts one attempt against a named rate-limit policy and refuses the
119
+ * request when it is over: a 429 envelope on an API call, a plain 429 on
120
+ * a route, with Retry-After and the policy's errorMessage either way. A
121
+ * policy without `per` takes the key as the second argument; a `per:
122
+ * "ip"` or `per: "session"` one reads it off the request. The instance's
123
+ * limiter, failOpen and key bounding apply, as for a declared limit.
124
+ */
125
+ rateLimit: LambderContextRateLimit<TRateLimitPolicies>;
126
+ /** The same count as rateLimit, answered instead of thrown: false, or the window that refused and its retryAfterSeconds. */
127
+ isRateLimited: LambderContextRateLimitCheck<TRateLimitPolicies>;
86
128
  };
87
- export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData, SessionData>, 'session'> & {
129
+ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}, TRateLimitPolicies = Record<string, LambderApiRateLimitPolicyConfig>> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData, SessionData, TRateLimitPolicies>, 'session'> & {
88
130
  session: LambderSessionRecord<SessionData>;
89
131
  };
132
+ /** The members of a render context that belong to the instance rendering the request rather than to its event. */
133
+ type LambderContextToolName = "sessionController" | "rateLimit" | "isRateLimited";
134
+ /** What an instance binds onto each context it renders: see bindContextTools. */
135
+ export type LambderContextTools = {
136
+ sessionControllerFor: (ctx: LambderRenderContext) => LambderSessionController<any>;
137
+ /** Charges a policy for ctx; `refuse` throws the refusal when it is over instead of answering the check result. */
138
+ chargeRateLimit: (ctx: LambderRenderContext, policy: string, key: string | undefined, refuse: boolean) => Promise<LambderRateLimitCheckResult>;
139
+ };
140
+ /**
141
+ * Puts one instance's tools onto a context, bound to that very object, so
142
+ * what they read (the session, the ip, which API the call is) is the
143
+ * context's own. Bound through bindCallTools, as the mock's are, and bound
144
+ * again by the instance on whatever context a beforeRender hook hands back.
145
+ */
146
+ export declare const bindContextTools: (ctx: Omit<LambderRenderContext, LambderContextToolName> | LambderRenderContext, tools: LambderContextTools) => LambderRenderContext;
147
+ /** What createContext reads a request with: the instance's own settings for where an API call goes and which forwarded headers it trusts. */
148
+ export type LambderContextOptions = {
149
+ /** See `apiPath` at create(). Default: "/api", as there. */
150
+ apiPath?: string;
151
+ /** See `trustedClientIpHeaders` at create(). Default: none. */
152
+ trustedClientIpHeaders?: readonly string[];
153
+ /** See `trustedHostHeaders` at create(). Default: none. */
154
+ trustedHostHeaders?: readonly string[];
155
+ };
90
156
  /** The render context for one request: everything a route handler, an API handler, a hook or a guard reads about it, built once from the Lambda event. */
91
- export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string, trustedClientIpHeaders?: readonly string[]) => LambderRenderContext;
157
+ export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, { apiPath, trustedClientIpHeaders, trustedHostHeaders }?: LambderContextOptions) => LambderRenderContext;
158
+ export {};
@@ -1,17 +1,76 @@
1
- import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
1
+ import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
2
2
  import { resolveClientIp } from "../shared/util/LambderClientIp.js";
3
3
  import { base64ToText } from "../shared/util/LambderBase64.js";
4
4
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
5
+ import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
6
+ import { LAMBDER_INVOKE_API_ID, LAMBDER_LOCAL_API_ID } from "../shared/wire/LambderInvokeApiId.js";
7
+ import { bindCallTools } from "../api/LambderApiCallContext.js";
8
+ import { decodeRequestPath } from "./LambderRequestPath.js";
5
9
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
6
10
  export const isV2HttpEvent = (event) => !!event && typeof event === "object"
7
11
  && event.version === "2.0"
8
12
  && !!event.requestContext?.http;
13
+ /**
14
+ * Puts one instance's tools onto a context, bound to that very object, so
15
+ * what they read (the session, the ip, which API the call is) is the
16
+ * context's own. Bound through bindCallTools, as the mock's are, and bound
17
+ * again by the instance on whatever context a beforeRender hook hands back.
18
+ */
19
+ export const bindContextTools = (ctx, tools) => {
20
+ const bound = ctx;
21
+ bindCallTools(bound, {
22
+ getters: { sessionController: () => tools.sessionControllerFor(bound) },
23
+ methods: {
24
+ rateLimit: async (policy, key) => { await tools.chargeRateLimit(bound, policy, key, true); },
25
+ isRateLimited: (policy, key) => tools.chargeRateLimit(bound, policy, key, false),
26
+ },
27
+ });
28
+ return bound;
29
+ };
30
+ /**
31
+ * The tools of a context no instance renders: one createContext() built from
32
+ * an event on its own. Touching one says what is missing instead of failing
33
+ * on an undefined member.
34
+ */
35
+ const unboundTool = (member) => () => {
36
+ throw new Error(`Lambder: ctx.${member} is bound by the Lambder instance rendering the request, and this context was built by createContext() alone. Use lambder.getSessionController(ctx) for its session controller.`);
37
+ };
38
+ const UNBOUND_CONTEXT_TOOLS = {
39
+ sessionControllerFor: unboundTool("sessionController"),
40
+ chargeRateLimit: unboundTool("rateLimit"),
41
+ };
42
+ /**
43
+ * A Function URL's own domain, `<url-id>.lambda-url.<region>.on.aws`, which
44
+ * its events always carry: the URL answers no other Host, so CloudFront in
45
+ * front of one sends it this one too. Every other gateway's v2 event is an
46
+ * HTTP API's, custom domains included.
47
+ *
48
+ * Asked of a gateway's event only. An event Lambder synthesized (an invoke,
49
+ * lambder/testing, the handler transport) carries one of Lambder's own
50
+ * apiIds and its path decoded, and names whatever host its caller chose,
51
+ * this domain included.
52
+ */
53
+ const FUNCTION_URL_DOMAIN = /\.lambda-url\.[a-z0-9-]+\.on\.aws$/i;
54
+ /** A value a Host header may carry: a name or an address, and a port. Anything else from a forwarded header is not taken as the host. */
55
+ const HOST_VALUE_PATTERN = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d{1,5})?$/;
56
+ /**
57
+ * The path without a named stage's prefix. An HTTP API keeps the stage in
58
+ * the path it delivers, in either payload format (`/prod/orders` on stage
59
+ * `prod`), where a REST API strips it first; `$default` is never in it.
60
+ */
61
+ const withoutStagePrefix = (path, stage) => stage && stage !== "$default" && (path === `/${stage}` || path.startsWith(`/${stage}/`))
62
+ ? path.slice(stage.length + 1) || "/"
63
+ : path;
9
64
  /** The render context for one request: everything a route handler, an API handler, a hook or a guard reads about it, built once from the Lambda event. */
10
- export const createContext = (event, lambdaContext, apiPath, trustedClientIpHeaders = []) => {
65
+ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH, trustedClientIpHeaders = [], trustedHostHeaders = [] } = {}) => {
11
66
  // Normalize the two API Gateway payload formats into one shape.
12
67
  const eventFormat = isV2HttpEvent(event) ? "v2" : "v1";
13
68
  let host;
14
- let path;
69
+ let rawPath;
70
+ // An HTTP API delivers the path decoded, in either payload format, and so
71
+ // does Lambder's own 2.0 event builder; a REST API and a Function URL
72
+ // deliver it as the viewer sent it.
73
+ let pathAlreadyDecoded = false;
15
74
  let method;
16
75
  let get;
17
76
  let cookiePairs;
@@ -19,12 +78,10 @@ export const createContext = (event, lambdaContext, apiPath, trustedClientIpHead
19
78
  const headers = event.headers ?? {};
20
79
  if (isV2HttpEvent(event)) {
21
80
  host = headers.host || event.requestContext.domainName || "";
22
- path = event.rawPath;
23
- // Named stages (non-$default) are included in rawPath; v1 strips them.
24
- const stage = event.requestContext.stage;
25
- if (stage && stage !== "$default" && (path === `/${stage}` || path.startsWith(`/${stage}/`))) {
26
- path = path.slice(stage.length + 1) || "/";
27
- }
81
+ const apiId = event.requestContext.apiId;
82
+ pathAlreadyDecoded = apiId === LAMBDER_INVOKE_API_ID || apiId === LAMBDER_LOCAL_API_ID
83
+ || !FUNCTION_URL_DOMAIN.test(event.requestContext.domainName ?? "");
84
+ rawPath = withoutStagePrefix(event.rawPath, event.requestContext.stage);
28
85
  method = event.requestContext.http.method;
29
86
  get = {};
30
87
  for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
@@ -36,26 +93,54 @@ export const createContext = (event, lambdaContext, apiPath, trustedClientIpHead
36
93
  }
37
94
  else {
38
95
  host = headers.Host || headers.host || "";
39
- path = event.path;
96
+ rawPath = event.path;
97
+ // An HTTP API sending payload format 1.0 marks it `version: "1.0"`;
98
+ // a REST API's event carries no version. Read as a REST API's, the
99
+ // decoded path would be decoded a second time: `/%2561dmin`, handed
100
+ // over as `/%61dmin`, would reach `/admin` past whatever authorizer
101
+ // guarded that route at the gateway.
102
+ if (event.version === "1.0") {
103
+ pathAlreadyDecoded = true;
104
+ rawPath = withoutStagePrefix(event.path, event.requestContext?.stage);
105
+ }
40
106
  method = event.httpMethod;
41
107
  get = event.queryStringParameters || {};
42
108
  // A REST API keeps only the LAST value of a repeated header in
43
109
  // `headers` and every value in `multiValueHeaders`, and HTTP/2 lets a
44
110
  // client split its cookies across several Cookie headers. The session
45
- // layer weighs every copy of a cookie name, so dropping one is
46
- // dropping a candidate session; v2's `event.cookies` already carries
47
- // them all.
111
+ // layer weighs every copy of a cookie name, so dropping one drops a
112
+ // candidate session (v2's `event.cookies` carries them all).
48
113
  const cookieHeaders = event.multiValueHeaders?.Cookie ?? event.multiValueHeaders?.cookie;
49
114
  cookiePairs = (cookieHeaders?.length ? cookieHeaders.join("; ") : (headers.Cookie || headers.cookie || "")).split(";");
50
115
  sourceIp = event.requestContext?.identity?.sourceIp || "";
51
116
  }
117
+ const path = decodeRequestPath(rawPath, pathAlreadyDecoded);
52
118
  const cookieList = cookieValuesByName(cookiePairs);
53
119
  const cookie = Object.create(null);
54
120
  for (const [name, values] of Object.entries(cookieList))
55
121
  cookie[name] = values[0];
56
122
  const lowercasedHeaders = lowercaseHeaderNames(headers);
57
123
  const header = (name) => lowercasedHeaders[name.toLowerCase()];
58
- const ip = resolveClientIp(lowercasedHeaders, sourceIp, trustedClientIpHeaders);
124
+ // A trusted forwarding header is trusted because a proxy in front of this
125
+ // function writes it. A direct invoke has no such proxy: its headers are
126
+ // whatever the invoking code passed on, and a gateway lambda forwarding a
127
+ // browser's request passes on the browser's own. So on an invoke the
128
+ // address and the host are the ones the invoker named (clientIp and host,
129
+ // delivered as sourceIp and Host), and no header is read for either.
130
+ const invokedDirectly = event.requestContext?.apiId === LAMBDER_INVOKE_API_ID;
131
+ // The first trusted header carrying a well-formed host, leftmost entry,
132
+ // else the host the gateway saw. Behind CloudFront a Function URL sees
133
+ // its own lambda-url domain, since CloudFront sends an origin its own
134
+ // Host, so cookie domains and host routing need the viewer's host from a
135
+ // header the distribution writes.
136
+ for (const name of invokedDirectly ? [] : trustedHostHeaders) {
137
+ const forwarded = (lowercasedHeaders[name.toLowerCase()] ?? "").split(",")[0].trim();
138
+ if (HOST_VALUE_PATTERN.test(forwarded)) {
139
+ host = forwarded;
140
+ break;
141
+ }
142
+ }
143
+ const ip = resolveClientIp(lowercasedHeaders, sourceIp, invokedDirectly ? [] : trustedClientIpHeaders);
59
144
  // Decode body: keep the raw string, then parse as JSON with urlencoded fallback.
60
145
  const rawBody = event.isBase64Encoded
61
146
  ? (event.body ? base64ToText(event.body) : "")
@@ -71,13 +156,15 @@ export const createContext = (event, lambdaContext, apiPath, trustedClientIpHead
71
156
  post[key] = value;
72
157
  }
73
158
  }
74
- // A POST to the API path whose body names an API is an API call; the
75
- // core reads the envelope, and everything downstream reads ctx.api.
76
- const api = method === "POST" && !!apiPath && path === apiPath
159
+ // A JSON POST to the API path whose body names an API is an API call;
160
+ // the core reads the envelope, and everything downstream reads ctx.api.
161
+ // JSON only (isApiCallContentType): any site can submit a plain HTML form
162
+ // to this path, and enctype="text/plain" lays out a JSON body exactly.
163
+ const api = method === "POST" && !!apiPath && path === apiPath && isApiCallContentType(lowercasedHeaders)
77
164
  ? readApiEnvelope(post, { headers: lowercasedHeaders, cookies: cookieList, ip, host })
78
165
  : null;
79
- return {
80
- host, path, pathParams: {}, method,
166
+ return bindContextTools({
167
+ host, path, rawPath, pathParams: {}, method,
81
168
  get, post, cookie, cookieList, event,
82
169
  session: null,
83
170
  api,
@@ -89,5 +176,5 @@ export const createContext = (event, lambdaContext, apiPath, trustedClientIpHead
89
176
  eventFormat,
90
177
  responseHeaders: new LambderAnswerHeaders(),
91
178
  logList: [],
92
- };
179
+ }, UNBOUND_CONTEXT_TOOLS);
93
180
  };
@@ -1,8 +1,14 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
2
  import type { LambderResponse } from "./LambderResponse.js";
3
3
  export type LambderCorsConfig = {
4
- /** "*" (default), an allowlist, or a per-request predicate. With credentials, the origin is echoed (never "*"). */
4
+ /** "*" (default), an allowlist, or a per-request predicate, asked once per request; one that throws counts as refused. An allowed origin is echoed back. */
5
5
  origins?: "*" | string[] | ((origin: string, ctx: LambderRenderContext) => boolean);
6
+ /**
7
+ * Let an allowed origin make credentialed calls (cookies). Needs an
8
+ * allowlist or a predicate in `origins`: credentials for any origin would
9
+ * let every website read a signed-in user's answers, so create() refuses
10
+ * the pair.
11
+ */
6
12
  credentials?: boolean;
7
13
  methods?: string[];
8
14
  allowHeaders?: string[];
@@ -14,5 +20,17 @@ export type LambderCorsConfig = {
14
20
  exposeHeaders?: string[];
15
21
  maxAge?: number;
16
22
  };
17
- /** Mutate the response with the CORS headers the config allows for this request. */
18
- export declare const applyCorsHeaders: (config: LambderCorsConfig | null, ctx: LambderRenderContext, response: LambderResponse, isPreflight: boolean) => void;
23
+ /**
24
+ * The Access-Control-Allow-Origin this request earns: "*", the echoed origin,
25
+ * or null for a refused or absent one.
26
+ *
27
+ * Settled once per request, before anything can crash, and handed to every
28
+ * answer the request ends in: the crash path then applies a verdict already
29
+ * reached and runs no app code of its own. A predicate that throws counts as
30
+ * refused and is logged. `new URL(origin)` throws on the `Origin: null` a
31
+ * sandboxed frame or a cross-origin redirect sends, and a CORS header is no
32
+ * reason to fail the request it decorates.
33
+ */
34
+ export declare const allowedCorsOriginOf: (config: LambderCorsConfig, ctx: LambderRenderContext) => string | null;
35
+ /** Mutate the response with the CORS headers the config allows, for the origin verdict allowedCorsOriginOf settled for this request. */
36
+ export declare const applyCorsHeaders: (config: LambderCorsConfig | null, allowedOrigin: string | null, response: LambderResponse, isPreflight: boolean) => void;
@@ -1,24 +1,43 @@
1
- /** Mutate the response with the CORS headers the config allows for this request. */
2
- export const applyCorsHeaders = (config, ctx, response, isPreflight) => {
3
- if (!config)
4
- return;
5
- const origin = ctx.header("origin") ?? "";
1
+ /**
2
+ * The Access-Control-Allow-Origin this request earns: "*", the echoed origin,
3
+ * or null for a refused or absent one.
4
+ *
5
+ * Settled once per request, before anything can crash, and handed to every
6
+ * answer the request ends in: the crash path then applies a verdict already
7
+ * reached and runs no app code of its own. A predicate that throws counts as
8
+ * refused and is logged. `new URL(origin)` throws on the `Origin: null` a
9
+ * sandboxed frame or a cross-origin redirect sends, and a CORS header is no
10
+ * reason to fail the request it decorates.
11
+ */
12
+ export const allowedCorsOriginOf = (config, ctx) => {
6
13
  const origins = config.origins ?? "*";
7
- let allowOrigin = null;
8
- if (origins === "*") {
9
- allowOrigin = config.credentials ? (origin || null) : "*";
14
+ if (origins === "*")
15
+ return "*";
16
+ const origin = ctx.header("origin");
17
+ if (!origin)
18
+ return null;
19
+ if (Array.isArray(origins))
20
+ return origins.includes(origin) ? origin : null;
21
+ try {
22
+ return origins(origin, ctx) ? origin : null;
10
23
  }
11
- else if (Array.isArray(origins)) {
12
- allowOrigin = origin && origins.includes(origin) ? origin : null;
13
- }
14
- else {
15
- allowOrigin = origin && origins(origin, ctx) ? origin : null;
24
+ catch (predicateErr) {
25
+ console.error("Lambder: cors.origins threw; the origin was refused.", predicateErr);
26
+ return null;
16
27
  }
17
- if (!allowOrigin)
28
+ };
29
+ /** Mutate the response with the CORS headers the config allows, for the origin verdict allowedCorsOriginOf settled for this request. */
30
+ export const applyCorsHeaders = (config, allowedOrigin, response, isPreflight) => {
31
+ if (!config)
18
32
  return;
19
- response.setHeader("Access-Control-Allow-Origin", allowOrigin);
20
- if (allowOrigin !== "*")
33
+ // Under an allowlist or a predicate the answer depends on Origin even when
34
+ // this one was refused: a cache must not serve the answer to a refused or
35
+ // absent Origin to an allowed one, which would read it as a CORS failure.
36
+ if ((config.origins ?? "*") !== "*")
21
37
  response.addHeader("Vary", "Origin");
38
+ if (!allowedOrigin)
39
+ return;
40
+ response.setHeader("Access-Control-Allow-Origin", allowedOrigin);
22
41
  if (config.credentials)
23
42
  response.setHeader("Access-Control-Allow-Credentials", "true");
24
43
  if (isPreflight) {
@@ -0,0 +1,40 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ import type { LambderCrashOptions, LambderCrashSite } from "./LambderCreateOptions.js";
3
+ import { LambderResponse } from "./LambderResponse.js";
4
+ /**
5
+ * The `crashes` option applied: what the instance does with a crash beyond
6
+ * handing it to the app's global error handler.
7
+ *
8
+ * Kept apart from the request path, like CORS and file serving: a crash is
9
+ * reported wherever it happened (an API call, a route, an event, a `created`
10
+ * hook) and answered by the framework only when nothing the app wrote
11
+ * answered it, so neither is a step of rendering a request.
12
+ */
13
+ export declare class LambderCrashHandling {
14
+ private readonly options;
15
+ private readonly apiVersion;
16
+ private readonly reportTimeoutMs;
17
+ constructor(options: LambderCrashOptions, apiVersion: string | null);
18
+ /**
19
+ * Hands a crash to the app's reporter and waits for it, for up to
20
+ * reportTimeoutMs. Never throws: a reporter that fails is logged beside
21
+ * the crash it was given, one that has not finished by then is logged
22
+ * with it as unfinished, and either way the request goes on to be
23
+ * answered. The report itself cannot be cancelled and runs on; only the
24
+ * wait ends.
25
+ */
26
+ report(error: Error, site: LambderCrashSite): Promise<void>;
27
+ /**
28
+ * The framework's own 500, for a crash nothing the app wrote answered:
29
+ * API calls get the core's crash envelope so clients can parse a
30
+ * structured failure, everything else plain text. It carries the crash in
31
+ * full only for a caller `crashes.reveal` trusts.
32
+ *
33
+ * Without a reporter it also logs the crash (and a global error handler
34
+ * that threw on it): the invocation answered, so Lambda counts no error,
35
+ * and this log line is the only trace the crash leaves.
36
+ */
37
+ frameworkResponse(error: Error, ctx: LambderRenderContext | null, errorHandlerCrash: Error | null): Promise<LambderResponse>;
38
+ /** Whether this request's caller may read the crash; a reveal rule that throws answers no. */
39
+ private mayReveal;
40
+ }
@@ -0,0 +1,97 @@
1
+ import { crashAnswer } from "../api/LambderApiEnvelope.js";
2
+ import { describeCrash } from "../shared/wire/LambderCrashDetail.js";
3
+ import { stopWaitingWhenAborted } from "../shared/util/LambderCallAbort.js";
4
+ import { LambderResponse, responseFromAnswer } from "./LambderResponse.js";
5
+ /**
6
+ * How long a crash's answer waits for the reporter by default: long enough
7
+ * for a network write to a log or an error tracker, short enough that a
8
+ * stalled one leaves most of a function's timeout to answer in.
9
+ */
10
+ const DEFAULT_CRASH_REPORT_TIMEOUT_MS = 3_000;
11
+ /**
12
+ * The `crashes` option applied: what the instance does with a crash beyond
13
+ * handing it to the app's global error handler.
14
+ *
15
+ * Kept apart from the request path, like CORS and file serving: a crash is
16
+ * reported wherever it happened (an API call, a route, an event, a `created`
17
+ * hook) and answered by the framework only when nothing the app wrote
18
+ * answered it, so neither is a step of rendering a request.
19
+ */
20
+ export class LambderCrashHandling {
21
+ options;
22
+ apiVersion;
23
+ reportTimeoutMs;
24
+ constructor(options, apiVersion) {
25
+ this.options = options;
26
+ this.apiVersion = apiVersion;
27
+ this.reportTimeoutMs = options.reportTimeoutMs ?? DEFAULT_CRASH_REPORT_TIMEOUT_MS;
28
+ }
29
+ /**
30
+ * Hands a crash to the app's reporter and waits for it, for up to
31
+ * reportTimeoutMs. Never throws: a reporter that fails is logged beside
32
+ * the crash it was given, one that has not finished by then is logged
33
+ * with it as unfinished, and either way the request goes on to be
34
+ * answered. The report itself cannot be cancelled and runs on; only the
35
+ * wait ends.
36
+ */
37
+ async report(error, site) {
38
+ const report = this.options.report;
39
+ if (!report)
40
+ return;
41
+ const deadline = AbortSignal.timeout(this.reportTimeoutMs);
42
+ try {
43
+ // Started inside the promise chain, so a reporter that throws
44
+ // before its first await lands in the catch below like one that
45
+ // rejects.
46
+ await stopWaitingWhenAborted(Promise.resolve().then(() => report(error, site)), deadline);
47
+ }
48
+ catch (reportErr) {
49
+ if (deadline.aborted && reportErr === deadline.reason) {
50
+ console.error(`Lambder: crashes.report did not finish within ${this.reportTimeoutMs} ms and is no longer waited for. The crash:`, error);
51
+ }
52
+ else {
53
+ console.error("Lambder: crashes.report threw while reporting a crash. The crash:", error, "What the reporter threw:", reportErr);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * The framework's own 500, for a crash nothing the app wrote answered:
59
+ * API calls get the core's crash envelope so clients can parse a
60
+ * structured failure, everything else plain text. It carries the crash in
61
+ * full only for a caller `crashes.reveal` trusts.
62
+ *
63
+ * Without a reporter it also logs the crash (and a global error handler
64
+ * that threw on it): the invocation answered, so Lambda counts no error,
65
+ * and this log line is the only trace the crash leaves.
66
+ */
67
+ async frameworkResponse(error, ctx, errorHandlerCrash) {
68
+ if (!this.options.report) {
69
+ console.error(`Lambder: ${ctx ? `${ctx.method} ${ctx.path}` : "a request"} crashed and was answered with the framework's 500.`, error);
70
+ if (errorHandlerCrash)
71
+ console.error(errorHandlerCrash);
72
+ }
73
+ const revealed = ctx && await this.mayReveal(ctx) ? describeCrash(error, ctx) : null;
74
+ if (ctx?.api) {
75
+ return responseFromAnswer(crashAnswer(this.apiVersion, revealed ? { crash: revealed, logList: ctx.logList } : undefined));
76
+ }
77
+ return new LambderResponse({
78
+ statusCode: 500,
79
+ body: revealed
80
+ ? ["Internal Server Error.", "", revealed.stack ?? `${revealed.name}: ${revealed.message}`,
81
+ ...(revealed.causeList ?? []).map((cause) => `Caused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`)].join("\n")
82
+ : "Internal Server Error.",
83
+ });
84
+ }
85
+ /** Whether this request's caller may read the crash; a reveal rule that throws answers no. */
86
+ async mayReveal(ctx) {
87
+ if (!this.options.reveal)
88
+ return false;
89
+ try {
90
+ return (await this.options.reveal(ctx)) === true;
91
+ }
92
+ catch (revealErr) {
93
+ console.error("Lambder: crashes.reveal threw; the crash was not revealed.", revealErr);
94
+ return false;
95
+ }
96
+ }
97
+ }