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