lambder 7.3.1 → 8.1.1

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 (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -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/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. package/dist/shared/util/LambderKeyFields.js +0 -34
package/CHANGELOG.md CHANGED
@@ -9,6 +9,1050 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [8.1.1] - 2026-09-26
13
+
14
+ Two additions, and three breaking changes a minor line carries here on
15
+ purpose. An app's contract can now be written out as a generated file of
16
+ plain types for its clients to import, which does by default what
17
+ `LambderFlattenContract` asked each app to spell out, so that helper is gone.
18
+ And direct uploads, files posted from the browser straight to S3 on tickets
19
+ the server signs, are part of the framework.
20
+
21
+ ### Added
22
+
23
+ - **`writeApiContract` in `lambder/build`: the contract as a generated file.**
24
+ A client that imports `typeof lambder.ApiContract` from the server compiles
25
+ the server to get it, every endpoint's schemas and the libraries they infer
26
+ through included, and reads it as the intersection chaining built. In a
27
+ 193-endpoint app that was 17 of the frontend check's 19 million type
28
+ instantiations and 2.4 of its 4.8 GB. `writeApiContract` reads the
29
+ `ApiContract` of the instance a module exports (`module`, `exportName`)
30
+ through the TypeScript compiler, under the server's own tsconfig and
31
+ without running any of it, and writes the contract as one object type with
32
+ plain members to a module that imports nothing. The app declares nothing
33
+ for it, and its clients (a frontend, another service's
34
+ `LambderInvokeCaller`, its own tests through `lambderTestApp`) import the
35
+ type from there: the same frontend check fell to 1.8 million
36
+ instantiations and 2.4 GB.
37
+ - Every type is printed as the structure it resolves to. The default
38
+ library's interfaces (`Date`) keep their names, a non-generic named type
39
+ (an alias, an interface, a class) is printed once as a declaration the
40
+ entries refer to, which is also how a recursive type refers to itself,
41
+ and properties keep the order they are written in, so the file changes
42
+ only when an API does.
43
+ - Anything with no plain form fails the call and names where it sits: a
44
+ function, a symbol key, an enum, a class's private member, an open type
45
+ parameter, and a type a compile error left unresolved, wherever in the
46
+ server's sources the error is.
47
+ - A write compiles the new text beside the server's sources and checks each
48
+ entry against the contract in both directions before it touches the file.
49
+ `check: true` writes nothing and fails a stale file. Both name the APIs
50
+ that moved, counting a change to a shared declaration against every API
51
+ that reaches it.
52
+ - `typescript` 5.4 or later is an optional peer dependency, loaded only when
53
+ `writeApiContract` runs. It needs the compiler API, so 5.x or 6.x:
54
+ TypeScript 7 ships none, and the call says so when it finds a 7.
55
+
56
+ See [the contract as a generated file](./docs/apis.md#the-contract-as-a-generated-file).
57
+
58
+ - **Direct uploads.** A file too large for an API payload goes from the
59
+ browser straight to object storage, on a ticket the app's endpoint signs
60
+ for exactly that file: its key, byte size, content type and SHA-256, all
61
+ enforced by storage, and verified by the server before the app's record
62
+ counts it as uploaded.
63
+ - `LambderUploadBucket`, the interface a bucket implements: sign a ticket,
64
+ verify what arrived, sign a download link, and read, write, copy and
65
+ delete objects for the rest of their life. `LambderS3UploadBucket`
66
+ implements it over an S3 presigned POST whose policy pins every fact;
67
+ `@aws-sdk/s3-presigned-post` and `@aws-sdk/s3-request-presigner` join
68
+ `@aws-sdk/client-s3` as optional peers, each loaded on first use.
69
+ `writeObject` sends the checksum it is given or has the SDK compute one.
70
+ - Lifetimes at both levels: `ticketLifetimeSeconds` and
71
+ `downloadLifetimeSeconds` on the bucket, `lifetimeSeconds` on a ticket or
72
+ a link, each held to S3's seven days. What a stored object carries,
73
+ `object` on a ticket or a write: tags (how an object gets a time to live,
74
+ through a lifecycle rule), metadata, `Cache-Control` and a
75
+ `Content-Disposition`, all pinned in the ticket's policy. A download link
76
+ takes its own `contentDisposition`, to save a file under its name.
77
+ - `LambderUploadRunner` in `lambder/client`, the browser half: checks the
78
+ file against the rule, hashes it, asks for a ticket, posts it with
79
+ progress, tries again after a dropped, failing, timed-out or stalled
80
+ connection with a growing wait, asks for a new ticket when storage says
81
+ the old one expired, stops on an abort signal it also hands to the app's
82
+ own calls, and has the server confirm. A failure is a `LambderUploadError`
83
+ whose `reason` a screen words. It posts over XMLHttpRequest for progress,
84
+ and over fetch where that is all there is.
85
+ - `LambderMemoryUploadBucket`, the same bucket in memory, holding a post to
86
+ the rules S3 holds a presigned POST to and refusing with S3's statuses and
87
+ XML errors, so a runner takes the same path against it as against S3; and
88
+ `lambderMockUploadMswHandler` in `lambder/mock`, which puts it behind MSW
89
+ for a mock app's uploads.
90
+ - `LambderUploadFileFactsSchema` and `LambderUploadTicketSchema`, the zod
91
+ schemas of the two shapes that cross the app's own API; and
92
+ `checkUploadRule` and `refuseUnacceptedUpload`, for a bucket of an app's
93
+ own to refuse a file as Lambder's do.
94
+ - Three refusal codes for a file a rule does not accept:
95
+ `lambder/upload-empty`, `lambder/upload-type-rejected` and
96
+ `lambder/upload-too-large`.
97
+
98
+ See [Direct uploads](./docs/uploads.md).
99
+
100
+ ### Removed
101
+
102
+ - **`LambderFlattenContract`.** It collapsed the chained contract into an
103
+ interface an app had to declare by hand for its clients' type checks to stay
104
+ cheap. A client that needs that now imports the contract `writeApiContract`
105
+ generates, which is flat by construction, and the server's own reads of its
106
+ contract cost it little. Replace
107
+ `export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}`
108
+ with `export type ApiContractType = typeof lambder.ApiContract`, or drop it
109
+ and point the clients at the generated file.
110
+
111
+ ### Changed
112
+
113
+ - **`writeApiSignatures({ module, exportName, file })`.** It takes the module
114
+ that exports the instance, as `writeApiContract` does, and imports it,
115
+ rather than the instance and then its module again for the fresh-process
116
+ check. That check now runs by default; `verifyInFreshProcess: false` skips
117
+ it. Replace `writeApiSignatures(lambder, { file, verifyInFreshProcess: {
118
+ module, exportName } })` with `writeApiSignatures({ module, exportName,
119
+ file })`. Both generators take `module` as a path or a file URL
120
+ (`LambderModuleLocation`).
121
+ - **`LambderMswModule` names `http.all` beside `http.post`.** It is the one
122
+ description of the msw module both mock adapters take, the API's and an
123
+ upload bucket's. The real `msw` module fits it as before; a hand-built
124
+ stand-in for it needs an `all` as well.
125
+
126
+ ## [8.0.2] - 2026-09-25
127
+
128
+ A major, out of a review of 7.3.1. Most of it closes holes: session writes
129
+ that could undo a logout or a password change, a login any website could
130
+ submit through a plain form, output fields that reached the client past their
131
+ schema, rate limits an IPv6 client or a guessed key could step around, and
132
+ idempotency keys that replayed another request's answer. The rest makes the
133
+ framework behave the same on every gateway, and in the mock as on the server,
134
+ and adds crash reporting, `ctx.sessionController`, `ctx.rateLimit`, the memory
135
+ cache and `writeApiSignatures`.
136
+
137
+ The wire format is unchanged in both directions apart from one new refusal
138
+ code (`lambder/idempotency-key-reused`, 409) and an `errorMessage` that is
139
+ always the message object (a 7.x caller already read both forms), so a
140
+ deployed callee and a 7.x client still understand each other. The one
141
+ exception is a 7.x `LambderInvokeCaller` call that forwarded a Content-Type of
142
+ its own in `headers`: it won over the JSON one there, and an 8.x callee does
143
+ not read such a call as an API call.
144
+
145
+ Stored state mostly carries over, with three exceptions. Every live session
146
+ is signed out once: a session record carries `dataVersion` now, and a 7.x
147
+ record, which has none, reads as no session until its TTL retires it (the
148
+ partition key is an HMAC of the sessionKey now as well, so a new session
149
+ never lands in a 7.x partition). An idempotency record a 7.x server wrote
150
+ carries no fingerprint, and until it expires a key that finds one is refused
151
+ as reused (409, which a key scope moves past) rather than replayed. A 7.x
152
+ cache `lock` item is ignored and expires by its TTL; cached values read as
153
+ before.
154
+
155
+ The compiler finds most of what an upgrade from 7 has to change: the session
156
+ and idempotency store contracts, the session crypto, the contract's input and
157
+ output types, the context's new members. Fourteen it cannot:
158
+
159
+ - A hand-built API call (fetch, curl, a tool) has to send
160
+ `Content-Type: application/json`, or it is not an API call at all.
161
+ - A hand-built API answer (an MSW handler, a test stub, a proxy) has to carry
162
+ `apiVersion` (`null` will do), or the caller reads it as a `server`
163
+ failure rather than a success.
164
+ - A handler whose payload does not match its output schema now crashes, and
165
+ extra fields it returns are stripped before they are sent, a refusal's
166
+ payload included. An output schema with an async refinement or transform
167
+ crashes on every call (`LambderApiOutputValidationError`) rather than
168
+ answering; move the async check into the handler.
169
+ - `ctx.path` is decoded, so code that decoded it itself decodes twice. A
170
+ percent sign in it is kept as `%25` (see below).
171
+ - String routes match case-sensitively: `/Admin` no longer reaches
172
+ `addRoute("/admin")`. Register each spelling an app answers, or use a
173
+ RegExp with the `i` flag.
174
+ - Behind a REST API, compression is off until `compression` is named, and
175
+ binary files and compressed answers need `binaryMediaTypes: ["*/*"]`.
176
+ - The IAM policies need two more actions. The session table needs
177
+ `dynamodb:UpdateItem`: every renewal and data write is an `UpdateItem`
178
+ where 7.x used `PutItem`. Without it every `updateSessionData` fails its
179
+ request, and renewal writes fail and are logged, so sessions stop sliding
180
+ and users are signed out once their TTL runs from creation. The rate-limit
181
+ table needs `dynamodb:GetItem`, for the read on a throttled partition;
182
+ without it every key-range throttle goes to `failOpen`.
183
+ `docs/dynamodb-tables.md` lists both.
184
+ - A custom-keyed rate limit is charged after the guards. A limit meant to
185
+ count the wrong guesses of something a guard checks (a one-time code keyed
186
+ per email) counts none of them until its policy says
187
+ `chargeAt: "beforeGuards"`.
188
+ - An idempotency key belongs to the request it was first sent with. A
189
+ single-use token carried inside the payload (checked by an `apiInput`
190
+ guard) makes every genuine retry a 409 `lambder/idempotency-key-reused`:
191
+ move it to `guardInputs`. A client that sends one fixed key with edited
192
+ requests gets the same 409.
193
+ - `errorMessage` is always `{ type, content }` on the wire. A client that is
194
+ not a Lambder caller (a native app, a script, a test asserting on the raw
195
+ envelope) and read a string reads the object's `content`.
196
+ - `cors: { credentials: true }` with every origin allowed makes `create()`
197
+ throw at cold start: name the origins, or a predicate.
198
+ - Templates refuse more slot positions when they compile: inside a tag where
199
+ an attribute name goes (`<input <!--slot:x/-->>`), inside the quoted value
200
+ of an `on*` attribute, `style` or `srcdoc`, inside a comment right before a
201
+ `-`, `!` or `>` that a value ending in `--` would turn into its end, and
202
+ wherever the branches of an if/else (or a slot's default content and its
203
+ value) leave the HTML in different positions before the next slot or block.
204
+ A URL attribute whose scheme a slot can reach renders `about:invalid` unless
205
+ the scheme is http, https, mailto or tel. `` html`...` `` and `` xml`...` ``
206
+ apply the same rules to their interpolations and throw when called, not
207
+ compiled, so a call site that interpolates into one of those positions fails
208
+ on its first render.
209
+ - The session controller's `refreshSessionData()` and `updateSessionData()`
210
+ throw `LambderSessionNotFoundError` where they answered null, and an API
211
+ call answers that as `sessionExpired`, a public API's included. A call site
212
+ written `(await refreshSessionData())?.data ?? null` still compiles and
213
+ never sees its null: catch the error where a signed-out answer is meant.
214
+ - A schema field annotated `z.ZodType<T>`, or built with `z.coerce` or
215
+ `z.preprocess`, has `unknown` as its input, and the contract's `input`, the
216
+ handler's `res.api` type and the guard and rate-limit slices read `z.input`
217
+ now, so such a field goes unchecked on the typed caller and in the handler.
218
+ Annotate it `z.ZodType<T, T>`, or write `satisfies` in place of `as`.
219
+
220
+ ### Added
221
+
222
+ - **`runAt: "afterInputValidation"` on a guard** runs it after the input schema
223
+ passes rather than before: for a guard that spends something on the
224
+ request, such as a single-use captcha token, which a request refused for a
225
+ mistyped field would otherwise have wasted. Default placement is unchanged.
226
+ - **`trustedHostHeaders`**: headers that may name the host the viewer asked
227
+ for, e.g. `["x-forwarded-host"]` for a Function URL behind CloudFront, which
228
+ sends the origin its own lambda-url Host, so cookie domains and host
229
+ routing saw the wrong host. Nothing is trusted by default, as with
230
+ `trustedClientIpHeaders`, and a value that is not a host is not taken.
231
+ - **`notFoundStatuses` on `LambderHttpFileSource`**: the statuses read as "no
232
+ such file" (default below).
233
+ - **Mock `onInvalidInput`**: the answer to an input that fails its schema,
234
+ for a server app that sets `setApiInputValidationErrorHandler`, stated as
235
+ data (`{ payload?, config?, statusCode? }`, `res.api(payload, config)` with
236
+ its status); `null` answers the standard 422. The mock answered 422 where
237
+ such a server answered 200 with an errorMessage.
238
+ - **`ctx.rawPath`**: the request path as the gateway delivered it, stage
239
+ stripped, beside the decoded `ctx.path`.
240
+ - **The file reader remembers a miss** for `memoryCache.missTtlSeconds` (60
241
+ by default, `0` to ask every time). An SPA over S3 or an HTTP origin paid a
242
+ source round trip, an S3 `GetObject` answering NoSuchKey, on every page
243
+ navigation before the shell was served, in warm containers too. The misses
244
+ are bounded by the bytes of their paths (4 MB), which the caller chooses.
245
+ The file cache beside it now evicts the least recently served file rather
246
+ than the first one read, so a hot bundle outlives a file fetched once; both
247
+ sit on `lru-cache`, which Lambder already depended on.
248
+ - **`crashes`: one reporter for every crash, and who may read one.**
249
+ `create({ crashes: { report, reveal } })`.
250
+ - `report(error, site)` is told every crash wherever it happened, with
251
+ `site.kind` saying where: `"api"`, `"route"`, `"event"` (a non-HTTP
252
+ action that threw, or an event no action matched) or `"startup"` (a
253
+ `created` hook). It is awaited before the answer goes out, never told a
254
+ refusal, and a reporter that throws is logged and swallowed. A global
255
+ error handler that throws while answering a crash is reported as a second
256
+ crash with what it threw as its cause. An event's error is still rethrown
257
+ to Lambda after the report, so retries and dead-letter queues are
258
+ unchanged. Before this, a crash in an `addAction` or a `created` hook
259
+ reached no hook at all, and an app wrapped `getHandler()` to see it.
260
+ - `reveal(ctx)` decides whether the framework's own 500 carries the crash:
261
+ `describeCrash` on an API call's `crash` field beside the call's
262
+ `logList`, the stack as text on a route. A callee reached only by trusted
263
+ invokers sets `reveal: () => true` instead of writing a global error
264
+ handler to attach `describeCrash`. It governs the framework's answer
265
+ only; a reveal that throws counts as no. Default: nobody.
266
+ - **`ctx.sessionController` on the server's context.** Every context the
267
+ instance renders carries the request's session controller, typed to the
268
+ app's session data, so a handler, guard or hook reaches it without holding
269
+ the instance (and without the import cycle a guards module that needs the
270
+ instance runs into). `lambder.getSessionController(ctx)` stays, for a
271
+ context the instance did not render (one `createContext()` built). The
272
+ tools are bound onto each context and bound again onto a context a
273
+ `beforeRender` hook hands back, spread copies included.
274
+ - **`initLambder<SessionData>().guard()` and `.rateLimitKey()`**: the policy
275
+ builders typed to the app's session, where the standalone `lambderGuard()`
276
+ leaves `ctx.session.data` as `any`. The mock's `guard` and `rateLimitKey`
277
+ are the same builders bound to its contexts.
278
+ - **`ctx.rateLimit(policy, key?)` and `ctx.isRateLimited(policy, key?)`**: a
279
+ named policy charged by code, for a key only the handler knows (one
280
+ recipient of an invitation). `rateLimit` refuses the way a declared limit
281
+ does (a 429 envelope with Retry-After and the policy's `errorMessage` on an
282
+ API call, a plain 429 on a route); `isRateLimited` answers the check
283
+ result (`LambderRateLimitCheckResult`), with `retryAfterSeconds`, for a
284
+ handler whose output says "too many" its own way. Both run on the instance's limiter, so `failOpen`, key bounding
285
+ and `lambder/testing`'s memory limiter apply, none of which a direct call to
286
+ a limiter's `isRateLimited` gets. A policy may now leave `per` out, which
287
+ makes it one the code charging it keys; an API cannot declare such a
288
+ policy. Policy names and the key argument are checked at compile time where
289
+ the types say (a handler's context), and when the charge runs elsewhere (a
290
+ hook's or a guard's context, or a policy typed as the general
291
+ `LambderApiRateLimitPolicyConfig`). A per-API policy charged from a hook or
292
+ the fallback for a call no registered API matched counts under no API, so a
293
+ fresh posted name is no fresh counter. Mock handlers have both too.
294
+ - **`LambderMemoryCache` and the `LambderCache` interface.** The DynamoDB
295
+ cache's twin for tests, with the same rules: the same key and value limits
296
+ (the key and value checks are shared modules both caches go through), the
297
+ same JSON round trip, TTL, sort-key order and `getOrSet` answers, and the
298
+ same counts from `delete` and `deletePartition` (live entries only). A
299
+ conformance suite drives both. `LambderDdbCache` implements the interface.
300
+ - **`lambder/build` with `writeApiSignatures(instance, { file })`.** Writes
301
+ the signature module both sides ship, or checks it (`check: true`), naming
302
+ the endpoints whose signatures moved. It compares the map the file holds, so
303
+ line endings or a formatter (one that re-indents the file or takes the
304
+ quotes off its keys) neither make a file stale nor get it rewritten, and the
305
+ map carries a `// prettier-ignore` line. A write goes to a temporary file
306
+ renamed over the old one, through a symlink to the file it names. With
307
+ `verifyInFreshProcess: { module, exportName }` it loads the module that
308
+ holds the instance in a fresh Node process, never the calling script, and
309
+ checks the file against what it digests there, after a write and after a
310
+ check that finds the file current, which is where a schema that digests
311
+ differently per process shows. `module` is a path or a file URL, as a `URL`
312
+ or as the string `import.meta.resolve()` answers. The fresh process gets the
313
+ generator's Node flags less the inspector, watch mode, the test runner and
314
+ the eval flags (`-e`, `-p`, `-pe`, `--input-type`); `exportName` defaults to
315
+ `"default"`. `header`, `quotes` and `semicolons` match a project's style.
316
+ Every app with `apiSignatures` wrote this generator itself from a sketch in
317
+ the docs.
318
+ - **`LambderContractKeysWithGuard<Contract, "guardName">`**: the endpoints
319
+ whose guards option names that guard, in any form, for a list a test loops
320
+ over. `satisfies` refuses a name the guard does not cover; the JSDoc shows
321
+ the one-line check that also refuses a list missing one.
322
+ - **`chargeAt: "beforeGuards"` on a custom-keyed rate-limit policy** charges
323
+ it before the guards and the input schema, so the attempts they refuse are
324
+ counted: a limit on guessing a one-time code a guard checks, keyed per
325
+ email. The default stays `"afterGuards"` (below).
326
+ - **`LambderApiOutputValidationError`**: the crash a handler's answer causes
327
+ when its output schema does not accept it, for a crash reporter to tell
328
+ apart, with the `apiName`, the `zodError` when the schema rejected the
329
+ payload (null when parsing threw), and what was thrown as its `cause`.
330
+ - **A `servePublicFiles` path mapper receives the file path** as its second
331
+ argument, `(ctx, filePath) => ...`: the file `ctx.path` names, its kept
332
+ escapes turned back (see `ctx.path` below).
333
+ - `LambderSessionChanges` and `LambderSessionUpdateResult` are exported, the
334
+ types a session store of your own writes its `update` with.
335
+ - **`refusalMessageOf(value)`**: an envelope's errorMessage as the message
336
+ object (a plain string becomes `{ type: "error", content }`), exported from
337
+ `lambder` and `lambder/client`.
338
+ - **`crashes.reportTimeoutMs`**: how long a crash's answer waits for
339
+ `crashes.report` (default 3000). A report still running then is logged
340
+ with the crash as unfinished and the request is answered; the report
341
+ itself is not cancelled.
342
+ - **`notFoundErrorNames` on `LambderS3FileSource`**: the S3 error names read
343
+ as "no such file", as `notFoundStatuses` is on `LambderHttpFileSource`
344
+ (default below).
345
+
346
+ ### Changed (breaking)
347
+
348
+ - **A custom-keyed rate limit is charged after the guards and the input
349
+ validation.** The order is now: session-keyed limits (and custom keys
350
+ charged `"beforeGuards"`), guards, input validation, guards placed after
351
+ it, custom-key limits, handler. What refuses a request for free runs first
352
+ and what spends something last. The key is a value the caller chose (an
353
+ email in the payload): charged before a captcha guard, it let a caller who
354
+ never solved the captcha spend a victim's per-email budget and keep them
355
+ locked out of reset, register and send-code, and charged before the input
356
+ schema, a request refused for its input spent that budget too. `per: "ip"`
357
+ still runs before the session read and `per: "session"` before the guards.
358
+ After the guards, an attempt they refuse is not counted, so a limit on
359
+ guessing what a guard checks (a one-time code, keyed per email) would count
360
+ none of the wrong guesses: such a policy says `chargeAt: "beforeGuards"`
361
+ and is charged where 7.x charged it.
362
+ - **`LambderHttpFileSource` reads a 403 as a missing file**, beside 404 and
363
+ 410. A private S3 bucket behind CloudFront answers a missing key 403 when
364
+ its reader may not list the bucket, so every SPA route failed with a 500
365
+ before the shell was served. `notFoundStatuses: [404, 410]` makes a 403 an
366
+ error again, for an origin that answers missing keys 404.
367
+ - **`LambderS3FileSource` reads AccessDenied as a missing file by default**,
368
+ for the same reason: a reader without `s3:ListBucket` is told AccessDenied
369
+ for a missing key. S3's key refusals (KeyTooLongError, InvalidURI) and R2's
370
+ InvalidObjectName read the same way, so a long path is a 404 rather than a
371
+ crash. A reader granted `s3:ListBucket` that wants a refused credential to
372
+ surface passes a `notFoundErrorNames` without AccessDenied.
373
+ - **An answer that carries Set-Cookie is never publicly cacheable.** When its
374
+ Cache-Control is neither `private` nor `no-store`, `public` becomes
375
+ `private`, and `s-maxage` and `immutable` are dropped, on every exit, the
376
+ 304 and the crash answer included. A hook that set a cookie (a guest
377
+ session, a session read that re-issues the cookies) on a content-hashed
378
+ asset sent it as `public, max-age=31536000, immutable`, which a shared
379
+ cache that keeps Set-Cookie hands to everyone.
380
+ - **`createContext(event, lambdaContext, options?)`** takes
381
+ `{ apiPath?, trustedClientIpHeaders?, trustedHostHeaders? }`
382
+ (`LambderContextOptions`) in place of positional arguments; `apiPath`
383
+ defaults to `"/api"`, as at `create()`.
384
+ - **String routes match case-sensitively**, as API Gateway routes and
385
+ CloudFront behaviors do. `/ADMIN/users` reached `addRoute("/admin/:x")`
386
+ past an authorizer or a behavior on `/admin/*`, which the gateway never
387
+ matched.
388
+ - **`ctx.path` is decoded, whichever gateway sent it.** A REST API and a
389
+ Function URL deliver the path percent-encoded and an HTTP API decoded, so
390
+ `addRoute("/hakkımızda")` answered 404 on two of the three, a file named
391
+ `team photo.jpg` was never found there (and `serveIndexHtml` answered an
392
+ `<img>` for it with the HTML shell), and regex routes saw a different
393
+ string per gateway. `ctx.path` is now the path decoded exactly once: an HTTP
394
+ API's is taken as delivered, and a Function URL is told apart by its own
395
+ `*.lambda-url.<region>.on.aws` domain, which its events always carry. Two
396
+ escapes stay in it, so it reads back unambiguously and no decoded text can
397
+ pass for an escape (`/%2561dmin` is never `/admin`, which would get past an
398
+ authorizer or a WAF rule in front of the function that checked the path
399
+ once): a slash inside a segment stays `%2F`, so it cannot become a
400
+ separator, and a percent sign stays `%25`. A path param and a RegExp
401
+ route's captures turn both back, and keep a decoded `#` or `?`
402
+ (`/tags/C%23` is the tag `C#`); `servePublicFiles` looks up the file the
403
+ path names, its `%25` read as `%`, and a path with an encoded slash names
404
+ none. An app that decoded `ctx.path` itself should stop. The path as
405
+ delivered is `ctx.rawPath`. The v2 events `lambder/testing` and
406
+ `LambderInvokeCaller` synthesize are an HTTP API's, their path decoded; the
407
+ v1 ones (`eventFormat: "v1"`) carry it as written, as a REST API's do.
408
+ The server tells those events by their own `requestContext.apiId`
409
+ (`lambder-invoke`, `lambder-local`) and reads their path as decoded whatever
410
+ host they name, so a caller or a test visitor naming a Function URL's
411
+ `*.lambda-url.*` host does not have it decoded a second time, where
412
+ `/%2561dmin` reached `/admin`.
413
+ - **On a REST API, compression is off unless `compression` is named**, and
414
+ **text leaves as text** on every gateway. A REST API decodes a base64 body
415
+ only for its `binaryMediaTypes`, so compressed answers and every served
416
+ file (CSS and JS included) reached the browser as base64 unless those were
417
+ `*/*`. A text body that is not compressed (a string, or a text-typed Buffer
418
+ that is valid UTF-8) now goes out with `isBase64Encoded: false`; binary
419
+ files and compressed bodies still need `binaryMediaTypes: ["*/*"]` there.
420
+ - **ETags are taken over the uncompressed body and name the encoding**
421
+ (`"<hash>-br"`), so a revalidation that ends in a 304 compresses nothing.
422
+ Every tag changes once, which costs each client one full answer.
423
+ - **The default immutable Cache-Control takes only bundler output**:
424
+ everything under `_next/static/`, and under `assets/` or `static/` a name
425
+ ending in its hash. The old rule matched from a name's first hyphen, so
426
+ `android-chrome-192x192.png`, `og-image-1200x630.png` or
427
+ `privacy-policy-v2.html` were cached for a year wherever they lived, and a
428
+ replaced copy never reached a returning browser. Inside `assets/` or
429
+ `static/`, a hand-named file whose last part could be a hash
430
+ (`assets/og-image-1200x630.png`) is still taken for bundler output. An
431
+ 8-character last part counts as a hash only when it looks random (a capital,
432
+ a lowercase letter and a digit, or at least three capitals and a lowercase
433
+ letter), so `assets/Inter-SemiBold.woff2`, `assets/icon-Settings.svg` and
434
+ `assets/og-image-v2-final.png` keep the ordinary Cache-Control, as does
435
+ about one Vite hash in twenty. Pass `immutablePattern` for another layout.
436
+ - **Served text files carry `charset=utf-8`** (`text/css; charset=utf-8`), so
437
+ a UTF-8 `.txt`, or an `.html` with no meta charset, is not read in the
438
+ browser's legacy encoding. A source's own content type is kept as given.
439
+ - **A template slot inside an unquoted attribute value is refused even with
440
+ a prefix before it** (`class=big-<!--slot:x/-->`), where a space in the
441
+ value starts a new attribute, and **so is a slot inside a tag where an
442
+ attribute name goes** (`<input <!--slot:x/-->>`), where any value is a new
443
+ attribute: vary attributes with `<!--if:...-->` around whole-tag variants,
444
+ or put the slot inside a quoted value. The check now reads the tag, past
445
+ the template's own tokens, so an `=` inside a quoted value or in text no
446
+ longer counts as one.
447
+ - **A template slot inside the quoted value of an event handler (`onclick`
448
+ and every other `on` attribute), `style` or `srcdoc` is refused** when the
449
+ template compiles. The browser decodes the escapes and then reads that
450
+ value as JavaScript, CSS or a whole document, so escaping cannot protect it.
451
+ - **A template's URL attribute is checked when it renders** (`href`, `src`,
452
+ `action`, `formaction`, `xlink:href` and the other single-URL attributes).
453
+ When a slot can reach the value's scheme and that scheme is not http,
454
+ https, mailto or tel (`javascript:` and `data:` included), the value
455
+ renders as `about:invalid`. The whole rendered value is checked, so a
456
+ scheme split across two slots, or completed by the template's own text
457
+ after a slot, is caught too. Relative URLs and values whose scheme the
458
+ template fixed render as written.
459
+ - **`html` and `xml` apply the same rules to their interpolations.** They
460
+ read each call site's static strings to find where every interpolation
461
+ lands, and throw where escaping cannot protect the value: an unquoted
462
+ attribute value (`class=${x}`), a tag where an attribute name goes
463
+ (`<input ${x}>`), the quoted value of an `on` attribute, `style` or
464
+ `srcdoc`, and the content of a `<script>` or `<style>` element. A quoted URL
465
+ attribute value holding an interpolation gets the template's scheme check.
466
+ The rules follow the template, not the value, so they hold for strings,
467
+ numbers, nested `html` fragments, `raw()` and empty values alike, and a
468
+ call site that breaks one throws on every call. Quote the attribute, pass
469
+ script data in a `data-` attribute or through `jsonScript()`, or build the
470
+ whole tag conditionally; a `data:` URL writes its scheme in the template
471
+ (`src="data:image/png;base64,${pngBase64}"`).
472
+ - **`getOrSet` answers the stored JSON on every call**, the call that filled
473
+ the entry included, on `LambderDdbCache` and `LambderMemoryCache` alike.
474
+ The filling call used to answer the loader's own object, so a `Date` was a
475
+ `Date` once and a string forever after, and a field the JSON dropped was
476
+ there only the first time. A value handed back uncached because the cache
477
+ failed open has the same shape. A loader's `undefined` is no longer stored
478
+ or logged as a failure: it comes back uncached and the next call loads
479
+ again (answer `null` to cache "not found").
480
+ - **An idempotency key belongs to the request it was first sent with.** The
481
+ claim and the stored record keep a fingerprint of the request's payload
482
+ (key order aside, and a key named `__proto__` kept as data), and the same
483
+ key with a different payload is refused with
484
+ `lambder/idempotency-key-reused` (409) instead of being handed the first
485
+ request's answer. Guard inputs stay out of it: a captcha or proof token is
486
+ single use, so a genuine retry carries a new one and still replays. Such a
487
+ token belongs in `guardInputs`; one carried inside the payload (checked by an
488
+ `apiInput` guard) is part of the fingerprint. `LambderIdempotencyStore.begin`
489
+ takes the `fingerprint`, reports the one it holds with `"pending"` and
490
+ `"done"`, and `complete` stores it; a store of your own keeps it the same way.
491
+ The fingerprint is a required `string` on the stored record and on the
492
+ `"pending"` answer. A record a store cannot tie to a request (one another
493
+ writer left in its table) reports `""`, which no request matches, so its
494
+ key is refused as reused; a claim refused with no record behind it (no
495
+ room to hold one) reports the caller's own fingerprint, so the engine
496
+ answers the in-flight 409 and the client keeps its key.
497
+ - **`LambderIdempotencyStore.abandon()` releases only a pending claim.** A
498
+ settled record stays, even when the owner that stored it asks.
499
+ `LambderDdbIdempotencyStore` deletes on `ownerToken = :owner AND
500
+ #state = :pending`, and a store of your own does the same.
501
+ - **`idempotencyKey` also takes a key scope** (`createIdempotencyKeyScope()`),
502
+ on `LambderCaller` and `LambderInvokeCaller` alike: the call sends its
503
+ current key and moves to a new one once an answer settles the operation,
504
+ so a corrected form after a refusal is sent under a new key. A success
505
+ settles it, and so does a key refused as reused. A refusal of this request
506
+ does too, unless an earlier attempt under the key went unanswered (a
507
+ timeout, a 5xx, an original still in flight): guards, validation and rate
508
+ limits refuse before the replay record is claimed, so the key is kept and
509
+ the next attempt replays the original rather than running it again. A rate
510
+ limit, an expired session and a stale version keep the key, and an answer
511
+ for a key the scope has already moved past changes nothing. A refusal
512
+ while another attempt under the key is still in flight keeps it too (a
513
+ double-tap refused by a single-use captcha guard while the first tap runs).
514
+ A double-tap's duplicate-in-flight answer leaves the key to the first tap's
515
+ own answer, so a first tap refused ("only 5 in stock") still moves the scope
516
+ on and the corrected order goes under a new key.
517
+ `LambderIdempotencyKeyScope` is a class now, made by
518
+ `createIdempotencyKeyScope()`, rather than an object type a site could
519
+ build itself, and only the callers start and settle its attempts.
520
+ - **`createIdempotencyKey()` and `createIdempotencyKeyScope()` are standalone
521
+ functions** of the root entry and `lambder/client`, in place of the
522
+ `LambderCaller` statics of the same names, since the invoke caller takes
523
+ their keys too. `LambderCaller.createIdempotencyKey()` becomes
524
+ `createIdempotencyKey()`.
525
+ - **A payload is parsed through the API's output schema before it is sent.**
526
+ The type system accepts a value carrying more than its type (a row read
527
+ straight from a table is assignable to a narrower output), and those extra
528
+ fields, secrets included, went to the client and into the idempotency store.
529
+ zod now strips them, fills defaults and runs transforms; a payload the
530
+ schema rejects is answered as a crash rather than sent. That covers every
531
+ payload the handler's `res.api()` answers, a refusal's beside an
532
+ `errorMessage` or a flag included; only `null` passes as it is. A hook, the
533
+ input validation handler and the global error handler answer in shapes of
534
+ their own and are sent as given. The handler writes the schema's input form
535
+ (`z.input`), what the transforms take, so each runs once: a handler for an
536
+ output with a transform writes the transform's source type. Output schemas
537
+ are parsed synchronously, so one cannot be async: an async refinement or
538
+ transform, or a transform that throws, is the same crash, with the thrown
539
+ error as its cause. The handler has run by the time its answer is refused,
540
+ so under an idempotency key the framework's crash answer is recorded as the
541
+ key's answer, and a retry is told the same thing rather than running the
542
+ operation again.
543
+ - **The contract records the client's side of each schema.** `input` is the
544
+ schema's `z.input` (a defaulted field is optional to send, a transformed
545
+ field is posted as its source type) and `output` its output as JSON
546
+ (`LambderJsonOf`: a `z.date()` field is a string, an `undefined` inside an
547
+ array is `null`, and `unknown` and recursive JSON such as `z.json()` stay as
548
+ they are, a key whose value may be undefined is optional since JSON leaves
549
+ it out, and a `Map` or `Set` is `{}`). At the top of an output a `void` or
550
+ `undefined` schema stays as it is, since an envelope carries no payload
551
+ rather than a JSON `undefined`, and an output that may be undefined keeps
552
+ that member (`LambderJsonOutputOf`). Guard and rate-limit `apiInput`
553
+ slices are checked against the posted form. An endpoint with a transform in
554
+ its input used to be uncallable through the typed caller, and a `z.date()`
555
+ output typed `Date` arrived as a string. A mock entry's `input` schema is
556
+ pinned the same way: it takes exactly the posted form, and what it parses to
557
+ must still read as it, so the server's schema restated with a `.default()`
558
+ passes and one whose transform changes a field's type does not.
559
+ - **A POST to `apiPath` is an API call only with `Content-Type:
560
+ application/json`.** Every Lambder caller sends it, and owns it: a
561
+ Content-Type among a call's own headers (a forwarded form post's) does not
562
+ replace it. A POST of another type reaches the API fallback, and the mock's
563
+ MSW adapter and invoke transport read it the same way. Before, the body was
564
+ read as JSON whatever its type, so a plain HTML form on any website (no
565
+ preflight) could post a login envelope and plant the attacker's session
566
+ cookies in a visitor's browser.
567
+ - **`cors: { credentials: true }` needs an allowlist or a predicate in
568
+ `origins`**; create() refuses it with every origin allowed, which echoed
569
+ whatever Origin asked and let any website read a signed-in user's answers.
570
+ - **A `cors.origins` predicate is asked once per request**, right after the
571
+ request is read and before any hook or handler runs, and its answer holds
572
+ for every answer the request ends in, a crash's included. A predicate that
573
+ throws (`new URL(origin)` on `Origin: null`) counts as refused and is
574
+ logged once.
575
+ - **`LambderCaller`'s `isCorsEnabled` is optional**, and
576
+ `lambderFetchTransport()`'s `cors` defaults to whether the call's `apiPath`
577
+ is on another origin than the page's, where it was `false`. Credentialed
578
+ cross-origin mode applies exactly when a browser needs it; an explicit
579
+ value still wins.
580
+ - **`LambderSessionStore` has `create` and `update` in place of `put` and
581
+ `markDataExpired`.** No write replaces a record any more: `create` writes a
582
+ new session and refuses an existing one, and `update(hashes, changes,
583
+ condition?)` changes only the named fields, only while the record exists,
584
+ answering `"updated"`, `"missing"` or `"stale"`. `delete` hands back the
585
+ record it removed, or null when there was none. A store of your own
586
+ implements the three; `LambderDdbSessionStore` does them with conditional
587
+ writes and `ReturnValues` (a refused update tells stale from missing by the
588
+ item it hands back, with no read after it), and `listSecretHashes` reads
589
+ consistently.
590
+ - **`LambderSessionRecord` has a required `dataVersion`**, created at 0, and
591
+ `update`'s condition is `{ dataVersion }`. A store adds one to
592
+ `dataVersion`, in the same atomic write, on every update whose changes carry
593
+ `data` or `dataExpiresAt`, even when the value written is unchanged, and
594
+ applies a conditioned update only while `dataVersion` equals the
595
+ condition's, answering `"stale"` otherwise. `dataExpiresAt` is a plain
596
+ deadline. `LambderDdbSessionStore` reads an item without `dataVersion` as no
597
+ session, and the manager reads a record without a numeric one the same way,
598
+ whatever store handed it back.
599
+ - **The session manager's `deleteSession()` answers false when there was no
600
+ record to delete**; 7.x always answered true.
601
+ - **The session partition key is HMAC-SHA256 of the sessionKey keyed by
602
+ `sessionSalt`**, in place of sha256 of the sessionKey followed by the salt,
603
+ so two deployments sharing a table cannot collide when a sessionKey absorbs
604
+ the difference between their salts. Every existing session is signed out
605
+ once. `LambderSessionCrypto` has a required `hmacSha256Hex(key, value)`,
606
+ which `LambderWebCrypto` and `LambderPlainSessionCrypto` implement and a
607
+ crypto of your own adds.
608
+ - **`updateSessionData` and `refreshSessionData` no longer slide the
609
+ expiry**; a session read does, which is also what re-issues the cookies.
610
+ The manager's `updateSessionData` answers null, and the controller's
611
+ throws `LambderSessionNotFoundError`, when the session was ended while the
612
+ request held it; an API call answers that as sessionExpired rather than as
613
+ a crash. The controller's `refreshSessionData` throws the same when the
614
+ session is over, whether the `dataRefresh` callback ended it or something
615
+ else did, rather than answering null and clearing the session cookies by
616
+ name, which could delete a session another response had just set.
617
+ - **`regenerateSession()` carries the data over as it was stored**, not as
618
+ the request read it, and leaves no session behind for a session ended
619
+ during the request: the manager's answers null and the controller's throws
620
+ `LambderSessionNotFoundError`. It writes the new record before deleting the
621
+ old one, and takes the new one back out when the old one is already gone,
622
+ so a rotation racing "log out everywhere" cannot slip its new record past
623
+ the subject-wide delete's listing. With `dataRefresh`, the new session's
624
+ data starts due, so its next read renews it from the source of truth. It
625
+ deleted without checking and created a new session from the request's
626
+ copy, so a thief's request that rotated after the owner's password change
627
+ came away with a live session, and a rotation after
628
+ `expireSessionDataAllByKey` kept the revoked data.
629
+ - **A refusal's `errorMessage` is the message object, on the wire and for every
630
+ reader.** A plain string a handler writes (`new LambderApiRefusal("Denied.")`,
631
+ `res.api(null, { errorMessage: "Denied." })`, the framework's own crash
632
+ answer) goes out as `{ type: "error", content: "Denied." }`, and
633
+ `LambderApiRefusal`'s `errorMessage` property is that object. `LambderCaller`
634
+ and `LambderInvokeCaller` outcomes, `LambderInvokeError` and
635
+ `errorMessageHandler` hand over `LambderAppRefusalMessage`, never a string. A
636
+ reader that compared `envelope.errorMessage` to a string compares its
637
+ `content`. A handler typed for the old union still compiles. An outcome
638
+ narrowed to `reason: "errorMessage"` carries `errorMessage` as a required
639
+ field, on both callers.
640
+ - **`LambderRenderContext` has four more members** (`sessionController`,
641
+ `rateLimit`, `isRateLimited`, `rawPath`) and a fifth type parameter, the
642
+ app's policies. A context written out by hand as an object literal has to
643
+ add them.
644
+ - **Handler contexts carry the app's session type.** A handler registered
645
+ with `addApi`, `addSessionApi` or a literal-path `addRoute` reads
646
+ `ctx.session` as the app's `SessionData` rather than `any`, which can
647
+ surface type errors that were hidden. The `guards` option of `create()` is
648
+ typed to the app's session too. A RegExp route, a hook and a fallback still
649
+ read it as `any`.
650
+ - The mock's `ctx.sessions` is `ctx.sessionController`, the server's name for
651
+ it, so it is not one letter from `ctx.session` (the record). It is bound
652
+ the way the server's is, as a non-enumerable member, so it does not show in
653
+ `Object.keys(ctx)` or a spread copy of a mock context; destructuring reads
654
+ it as before. `ctx.rateLimit` and `ctx.isRateLimited` are bound beside it.
655
+ - `LambderApiEnvelopeBody`'s `apiVersion` is required (`string | null`), as
656
+ every envelope carries it: a hand-built answer typed with it without one no
657
+ longer compiles.
658
+ - **`LambderDdbCache` keeps a `getOrSet` fill's lease on the entry's manifest
659
+ item** instead of a separate `lock` item. The manifest item holds either
660
+ the value or, while a fill runs, only the lease (`leaseOwner`), whose
661
+ `expiresAt` is the end of the lease, so the table's TTL removes an
662
+ abandoned one. The IAM actions are unchanged.
663
+ - **`getOrSet` checks `ttlSeconds`, `leaseSeconds` and `waitForFillMs`
664
+ before it reads or loads**, on both caches, and an invalid one throws to
665
+ the caller. The fail-open caught it, logged, handed back the loader's value
666
+ and left caching off.
667
+ - **The `@aws-sdk/client-dynamodb` peer dependency starts at 3.868.0**, the
668
+ first version whose throttling errors name their reasons. Below it the
669
+ rate limiter never recognizes a key-range throttle.
670
+ On Lambda, a deployment that relies on the runtime's bundled SDK needs a
671
+ runtime whose client is at least that, or bundles its own (see the README).
672
+ - create() does not check at runtime for `LambderDdbSessionStore` fields
673
+ (`tableName`, `partitionKey` and the rest) on the session option; the
674
+ compile-time key check refuses them.
675
+ - `LambderCacheKey` is declared in the cache contract now; the export name is
676
+ unchanged. `LambderDdbCacheSetOptions` and `LambderDdbCacheListOptions` are
677
+ gone: both caches take the shared `LambderCacheSetOptions` and
678
+ `LambderCacheListOptions`.
679
+
680
+ ### Fixed
681
+
682
+ - **A `lambder/testing` visitor posts its own jar's CSRF token** where a
683
+ `document` holds one too (a test with a DOM): it posted the page's, and
684
+ every session call answered `sessionExpired`.
685
+ - **A flood on one rate-limit key is refused, and only that key.** DynamoDB
686
+ throttles a partition at roughly a thousand writes a second, and the
687
+ limiter passed the throttle on as a failure, which `failOpen` turned into
688
+ no limit at all for exactly the flood the limit exists for. A key-range
689
+ throttle (`KeyRangeThroughputExceeded` among the error's throttling
690
+ reasons) falls on every key of the partition, so the limiter reads the
691
+ window's own count with a consistent read: a key at or over its limit is
692
+ refused with a Retry-After of 5 seconds, and a key under it (a neighbour of
693
+ the flood, or of a session or cache spike on a shared table) has the
694
+ throttle passed on for `failOpen` to decide, as does a throttle of the
695
+ table or the account.
696
+ - **A key over a later window's cap is refused on a throttled partition.** The
697
+ limiter read only the throttled window, so at each minute rollover a key
698
+ over its daily cap went to `failOpen`. It now reads every window the attempt
699
+ was not counted against, in parallel, and refuses when any is at its limit.
700
+ Each process remembers such a window for 5 seconds and refuses the key's
701
+ repeats without touching the table. A key whose read is throttled as well
702
+ rethrows its first throttle, which `failOpen` logs once rather than per
703
+ request.
704
+ - **An idempotency scope's caller identity and sessionKey are bounded** like a
705
+ rate-limit key: past 1024 bytes they become `i:h:<sha256>` and
706
+ `s:h:<sha256>`. A long `callerIdentity` (a device token) put the scope past
707
+ DynamoDB's key limit, and `failOpen` turned the refusal into no idempotency
708
+ for that caller.
709
+ - **Key bounds measure the key as written**, escaped separators included.
710
+ 1,000 `|` characters passed the 1024-byte bound, overflowed the partition
711
+ key and failed open.
712
+ - **A replay is built from a copy of the stored record**, so a store that
713
+ hands back its own object cannot have the replaying call's Set-Cookie
714
+ written into it.
715
+ - **A guard or rate-limit `apiInput` slice is checked against a union input
716
+ whole**: a slice only some members carry is a compile error rather than a
717
+ 422 on every request of the others.
718
+ - **Retry-After is measured on the limiter's clock**
719
+ (`LambderRateLimiter.clockMilliseconds`, optional; both shipped limiters
720
+ have it).
721
+ - **DynamoDB stores share one default client per region.** Sessions, rate
722
+ limits, idempotency and the cache each built their own client when given
723
+ none: four connection pools and, on a cold container, four TLS handshakes
724
+ and credential lookups inside the first request. A store given a `client`
725
+ keeps using it.
726
+ - **The mock hands a handler a parse of the request's JSON**, as every
727
+ server-bound transport sends it. The direct transport handed over the
728
+ page's own object: a handler that stored the payload shared it with the
729
+ form, later edits changed the "saved" record with no call, and a `Date` or
730
+ a key set to `undefined` reached the handler as no server ever sees one.
731
+ - **A mock memory-mode page stays signed in after a logout and a login.**
732
+ `signIn` mirrors the CSRF cookie into `document.cookie` for the MSW
733
+ adapter, a page's caller reads its token from there, and a memory
734
+ transport never writes there again, so the next login's token landed in
735
+ the jar only and the stale one failed the pairing. In memory mode (and with
736
+ a jar you pass) the jar is the page's cookie store and its CSRF token is
737
+ the one posted.
738
+ - **The MSW adapter no longer reads the request's Cookie header.** MSW fills
739
+ it from its own store, which captured the HttpOnly session cookie and kept
740
+ it in localStorage across reloads: after a user switch both sessions went
741
+ out and every call answered sessionExpired, a cleared jar stayed signed
742
+ in, and the raw token showed in request events. A call's cookies are the
743
+ adapter's jar and `document.cookie`.
744
+ - **i18n detects the language on every read.** The first detection was kept
745
+ for the life of the process, so a path-based detector never saw `/en/`
746
+ become `/tr/`. A throwing detector is reported once.
747
+ - **i18n fills parameters in one pass**, so a value is inserted as it is: a
748
+ display name `Eve {org}` no longer had its `{org}` filled by the next
749
+ parameter.
750
+ - **A response object kept between requests no longer collects another
751
+ caller's headers.** A handler, hook or error handler that answered with an
752
+ object it keeps (a module-level 404) had the call's Set-Cookie, CORS,
753
+ `Vary`, `Content-Encoding` and `ETag` written into it, and the next caller
754
+ was sent the previous caller's session cookie. Each request now writes into
755
+ its own copy, and a response an afterRender hook answers with is copied
756
+ before the hooks after it write into it.
757
+ - **`redirectTrailingSlash` percent-encodes the Location, and so does
758
+ `res.redirect()`.** A TAB or line break in the path (`/%09/evil.example/`)
759
+ reached the header as it was, a browser drops those before resolving, and
760
+ `//evil.example` is another host; a decoded `ctx.path` carries them on every
761
+ gateway now, so an app redirecting to a path built from it was exposed the
762
+ same way. `res.redirect()` percent-encodes control characters, a space, a
763
+ backslash and anything outside ASCII in every Location, leaves `%` alone,
764
+ and collapses a path's leading run of slashes and backslashes to one slash,
765
+ so `//evil.example` built from the path stays on the site; another host is
766
+ named with its scheme.
767
+ - **The DynamoDB cache's memory layer keeps to `memoryMaxBytes`.** A small
768
+ compressed value was a view into zlib's much larger output buffer and was
769
+ counted as its own few bytes, so a warm container could hold many times
770
+ its budget. The layer now keeps each value in a buffer of exactly its own
771
+ size, and counts each entry with its key and a fixed overhead.
772
+ - **Waiters on a cache fill wait as long as the lease**, and a second more:
773
+ `waitForFillMs` defaults to (`leaseSeconds` + 1) × 1000 instead of 5 s, so a
774
+ loader slower than 5 s is no longer run again by every container that asked
775
+ for the key, and a waiter is still there to take over the lease of a holder
776
+ that crashed (a lease expires in whole seconds). A container refused the
777
+ lease because a value is already there serves it from the refusal, which
778
+ carries the item as the table's leader holds it (a chunked value's chunks
779
+ are read consistently), rather than loading: the read that found it missing
780
+ may have come from a replica that had not seen another container's fill yet,
781
+ and the loader would have run twice. A holder whose loader runs past the
782
+ lease has its publish refused by the takeover and is told so with
783
+ `console.warn`: such a call needs a `leaseSeconds` longer than its loader
784
+ takes, or under demand the entry never fills.
785
+ - **Overwriting a chunked cache value deletes the old version's chunks**,
786
+ which stayed in the table until their TTL (a year by default), and which
787
+ `delete`, `deletePartition` and `listSortKeys` then paid to read.
788
+ - **A cache read no longer deletes a fresh entry over replica lag.** Chunks
789
+ an eventually consistent read did not find yet, or that a newer write had
790
+ just replaced, counted as corruption and dropped the manifest. The read
791
+ now tries once more with a consistent read and drops only what fails that.
792
+ - **A cache that fails open no longer logs the key**, which is the app's
793
+ data (an email address, a user id).
794
+ - **API Gateway's own JSON errors no longer resolve as successes.** A 413, a
795
+ throttle, a WAF or missing-route 403 and an authorizer 401 answer
796
+ `{"message": ...}`, which read as an envelope with no payload: `ok: true`,
797
+ no `errorHandler`, the gateway text shown as an app message, and a refused
798
+ save that looked saved. An object is an envelope only when it carries
799
+ `apiVersion`, on a 5xx too, so a gateway's 502 `{"message": ...}` or a
800
+ proxy's body never lands on `response`, `errorMessage` or the invoke
801
+ caller's `crash`. A non-2xx answer that names no reason is a `server`
802
+ failure, with its status and `retryAfterSeconds`, a 503's `Retry-After`
803
+ included.
804
+ - **A stale `sessionExpired` no longer signs a fresh login out.** A call sent
805
+ before a login (a poll, another tab) that answered after it deleted the
806
+ CSRF cookie the login had just set. The caller now acts on it only while
807
+ the cookie is still the one it sent, or is gone. It reads the token right
808
+ before sending, after any guard-input provider, so a rotation answered
809
+ meanwhile does not pair the new session cookie with the old token.
810
+ Over a cookie jar (the mock's memory mode, `lambder/testing`,
811
+ `lambderCookieJarTransport`) it compares the jar's CSRF token, which the
812
+ transport reports on the answer as `csrfTokens: { posted, held() }`, since
813
+ that session never reaches `document.cookie`.
814
+ - **A timeout or abort while the answer's body downloads is reported as
815
+ one**, not as a malformed answer: the fetch transport reads the body inside
816
+ the call.
817
+ - **A corrected or edited retry is no longer handed another request's
818
+ stored answer.** A key reused after a refusal replayed that refusal for
819
+ the whole window (qty 2 told "only 5 in stock", from the qty 10 before
820
+ it), and an edited retry after a timeout was told the first order went
821
+ through while only the first was placed.
822
+ - **A DynamoDB claim the SDK retried after it had landed recognizes itself**
823
+ instead of answering its own original 409, and a refused claim costs one
824
+ write rather than a write and a read (`ALL_OLD`).
825
+ - **A `per: "ip"` limit counts an IPv6 caller by its /64**
826
+ (`rateLimits.ipv6PrefixLength`, default 64). Any IPv6 subscriber holds at
827
+ least a /64 and may rotate the address inside it, so one counter per full
828
+ address let it through on every request. An IPv4-mapped address counts as
829
+ its IPv4 address, and the port CloudFront-Viewer-Address always appends is
830
+ taken off by the header it came from, not guessed from the text, so a
831
+ compressed address cannot carry its port into the /64
832
+ (`2600:3c00::1111:91ff:fe93:1234:443` counts under `2600:3c00::/64`). An
833
+ unbracketed IPv6 address in any other header is read as the address it
834
+ spells.
835
+ - An async refinement in an input schema, a guard slice or a rate-limit key
836
+ slice validates instead of making zod throw on every call.
837
+ - Under a CORS allowlist or predicate every answer carries `Vary: Origin`,
838
+ including one to a refused or absent Origin, so a cache cannot serve it to
839
+ an allowed origin.
840
+ - **A logout, "log out everywhere", a password change or a revocation can no
841
+ longer be undone by a session write already in flight**, and "log out
842
+ everywhere" or a password change not by a rotation either. Every renewal,
843
+ data write and refresh wrote the whole record back unconditionally, so a
844
+ poll that slid the session as the user logged out re-created it (and
845
+ re-issued the cookies), and a thief's in-flight request brought a stolen
846
+ session back after the owner changed their password. Writes are now
847
+ conditional on the record existing, renewal writes only the expiry fields, a
848
+ data write that finds the data version moved since its read lands marked
849
+ due, so a revocation marked or already applied meanwhile stays in force, and
850
+ a rotation leaves no session behind for a session ended meanwhile. Deleting
851
+ every session of a subject lists them again when it finds a listed one
852
+ already gone, which is how a rotation that replaced it in between shows. It
853
+ lists at most four times; when a rotation completed inside every pass, it
854
+ logs that with `console.error` and the manager's `deleteSessionAll()` and
855
+ `deleteSessionAllByKey()` answer false, since a session may still stand. A
856
+ plain logout in one tab racing a rotation in another ends the session it
857
+ read; the rotated one lives on.
858
+ - **`updateSessionData()` no longer pushes back the `dataRefresh`
859
+ deadline.** Only the refresh callback's output is stamped fresh, so an app
860
+ that writes session data more often than `ttlSeconds` (usually
861
+ `{ ...ctx.session.data, x }`, still carrying the roles read at login)
862
+ still refreshes on schedule, and a session rotated by
863
+ `regenerateSession()` stays due when the handler writes data right after.
864
+ - **A revocation in the same second as the stored deadline holds.**
865
+ `expireSessionDataAllByKey()` marked data stale by writing the current
866
+ second, so a mark in the second the deadline already read, or a second
867
+ mark within one second, changed nothing, and a refresh already in flight
868
+ landed the revoked data for a full `ttlSeconds`. Conditional session
869
+ writes compare the data version instead.
870
+ - **A `LambderSessionNotFoundError` thrown from a route, a session route or a
871
+ `beforeRender` or `afterRender` hook is answered as a missing session**
872
+ (the `setSessionExpiredRouteHandler` answer or the default 401, or the
873
+ sessionExpired envelope on an API call), not as a 500 and a crash report.
874
+ It happens when the session was ended while the request held it.
875
+ So is `LambderSessionAmbiguousError`, now a subclass of
876
+ `LambderSessionNotFoundError`, when a route, a hook or an API handler calls
877
+ `fetchSession()` on a request whose cookies name more than one live session.
878
+ It answered a 500 and a crash report there, where `fetchSessionIfExists()`
879
+ read it as no session; the answer carries the clearing cookies.
880
+ - **The context a `beforeRender` hook hands back is the request's from then
881
+ on.** The handler received it, but the `afterRender` hooks, the global error
882
+ handler and a crash's `site.ctx` and `reveal(ctx)` received the context as
883
+ it arrived, without what the hook added and without the session a session
884
+ route or API read onto the replacement.
885
+ - **Active users are no longer signed out at creation plus TTL** by an app
886
+ that writes session data often: the data write slid the record but not the
887
+ cookies, and renewal then saw nothing to slide.
888
+ - **"Log out everywhere" finds a session created a moment before it** (the
889
+ listing reads consistently), and a subject's sessions are deleted or
890
+ expired a bounded number at a time rather than one round trip after
891
+ another.
892
+ - **Session data holding an `undefined` no longer fails the DynamoDB write**
893
+ with compression off (a login that answered 500 in production only): the
894
+ plain attribute is written from the same JSON the compressed one is.
895
+ - Session cookies carry `Max-Age` beside `Expires`, so a device whose clock
896
+ runs ahead does not drop a short-lived session early.
897
+ - **A crash nothing answered is no longer silent.** With no reporter, the
898
+ framework's own 500 logs the crash with `console.error` (and a global error
899
+ handler that threw, beside it). That invocation succeeds, so Lambda's error
900
+ metric never counted it, and nothing else recorded it.
901
+ - **A stored idempotency answer survives a `complete()` that reported a
902
+ failure after landing.** The engine releases the claim after any failed
903
+ store, and the release deleted on the owner token alone, which the stored
904
+ record still carries: a write whose response was lost took its record with
905
+ it, and the client's retry ran the operation again.
906
+ - **An invoke cannot set `ctx.ip` or `ctx.host` through forwarded headers.**
907
+ The server tells an invoke by its `requestContext.apiId`, which no gateway
908
+ lets a client write, and reads neither `trustedClientIpHeaders` nor
909
+ `trustedHostHeaders` on one, so the invoker's `clientIp` and `host` are the
910
+ only channel. A gateway lambda that forwarded a browser's headers handed a
911
+ callee that trusted `x-real-ip` or `x-forwarded-host` an address and a host
912
+ the browser chose.
913
+ The invoke event also drops an `x-forwarded-for` the caller forwards, so an
914
+ 8.x gateway lambda forwarding a browser's headers to a callee still on 7.x
915
+ that trusts that header hands it no address the browser chose. A
916
+ browser-shaped request (`lambder/testing`, `lambderHandlerTransport`) keeps
917
+ every header it is given, a forwarding header included, so a test exercises
918
+ the app's own `trustedClientIpHeaders`; 7.x dropped `x-forwarded-for` there
919
+ too.
920
+ - **An HTTP API sending payload format 1.0 has its path read as already
921
+ decoded**, as its 2.0 events are, so `/%2561dmin` stays text instead of
922
+ reaching `/admin` past a route an authorizer guards, and a named stage is
923
+ dropped from the front of its path.
924
+ - **A stale bundle with two or more stale endpoints no longer reloads
925
+ forever.** Each refusal overwrote the other's record, so no load saw a
926
+ repeat. The caller keeps every call refused within the window, by endpoint,
927
+ signature and version, with the time it was refused, per tab and per origin
928
+ in `sessionStorage`. Only a call recorded before the document loaded counts
929
+ as a repeat, so a retry or a second caller in the same page is asked about,
930
+ not reported as a loop. A page asks one `versionExpiredHandler` at a time,
931
+ however many of its calls, and of its callers, answer `versionExpired`:
932
+ refusals heard while it runs call nothing, and one heard after it returned
933
+ with the page still open asks again, so a per-call handler that does
934
+ something else, or a reload cancelled at a `beforeunload` prompt, leaves no
935
+ later call failing silently. Without `sessionStorage` nothing survives a
936
+ reload, so the protection lasts for the page only.
937
+ - **Server-side `t()` answers in `defaultLanguage`**, not the process
938
+ locale: Node 21 and later define `navigator.languages` from it. Browser
939
+ detection runs only where there is a `document`.
940
+ - **A `getOrSet` fill that finishes after a `set`, `delete` or
941
+ `deletePartition` of its key does not store the value its loader read
942
+ before that write**, on either cache. On `LambderDdbCache` the write
943
+ replaced or removed the fill's lease, so the fill's publish is refused, the
944
+ chunks it wrote are deleted, and its value goes back to its callers
945
+ uncached; a fill whose lease lapsed and was taken over is refused the same
946
+ way. A `getOrSet` made after the write starts its own load rather than
947
+ joining the overtaken one.
948
+ `delete` removes the manifest item by its key and `deletePartition` finds
949
+ the partition's items with a consistent Query, so a lease another container
950
+ took a moment before goes too.
951
+ - **A `LambderDdbCache` read whose reply arrives after a `set`, `delete` or
952
+ `deletePartition` in the same instance does not put the replaced value back
953
+ in the memory layer**, and two overlapping writes of one key leave no memory
954
+ copy. For a few seconds after an instance writes a key (or drops its
955
+ partition) it reads that key with consistent reads, so a read that starts
956
+ after the write and reaches a replica that has not applied it cannot answer
957
+ or keep the old value either. Only the key written is affected: reads and
958
+ writes of other keys in flight keep their values in memory.
959
+ - **`LambderDdbCache.delete` and `deletePartition` remove what another
960
+ container wrote a moment before.** Both found the items to delete with an
961
+ eventually consistent Query, which can miss an item the table accepted
962
+ within replica lag: `delete` answered false and a value published just
963
+ before it stayed, served by every container for its TTL, and a fill's lease
964
+ stayed, so that fill published a value its loader read before the change.
965
+ `delete` removes the manifest item by its key and finds its chunks with a
966
+ consistent Query; `deletePartition` queries consistently.
967
+ - **`LambderDdbCache.delete` answers `true` only when a live value was
968
+ there**, and `deletePartition` counts only live values, as
969
+ `LambderMemoryCache` does: a fill's lease, a value past its TTL the table
970
+ had not removed yet, orphan chunks and a 7.x `lock` item all counted.
971
+ `delete` leaves a 7.x `lock` item to its TTL.
972
+ - **Calls that share one `getOrSet` load each get a parse of their own**, on
973
+ both caches, as every read does. They got one object between them, so one
974
+ caller's change to its answer showed in the others'.
975
+ - **A throwing `cors.origins` predicate no longer fails the request** with a
976
+ 502 and a second, misattributed crash report (see the predicate above).
977
+ - **A stalled `crashes.report` no longer turns every crash into a Lambda
978
+ timeout** (see `crashes.reportTimeoutMs`).
979
+ - **`html` and `xml` no longer let a user-supplied value run script.**
980
+ `` html`<a href="${user.website}">` `` with `javascript:alert(document.cookie)`
981
+ produced a live link, and an interpolation inside `onclick="..."` ran once
982
+ the browser decoded the escapes. The tagged templates and
983
+ `LambderTemplatingEngine` slots share one implementation of the position
984
+ rules and the URL check.
985
+ - **`LambderTemplatingEngine` reads `<script>` and `<style>` content as raw
986
+ text up to the element's own end tag.** A `<` and a quote inside a script
987
+ no longer hide an unquoted slot after it, and `</scripts>` does not end a
988
+ script. A `<script>` mentioned in a comment or an attribute value, or a
989
+ custom element such as `<style-box>`, no longer makes a slot refused.
990
+ - **A template slot's position is read along the template's branches.** The
991
+ check read the text of both branches of an `<!--if:...-->` run together, so
992
+ an attribute name the if/else chose (`<a
993
+ <!--if:x-->title<!--else-->onclick<!--/if:x-->="<!--slot:v/-->">`) read as
994
+ `titleonclick`, neither refused nor URL-checked, while the else branch
995
+ rendered the slot inside a live `onclick` (and `href` against `data-lang`
996
+ gave an unchecked `href`). Each branch is now read from where its block
997
+ starts, a slot's default content and its value are two branches, and the
998
+ branches have to leave the HTML in the same position before the next slot or
999
+ block, or the template is refused when it compiles, naming the block. Vary
1000
+ whole tags or elements inside the branches. The check is one pass over the
1001
+ template instead of a rescan from the start per slot.
1002
+ - **Comments end where the browser ends them.** `<!-->`, `<!--->` and `--!>`
1003
+ close a comment, and the check kept reading what followed as comment text,
1004
+ so a slot or interpolation after it in an unquoted attribute (`<!--><a
1005
+ title=<!--slot:v/-->>`) passed and rendered a live attribute list. A value
1006
+ in a comment right before a `-`, `!` or `>` that would end the comment after
1007
+ a value ending in `--` is refused too; put a space after it.
1008
+ - **The position check reads markup the way a browser's tokenizer does.** The
1009
+ attributes of an end tag, bogus comments and DOCTYPEs (`<!x ...>`,
1010
+ `<?...>`), the content of `<title>`, `<textarea>`, `<xmp>`, `<iframe>`,
1011
+ `<noembed>` and `<noframes>`, a script's `<!--<script>` escape, a no-break
1012
+ space inside a tag, and an `=` that starts an attribute name each left the
1013
+ check inside a quote, or outside a script, that the browser had left or was
1014
+ still in, so a slot or interpolation passed in an unquoted value or inside a
1015
+ script.
1016
+ - **The position check fails closed where inline SVG and MathML read the
1017
+ template apart from plain HTML.** It read the content of `<title>`,
1018
+ `<textarea>`, `<xmp>`, `<iframe>`, `<noembed>` and `<noframes>` as text
1019
+ everywhere, and a CDATA section as a bogus comment ending at its first `>`,
1020
+ where inside `<svg>` or `<math>` that content is markup and the section runs
1021
+ to `]]>`, so `<svg><title><img src=x onerror="${v}">` rendered a live
1022
+ handler. Once such content holds a tag, or a CDATA section runs past its
1023
+ first `>`, every value from there on is refused, past the element's end tag
1024
+ too, in `html` and in `LambderTemplatingEngine` alike; plain
1025
+ `<title>${v}</title>` and `<textarea>${v}</textarea>` render escaped as
1026
+ before. And `html` and `xml` throw when a call's template does not end in
1027
+ plain text (inside a tag, an attribute value, a comment, or the content of a
1028
+ `<script>` or a text-only element): a nested fragment is inserted without
1029
+ being read, so one that ended mid-markup (`${html`<a href=`}${url}>`) moved
1030
+ the interpolations after it and rendered a value unquoted.
1031
+
1032
+ ### Documentation
1033
+
1034
+ - [docs/sessions.md](./docs/sessions.md) recommended `regenerateSession()`
1035
+ after a password change, which rotates only the caller's own session; it
1036
+ now says `endSessionAll()` then `createSession()`.
1037
+ - [docs/routing.md](./docs/routing.md#crashes) says a crash reporter's
1038
+ `site.ctx` carries the request's secrets (the session cookie, a login's
1039
+ password in the body, the session record), so a reporter forwards the
1040
+ fields a crash needs rather than the whole context.
1041
+ - [docs/configuration.md](./docs/configuration.md#trustedhostheaders) says a
1042
+ Function URL with auth `NONE` can be called directly with any
1043
+ `x-forwarded-host`, so the header is trustworthy only when the function is
1044
+ reachable solely through the distribution (`AWS_IAM` auth plus CloudFront
1045
+ origin access control).
1046
+ - [docs/testing.md](./docs/testing.md) said the production stores are out of
1047
+ reach under the test app; that holds for the stores the instance holds. A new
1048
+ section says which Lambder classes an app constructs itself and what to swap
1049
+ each for (`LambderMemoryCache`, `ctx.rateLimit`,
1050
+ `LambderInvokeCaller.localTransport`).
1051
+ - [docs/templating.md](./docs/templating.md#branches) describes the branch
1052
+ rule, and says what the URL check leaves to the app: SVG animation targets
1053
+ and a meta refresh `content` are not checked, and a value starting with `/`
1054
+ or `\` turns a root-relative `href="/..."` protocol-relative.
1055
+
12
1056
  ## [7.3.1] - 2026-09-21
13
1057
 
14
1058
  ### Added
@@ -1612,7 +2656,7 @@ out silently falls back to the SDK's default chain.
1612
2656
  - **The mock no longer signs a browser out on any host but plain `localhost`.**
1613
2657
  `signIn` planted cookies at `localhost` while the transport's jar scoped them
1614
2658
  by the caller's site host, so every session call on a dev host such as
1615
- `transit.localhost:5173` answered sessionExpired with a full jar.
2659
+ `shop.localhost:5173` answered sessionExpired with a full jar.
1616
2660
  - **`sessionNotMocked` runs the same registration checks the other builders
1617
2661
  run**, so a session endpoint on a mock without the `sessions` option fails
1618
2662
  where it is written instead of answering 500 at the first call with a message
@@ -2175,8 +3219,8 @@ than a migration.
2175
3219
 
2176
3220
  - **Grouped cache keys.** `LambderDdbCache` keys may be a `{ pk, sk }` pair
2177
3221
  instead of a string, which stores related entries in one partition:
2178
- `{ pk: "division:ist-34", sk: "1700:1800" }` keeps every cached window of one
2179
- division together. `deletePartition(pk)` then drops the whole group without
3222
+ `{ pk: "store:nyc-01", sk: "1700:1800" }` keeps every cached window of one
3223
+ store together. `deletePartition(pk)` then drops the whole group without
2180
3224
  knowing which sort keys exist, and `listSortKeys(pk, { prefix, limit })`
2181
3225
  reads back what is currently cached under it. The group invalidation a cache
2182
3226
  of derived, per-entity values needs, in place of remembering every key ever