lambder 6.0.2 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,2316 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
+ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ Entries are grouped by the published npm version; a minor line's feature notes
8
+ sit on its first published patch, and later patches list only what they changed.
9
+ Releases up to 3.2.6 carry git tags; the ones after it were published without
10
+ one, so versions are not cross-linked to tag comparisons here.
11
+
12
+ ## [7.0.0] - 2026-09-15
13
+
14
+ A major. The API pipeline moved out of the Lambda server into an isomorphic
15
+ core that the server and a new mock runtime both execute, the session layer
16
+ went behind a store interface, guards lost their resolver argument, and the
17
+ MSW adapter was replaced by a mock runtime. The wire format is unchanged in
18
+ both directions, so a deployed callee and an older client still understand
19
+ each other; the breaks are all in code.
20
+
21
+ Live sessions survive the upgrade. The DynamoDB item is unchanged, and so are
22
+ the token format, the hash constructions and the cookie names, so an existing
23
+ session validates against v7 given the same `sessionSalt`, the same cookie
24
+ keys, and a `LambderDdbSessionStore` over the same table and key attributes.
25
+ `LambderSessionStore` and `LambderSessionRecord` are generic over the session
26
+ data now, which is a type-level change alone: it renames nothing at rest. The
27
+ one thing to carry over deliberately is the region: it was required as
28
+ `session.tableRegion` and is optional as the store's `region`, so leaving it
29
+ out silently falls back to the SDK's default chain.
30
+
31
+ ### Breaking
32
+
33
+ - **The `session` option takes a store.** `session: { store, sessionSalt, ... }`
34
+ replaces `tableName`, `tableRegion`, `partitionKey`, `sortKey` and
35
+ `compression`, which moved onto `new LambderDdbSessionStore({ tableName,
36
+ region, partitionKey, sortKey, compression })`. `LambderSessionManager`
37
+ takes `{ store, sessionSalt, crypto?, ... }`, and every read is async,
38
+ because a store and a hash both are.
39
+ - **A leftover `tableName`, `tableRegion`, `partitionKey`, `sortKey` or
40
+ `compression` on the `session` option now throws at creation.** This is the
41
+ one break the compiler cannot find: `create()` is generic over
42
+ `const TOptions`, which switches excess-property checking off for the whole
43
+ options object, so those fields compile and would otherwise be dropped in
44
+ silence while the store fell back to its own table defaults.
45
+ - **`LambderSessionManager.getSession` is gone.** It was the combined shape the
46
+ `lookupSession` / `renewSession` split replaced, with no caller left: read
47
+ with `lookupSession` and renew with `renewSession`, which is what lets a
48
+ caller weighing several cookies decide which one is the visitor's before
49
+ anything is written on their behalf.
50
+ - **`LambderSessionManager.isSessionValid` is two methods**,
51
+ `isSessionTokenValid(record, sessionToken)` and
52
+ `isSessionCsrfTokenValid(record, csrfToken)`, both typed `string | null`
53
+ rather than `unknown`, with no trailing skip flag. A boolean at the call
54
+ site said nothing about which half it turned off.
55
+ - **`LambderSessionController.isSessionValid` is gone.** It had no caller and
56
+ it was the same combined shape `getSession` was removed for; it also used
57
+ to default to the request's FIRST session cookie, which on a request
58
+ carrying several is whichever copy the browser happened to put first, not
59
+ the one the read resolved. An app verifying a token itself reaches for the
60
+ manager's `isSessionTokenValid` and `isSessionCsrfTokenValid`.
61
+ - **`LambderSessionStore` and `LambderSessionRecord` are generic over the
62
+ session data** (`LambderSessionStore<SessionData = unknown>`), so
63
+ `ctx.session.data` is no longer laundered through `any`. Both shipped stores
64
+ take the parameter; the `session.store` option itself stays typed over `any`
65
+ on purpose, so an app's session type comes from `initLambder<SessionData>()`
66
+ rather than from its table.
67
+ - **`createSession` refuses a TTL that is not a positive whole number of
68
+ seconds.** A NaN from an unparsed environment variable used to write a record
69
+ nothing ever retires and no read ever accepts.
70
+ - **`LambderSessionStore` implementations must declare `isMemoryOnly`**, and
71
+ `LambderSessionCrypto` implementations must declare `isCryptographic`. The
72
+ session manager refuses to put non-cryptographic hashing in front of a store
73
+ that outlives the process, which no docstring could enforce.
74
+ - **`LambderSessionController`'s constructor changed shape**:
75
+ `lambderSessionManager` is `manager`, `sessionTokenCookieKey` is
76
+ `tokenCookieKey`, `sessionCsrfCookieKey` is `csrfCookieKey`, the public
77
+ fields were renamed to match, and `request` (the call's cookies and posted
78
+ CSRF token) is now required. Apps reach the controller through
79
+ `lambder.getSessionController(ctx)` and never construct one.
80
+ - **A session record is `LambderSessionRecord`**: `sessionKeyHash` and
81
+ `secretHash` in place of the table's own `pk`/`sk` attributes, and no index
82
+ signature.
83
+ - **An empty `sessionSalt` throws at creation.** It salts the hash that
84
+ partitions the store, so an unset environment variable stringifying to
85
+ nothing has to be loud rather than silently partitioning everything alike.
86
+ - **`addSessionApi` and `addSessionRoute` are compile errors on an instance
87
+ created without the `session` option**, the way `idempotency` already was.
88
+ The registration-time throw stays behind them, for a caller that reaches
89
+ registration through a cast or from JavaScript.
90
+ - **`ctx.session` is typed `LambderSessionRecord<SessionData> | null` on every
91
+ render context** instead of the literal `null`, so reading `ctx.session?.data`
92
+ after `createSession` compiles and the session route no longer needs a double
93
+ cast.
94
+ - **`ctx.post` is typed `Record<string, unknown>`**, so a reader narrows a
95
+ posted value before using it.
96
+ - **The global error handler takes three arguments.** The fourth
97
+ `logListToApiResponse` parameter is gone, since `ctx.logList` is on the
98
+ context the handler already receives.
99
+ - **Creation refuses an `apiPath` that does not start with `/`, an empty
100
+ `apiVersion`, and a `maxResponseBytes` that is not a positive integer**, each
101
+ naming the option it is about.
102
+ - **An option key the type does not have is now a compile error, at the top
103
+ level and inside a nested option.** `create()` is generic over
104
+ `const TOptions`, and inferring a generic from an object literal switches
105
+ excess-property checking off for the whole literal, nested objects included,
106
+ so `requireSessionApiGuard` (no trailing "s"), `maxResponseByte` or
107
+ `idempotency: { failOpn: false }` compiled, was dropped in silence, and left
108
+ the app running on the default. The two guard flags are the ones that hurt,
109
+ since both exist to make a missing authorization declaration a compile error,
110
+ and a one-character typo turned that back off with no signal from the
111
+ compiler or the runtime. Surplus keys now map to `never`, which puts the
112
+ error on the key itself, in `session` and `session.cookie`, `idempotency`,
113
+ `rateLimits` and each of its `policies`, and each guard in `guards`. The
114
+ mock's `create()` refuses the same way.
115
+ - **Mock handlers take the call context, not the payload.** A handler was
116
+ `(payload) => output` under `LambderMSW.mockApi`; a `lambder/mock` handler is
117
+ `(ctx) => output`, with the payload on `ctx.payload`. The compiler catches a
118
+ handler that reads its first argument, and does NOT catch one that ignores it
119
+ or casts it, so rename the parameter before migrating and let every read
120
+ become an error.
121
+ - **`mock.create()` requires its options argument**, and requires `guards`
122
+ whenever the contract declares a guard name: a guard the mock does not
123
+ declare cannot run, so the mock answered 200 where the server answers
124
+ notAuthorized. The bare form is `mock.create({})`. `LambderMockApp`'s
125
+ constructor no longer defaults its options argument either, for the same
126
+ reason.
127
+ - **A mock entry must restate `rateLimit` and `idempotency`** when the
128
+ contract declares them, the way it already had to restate `guards`. Optional,
129
+ they were the droppable half: the restatement is the only thing that tells
130
+ the runtime to take a claim or apply a limit, so an entry that left one out
131
+ answered 200 where the server answers a replay, a 409 or a 429.
132
+ - **The mock's `idempotency` option is a named type,
133
+ `LambderMockIdempotencyOptions`**, and carries the server's full set:
134
+ `defaultTtlSeconds`, `defaultPendingTtlSeconds`, `failOpen`, `store` and
135
+ `callerIdentity` (bound to the mock call context). Without `callerIdentity` a
136
+ mock replayed a public endpoint's stored answer where the server, configured
137
+ with one, misses.
138
+ - **A not-mocked session endpoint is declared with `sessionNotMocked`.**
139
+ `notMocked` now takes public names only. The refusal runs through the
140
+ pipeline so the steps before dispatch still happen, and the session read is
141
+ one of them, so declaring every not-mocked endpoint public switched that step
142
+ off and answered "not mocked" where the server answers `sessionExpired`. The
143
+ mode cannot be recovered at runtime, because the contract is a type.
144
+ - **A rate-limit policy's custom key is typed for the runtime that will call
145
+ it.** `LambderApiRateLimitPolicyConfig` and `LambderRateLimitPer` are generic
146
+ over the context, the server's policies map is bound to the render context
147
+ and the mock's to the mock call context, and `lambderRateLimitKeyBuilder`
148
+ binds the builder the way `lambderGuardBuilder` already did. A handler built
149
+ with the server's `lambderRateLimitKey()` used to compile against the mock
150
+ and then read `ip`/`method`/`path` as `undefined`, so every caller collapsed
151
+ onto one counter and a per-IP limit a test was written to prove proved
152
+ nothing. Build mock keys with `rateLimitKey` from `initLambderMock()`.
153
+ - **`rateLimits.failOpen` replaces the limiter's own.** Fail-open is a decision
154
+ about the request rather than about a store, so it sits beside `policies`
155
+ (default true) and applies to any `LambderRateLimiter`, including one of your
156
+ own, which previously had no fail-open at all. `LambderDdbRateLimiter`'s own
157
+ `failOpen` option is gone with it: a DynamoDB error propagates, and the engine
158
+ decides.
159
+ - **`rateLimit: {}`, `rateLimit: []` and `rateLimit: { policy: undefined }` are
160
+ compile errors**, built from the same non-empty construction the `guards`
161
+ option uses. All three declared a limit and enforced none.
162
+ - **A guards option with nothing in it throws at creation**, and so does a
163
+ `rateLimits` option with no policies in it. Declaring the option is declaring
164
+ a guard or a policy; an empty map used to leave the engine unconfigured and
165
+ then report every API that named a guard or a policy as if the option had
166
+ never been given.
167
+ - **Guard handlers no longer receive a resolver.** A guard is
168
+ `(ctx, input, param)`, was `(ctx, input, res, param)`. Guards say no with
169
+ `refuse()` or a thrown `LambderApiRefusal`. Building a response from a guard
170
+ was never a supported idea, and a guard that "denied" by RETURNING one
171
+ already failed open in 6.0.2, so both spellings are now rejected: the
172
+ builder refuses a handler whose return type is a `LambderResponse`, and the
173
+ engine throws if one reaches it through a cast. (Rate-limit key handlers are
174
+ unchanged: they were already `(ctx, payload)`.)
175
+ - **`LambderApiGuard` types its handler's context.** The two parameters carry
176
+ the adapter's plain and session-typed contexts, so the server's guards map is
177
+ pinned to the render contexts and the mock's to the mock call contexts. A
178
+ server guard dropped into a mock map compiled, read `ctx.ip` as `undefined`,
179
+ and then authorized or refused everything.
180
+ - **`LambderRefusalMessage` takes the app's code vocabulary as a type
181
+ argument.** `code` was `LambderRefusalCode | (string & {})`, which narrows to
182
+ nothing: a `switch` case was not assignable and the `default: never`
183
+ assertion failed, so the exhaustiveness the refusal codes exist for was
184
+ unavailable. `LambderRefusalMessage<"app/...">` adds your codes, plain
185
+ `LambderRefusalMessage` is the framework's alone, and the new
186
+ `LambderAppRefusalMessage` is what options an app WRITES take (a policy's
187
+ `errorMessage`, the mock's failure injection).
188
+ - **`LambderApiRequest.idempotencyKey` is `unknown`.** It is client data: the
189
+ shape check and the client-facing 400 belong to the idempotency engine, and
190
+ the old `string | undefined` entitled every reader to treat a number or an
191
+ object as a key.
192
+ - **`onInvalidInput` may return `null`** to ask for the standard 422, so "no
193
+ handler, standard 422" is implemented once, in the pipeline.
194
+ - **`parsePreflightSlice` and `toGuardEntries` are no longer root exports.**
195
+ They are the engines' own helpers; `parsePreflightSlice` now lives beside
196
+ `LambderApiValidationRefusal` and `toGuardEntries` is private to the guards
197
+ engine.
198
+ - **`LambderApiOutcome`'s failure side is discriminated by `reason`**:
199
+ `network`, `timeout`, `server` and `unknown` always carry `error`,
200
+ `validation` always carries `zodError`, and `versionExpired`,
201
+ `sessionExpired`, `notAuthorized` and `errorMessage` always carry `response`.
202
+ The arms are exported as `LambderApiCallFailure`,
203
+ `LambderApiValidationFailure` and `LambderApiEnvelopeFailure`, beside
204
+ `LambderApiSuccessOutcome` and `LambderApiAnswerOutcome` (what
205
+ `resolveApiOutcome` returns). Code that read `outcome.response?` or
206
+ `outcome.error?` without narrowing on `reason` now narrows first, and needs
207
+ no `!`.
208
+ - **`api()` and `apiOutcome()` on both callers compute their output from the
209
+ contract** in the return type instead of taking a free type parameter, so a
210
+ call-site annotation can no longer replace it, and take the payload as part
211
+ of a rest tuple, so it is a compile error to omit it unless the API's input
212
+ accepts `undefined`.
213
+ - **`LambderCallOptions.headers` and `LambderInvokeCallOptions.headers` are
214
+ `Record<string, string>`**; a number as a header value no longer compiles.
215
+ - **A rejected invoke that carries the Lambda SDK's own service-exception marks**
216
+ (`AccessDeniedException`, `ResourceNotFoundException`,
217
+ `RequestEntityTooLargeException`, a throttle) **is `protocol` rather than
218
+ `network`**, and a `LambderInvokeTransport` may throw
219
+ `LambderTransportFailure` to name its own reason. Genuine connectivity
220
+ failures stay `network`.
221
+ - **`LambderCookieJar.storeSetCookies` takes the full target, `secure`
222
+ included.** A target known to speak plain http now refuses a `Secure` cookie
223
+ instead of storing one it would never send.
224
+ - **`LambderDdbCache` no longer defaults `region` to `"us-east-1"`.** Leaving
225
+ the option out now means the AWS SDK's own default chain, the way the rate
226
+ limiter, the idempotency store and the session store have always behaved. An
227
+ app that deployed outside Virginia and relied on the old default has to name
228
+ the region.
229
+ - **`LambderExpiringMap.set()` throws unless `expiresAt` is a positive whole
230
+ number of epoch seconds**, and `size` and `values()` never sweep, so
231
+ reading the map never writes to it.
232
+ - **A rate-limit window capped at anything but a non-negative integer throws
233
+ at creation**, and so does a per-API override of one, and so does an
234
+ override that takes away the policy's last enforced window. A limiter is
235
+ handed only limits it can act on, which is where the two implementations
236
+ used to disagree.
237
+ - **An idempotency replay window that a store cannot act on throws at
238
+ creation**, both as `defaultTtlSeconds` and as an API's own `ttlSeconds`.
239
+ - **Every `per: "ip"` rate limit changes its key**, because `ctx.ip` no longer
240
+ reads a forwarding header unless `trustedClientIpHeaders` names one. Nothing
241
+ in an app's code has to change for this to happen, so it is listed here as
242
+ well as under Security; an app behind Cloudflare must set the option or its
243
+ limits will key on the gateway address.
244
+ - **The policy layer moved into `src/api/`** and `LambderApiPolicies.ts` is
245
+ `LambderApiPolicyEngine.ts`, named for the one thing it exports (same names
246
+ from the root entry; only deep imports break). `policies/` and `api/` were
247
+ one unit split across two directories: they imported each other in both
248
+ directions, every file in `policies/` was already named `LambderApiXxx`,
249
+ and docs/api-core.md described the policies as pipeline steps.
250
+ - **`LambderMockApp` split into four collaborators it composes**:
251
+ `LambderMockEntryRegistry` (registered entries and the overrides over them),
252
+ `LambderMockCallRecorder` (subscriptions and the bounded call log),
253
+ `LambderMockFailureInjector` (queued and standing failures, the offline
254
+ switch, latency, and the answers an injected failure renders) and
255
+ `LambderMockBrowserCookies` (the jars the runtime built for itself and the
256
+ copies it planted in the page's own cookie storage, the one piece that
257
+ touches `document`). They were four state machines in one class,
258
+ sharing no field and meeting only at `handleRequest`. Each owns its own state
259
+ now, and the app's **surface is unchanged**: every method a caller used is
260
+ still there, delegating. The four are deliberately not exported, since the
261
+ runtime is reached through the app. `LambderMockTransportError` moved to
262
+ `mock/LambderMockFailureInjector.ts`, and is still exported from
263
+ `lambder/mock`.
264
+ - **`LambderSessionStore` moved to `shared/`, `LambderMemorySessionStore` to
265
+ `stores/`, and `shared/LambderRateLimitWindows.ts` is
266
+ `shared/LambderRateLimiter.ts`** (same names from the root entry; only deep
267
+ imports break). The three store interfaces sat in three different places
268
+ while docs/api-core.md presented them as one symmetric set, and the memory
269
+ session store sat apart from its two siblings, so a reader looking for it
270
+ beside them did not find it.
271
+ - **The file-source family is laid out like the other three store families.**
272
+ `shared/LambderFileSource.ts` holds `LambderFile`, the `LambderFileSource`
273
+ interface and `remoteStoreFile`, beside `LambderSessionStore`,
274
+ `LambderRateLimiter` and `LambderIdempotencyStore`;
275
+ `stores/LambderLocalFileSource.ts` holds the local implementation beside
276
+ `LambderS3FileSource` and `LambderHttpFileSource`; `core/LambderFiles.ts`
277
+ keeps the reader and owns the path rule (`toRelativePath` is module-local
278
+ there, its only caller). Root exports are unchanged; a deep import of
279
+ `core/LambderFiles.js` or `stores/LambderFileSource.js` for a source is not.
280
+ - **`lambderCookieJarTransport` moved to its own module,
281
+ `shared/lambderCookieJarTransport.ts`.** The name and all three entries
282
+ (`lambder`, `lambder/client`, `lambder/mock`) are unchanged; only a deep
283
+ import of `shared/LambderApiTransport.js` has to move.
284
+ - **`compressPayloadBrotli` and `DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS`
285
+ moved to `shared/LambderRequestPayload.ts`**, beside their gzip twins. The
286
+ root entry exports them under the same names.
287
+ - **The invoke outcome vocabulary moved to `invoke/LambderInvokeOutcome.ts`**
288
+ (`LambderInvokeError`, `isLambderInvokeError`, `LambderInvokeOutcome`,
289
+ `LambderInvokeFailure`, `LambderInvokeFailureReason`,
290
+ `LambderInvokeFunctionError`; same names from the root entry). It is what a
291
+ caller of the caller reads, and reading it meant opening the caller's own module;
292
+ `shared/LambderApiOutcome.ts` already serves the browser side this way.
293
+ - **The idempotency store's types moved to `shared/LambderIdempotencyStore`**,
294
+ and the rate-limit vocabulary (`LambderRateLimitWindow`,
295
+ `LambderRateLimitPolicy`, `LambderRateLimitExceeded`,
296
+ `LambderRateLimitResult`, `RATE_LIMIT_WINDOWS`) moved from
297
+ `stores/LambderDdbRateLimiter` to `shared/LambderRateLimiter`, so a store
298
+ implements an interface without importing an engine or an SDK. Same names
299
+ from the root entry; only a deep import breaks.
300
+ - **`LambderHttpEventFormat` is declared in `LambderContext`** rather than in
301
+ `LambderResponse`, which removes the type cycle between the two. The root
302
+ export is unchanged.
303
+ - **`DieResolverMethods` and `LambderResponseInit` are no longer exported from
304
+ their modules**; neither was reachable from an entry point.
305
+ - **`ctx._otherInternal` is gone.** `ctx.api` is the parsed API request (null
306
+ on a route, so `isApiCall` is `ctx.api !== null` and `requestVersion` is
307
+ `ctx.api?.version`), `ctx.eventFormat` the payload format,
308
+ `ctx.responseHeaders` the header accumulator `res.setHeader`/`res.addHeader`
309
+ write to, and `ctx.logList` the logList channel.
310
+ - **`LambderRateLimitKeyFn`'s context type widened to `any`.** Code that
311
+ annotates a key handler with the type loses the context's typing and gets
312
+ no error where it used to. The `lambderRateLimitKey()` builder still pins
313
+ it, so building keys through the builder is unaffected.
314
+ - `restoreBytes` returns a `Uint8Array` (a `Buffer` on Node).
315
+ - `LambderApiAnswer` (the resolver's `api` method type) is now
316
+ `LambderResolverApiMethod`; the name `LambderApiAnswer` is the core's answer
317
+ type.
318
+ - `LAMBDER_INVOKE_HEADER`, `LAMBDER_INVOKED_BY_HEADER` and
319
+ `LAMBDER_INVOKE_PROTOCOL` moved to `invoke/LambderLambdaEvent.ts` (same
320
+ exports from the root entry).
321
+ - **`LambderRefusalCode` gained `lambder/not-mocked`**, so an exhaustive
322
+ switch over the union stops compiling until it handles the new member.
323
+ - **`LambderMswModule` changed shape and moved** to `lambder/mock`:
324
+ `HttpResponse` is the constructor plus `error()`, where it was `{ json() }`.
325
+ - **`lambder/testing` and `LambderMSW` are gone**, replaced by `lambder/mock`
326
+ and `LambderMockApp`.
327
+ - **`decompressPayloadGzip` is gone from `lambder/client`.** Inside the
328
+ framework the core's `restoreCompressedPayload` replaces it, but that is a
329
+ server-entry export and takes a request rather than a base64 string, so a
330
+ browser consumer that decoded payloads itself has no drop-in replacement.
331
+ - **Two aliases are gone, with nothing kept in their place**:
332
+ `LambderSessionContext` is `LambderSessionRecord` and
333
+ `LambderInvokeHttpResult` is `LambderLambdaHttpResult`. Both named exactly
334
+ what they aliased.
335
+ - **A caller outside a browser sends `siteHost: ""`** where 6.0.2 threw a
336
+ `ReferenceError` on `window`. Nothing in the framework reads the field, but
337
+ an app that routes on it sees the change.
338
+ - **`res.api(null, {})` is a compile error on an endpoint whose output is
339
+ not nullable.** The null overload takes `LambderApiNullAnswerConfig`, the
340
+ response config with at least one reason field (`errorMessage`, `message`,
341
+ `notAuthorized`, `sessionExpired` or `versionExpired`). A bare null with no
342
+ reason reached the caller as a success whose payload was null, which no
343
+ caller could tell from an endpoint that answered nothing on purpose.
344
+ - **The exported pipeline's `guards` option is bound to its own public
345
+ context**, the way its `rateLimits` option already was, so a guard built for
346
+ another adapter is a compile error there too.
347
+ - **A session token half may be up to 1024 hex characters, in either case.**
348
+ The bound that keeps a planted oversized cookie out of the store used to be
349
+ 256 lowercase characters, which `LambderPlainSessionCrypto` (it hex-encodes
350
+ its input rather than hashing it) crossed for a session key and salt over
351
+ 128 characters, and which a custom crypto minting uppercase or longer
352
+ halves crossed on every session: such sessions read as "no session" with no
353
+ warning. The store's partition-key limit is still comfortably clear.
354
+ - **Names that were one word, or named for something they are not, are
355
+ renamed; nothing is kept under the old name.** `LambderApiError` is
356
+ `LambderApiRefusal`, `isLambderApiError` is `isLambderApiRefusal`,
357
+ `LambderApiErrorOptions` is `LambderApiRefusalOptions`, and
358
+ `LambderApiValidationRefusal` with `isLambderApiValidationRefusal` follow: it is
359
+ the thing a guard or a handler throws to say no, and everything around it
360
+ was already called a refusal. `LambderDdbIdempotency` and
361
+ `LambderMemoryIdempotency` are `LambderDdbIdempotencyStore` and
362
+ `LambderMemoryIdempotencyStore` (and `LambderDdbIdempotencyOptions` is
363
+ `LambderDdbIdempotencyStoreOptions`), the noun their interface and their two
364
+ sibling families carry. `LambderMockFailureKind` is
365
+ `LambderMockFailureReason`, like every other `...FailureReason`.
366
+ `LambderApiResponse`, the contract's per-endpoint envelope, is
367
+ `LambderApiEnvelopeBody`, so it no longer reads as the API flavour of the
368
+ server's `LambderResponse`. `MergeContract` is `LambderMergeContract`,
369
+ `ApiContractShape` is `LambderApiContractShape`, `HttpStatusCode` is
370
+ `LambderHttpStatusCode` (a name half a dozen HTTP libraries use),
371
+ `ConditionFunction` is `LambderRouteConditionFn`, `RouteCondition` is
372
+ `LambderRouteCondition` and `PathParamsOf` is `LambderPathParamsOf`: the
373
+ last five were the public type names still carrying no prefix.
374
+ The deep-import paths move with them (`shared/LambderApiRefusal.ts`,
375
+ `api/LambderApiValidationRefusal.ts`, `stores/LambderDdbIdempotencyStore.ts`,
376
+ `stores/LambderMemoryIdempotencyStore.ts`), and `shared/node-polyfills.ts`,
377
+ which polyfills nothing and probes for four optional Node modules, is
378
+ `shared/LambderNodeModules.ts`.
379
+ - **`errorMessage` is typed on both sides**: `LambderAppRefusalMessage | string`
380
+ on `LambderApiRefusalOptions`, on the envelope, on the caller's failure
381
+ outcome and on `errorMessageHandler`, where it was `any` everywhere. A
382
+ handler that read `message.content` had no type to read it from; an app
383
+ with its own vocabulary puts it in `code` and the fields a refusal message
384
+ carries.
385
+
386
+ - **`engines.node` is `>=20`.** The package's own `lru-cache` dependency
387
+ declares `"20 || >=22"`, so the old `>=18` claim emitted `EBADENGINE` on
388
+ install and failed outright under `engine-strict`. Node 18 is out of support
389
+ and AWS has deprecated the `nodejs18.x` runtime; every current Lambda
390
+ Node.js runtime clears the floor.
391
+ - **An idempotent API demands its key at the call site.** When a contract
392
+ entry declares `idempotency`, the call's options argument is required and
393
+ carries `idempotencyKey: string`, built the way `guardInputs` already was
394
+ and read off the contract with `LambderContractIdempotencyOf`. A server
395
+ declaration that reads as protection used to provide none if the caller
396
+ forgot the key: the server runs a keyless call, which dedupes nothing, so a
397
+ double submit placed two orders. Both callers get it from the shared call
398
+ typing.
399
+ - **`LambderInvokeFailure` is a discriminated union, like
400
+ `LambderApiOutcome`.** `validation` always carries `zodError`, `crash`
401
+ always carries `functionError`, `payloadTooLarge` always carries `bytes`,
402
+ the envelope reasons always carry `response`, and the delivery reasons carry
403
+ it when the callee answered; `error`, `logList` and `cookies` are on a
404
+ shared base. Narrowing on `reason` narrows the fields, so a reader writes
405
+ no optional chains and no `!` for evidence the reason guarantees. The arms
406
+ are exported as `LambderInvokeValidationFailure`,
407
+ `LambderInvokeCrashFailure`, `LambderInvokePayloadTooLargeFailure`,
408
+ `LambderInvokeEnvelopeFailure` and `LambderInvokeDeliveryFailure`.
409
+ - **`createSession` refuses an empty `sessionKey`.** It names the subject the
410
+ session belongs to and partitions the store, and `lookupSession` rejects a
411
+ record without one, so an empty key wrote a record no read could ever
412
+ accept while handing the caller a valid-looking cookie pair: every request
413
+ after it read as a silent logout. Same class as the TTL refusal beside it.
414
+ - **`LambderSessionControllerOptions.tokenCookieKey` and `csrfCookieKey` are
415
+ required.** The defaults live once, in `shared/LambderSessionCookieNames.ts`,
416
+ and are applied where the app's session options are read; the controller
417
+ defaulted them a second time, which meant a controller built without them
418
+ read cookies the app does not write. Only code constructing
419
+ `LambderSessionController` directly is affected;
420
+ `lambder.getSessionController(ctx)` is unchanged.
421
+ - **`finalizeResponse` takes `Pick<ctx, "method" | "header">`** rather than
422
+ the raw `headers` map, so the two request headers it reads go through the
423
+ context's own case-insensitive lookup and the second copy of that lookup is
424
+ gone.
425
+ - **`LambderMockFailure` and `LambderMockTransportError` spell their
426
+ discriminant `reason`, not `kind`.** `mockApp.failNext("x", { reason:
427
+ "rateLimited" })`, `err.reason`. Every sibling in the package already used
428
+ the word; the rename of `LambderMockFailureKind` to
429
+ `LambderMockFailureReason` had stopped at the type's name. The string form
430
+ (`failNext("x", "network")`) is unchanged.
431
+ - **`LambderMockOverride` is `{ restore(): void }` alone.** The handle's
432
+ `[Symbol.dispose]` member, and with it `using stub = mockApp.override(...)`,
433
+ is gone: that member is declared only under `lib: ESNext`, so the published
434
+ `.d.ts` answered TS2550 for a consumer on `lib: ES2022` who did nothing but
435
+ import the entry with `skipLibCheck` off, and the Node fallback it came with
436
+ did not do what its comment said. A try/finally around `stub.restore()`
437
+ scopes an override in every project and needs no lib.
438
+ - **A mock entry may be a bare handler only where the contract declares
439
+ nothing for the endpoint.** The form was gated on guards alone, so for every
440
+ endpoint with no guards the mandatory `rateLimit` and `idempotency`
441
+ restatements were optional again: the handler ran twice for one key where
442
+ the server replays, and a `perMin` limit never answered 429. `publicApi` and
443
+ `sessionApi` take the options form wherever the contract declares guards, a
444
+ rate limit or idempotency.
445
+ - **`LambderMockApp.signOut(sessionKey, { jar?, host? })`** takes the jar it
446
+ should clear, symmetric with `signIn`.
447
+ - **The three per-API policy option shapes live in
448
+ `src/shared/LambderApiOptionValues.ts`**: `LambderGuardsOptionValue` (was
449
+ `api/LambderApiGuards.ts`), `LambderRateLimitOptionValue` and
450
+ `LambderRateLimitOverride` (were `api/LambderApiRateLimits.ts`) and
451
+ `LambderApiIdempotencyOption` (was `api/LambderApiDefinition.ts`). A
452
+ contract records the options an API declared and the engines read the same
453
+ shapes back, so the declarations belong below both; before this,
454
+ `shared/LambderApiContract.ts` imported three types out of `api/`, which was
455
+ a four-module cycle. Root entry names are unchanged; only the deep-import
456
+ path moved.
457
+ - **`LambderAnswerHeaders` moved from `api/` to `shared/`**
458
+ (`src/shared/LambderAnswerHeaders.ts`, with `getAnswerHeader`,
459
+ `setAnswerHeader` and `addAnswerHeader`). It is a pure header accumulator
460
+ with no API vocabulary in it, and `core/`, `mock/`, `session/` and `api/`
461
+ all build on it. Root entry names are unchanged.
462
+ - **`LambderCreatedHook` is declared beside the class in `core/Lambder.ts`**
463
+ rather than in `core/LambderCreateOptions.ts`. Its parameter is the
464
+ instance, and that back-edge made the options module unreadable without the
465
+ class and made `dist/core/LambderCreateOptions.d.ts` import the whole class
466
+ declaration for any app naming a single option type. The root entry exports
467
+ the name from `./core/Lambder.js`; the name is unchanged.
468
+ - **Four internal pass-through re-exports are gone, and every importer names
469
+ the module that declares the type.** `LambderSessionRecord` and
470
+ `LambderSessionStore` are read from `shared/LambderSessionStore.js` rather
471
+ than through `session/LambderSessionManager.js`; `LambderApiMode` from
472
+ `shared/LambderApiContract.js` rather than through
473
+ `api/LambderApiDefinition.js`; `LambderHttpStatusCode` from
474
+ `shared/LambderHttpStatus.js` rather than through `core/LambderResponse.js`;
475
+ and `LAMBDER_INVOKE_HEADER`, `LAMBDER_INVOKED_BY_HEADER` and
476
+ `LAMBDER_INVOKE_PROTOCOL` from their home module
477
+ `invoke/LambderLambdaEvent.js` rather than through
478
+ `invoke/LambderInvokeCaller.js`. `LambderInvokeSession` and the pure failure
479
+ readers (`classifyDeliveryFailure`, `errorFromFunctionError`,
480
+ `parseFunctionError`, `describeFailure`) moved out of the invoke caller too,
481
+ the first to `invoke/LambderLambdaEvent.js` beside the event it becomes a
482
+ cookie on, the rest to `invoke/LambderInvokeOutcome.js` beside the reasons
483
+ they describe. Entry names are unchanged throughout; a deep import of any
484
+ of these names must now name the declaring module.
485
+ - **`acceptsEncoding`, `LambderHeaderTarget` and the `LambderPublicFilesHandler`
486
+ class are no longer exported from the root entry.** The first is an
487
+ `Accept-Encoding` parser used once inside `finalizeResponse` (an adapter
488
+ that compresses reaches for `resolveCompressionOption` and `compressText`);
489
+ the second is the structural parameter type of `LambderAnswerHeaders.applyTo`,
490
+ which a caller passing a `LambderResponse` never names; the third is reached
491
+ through `servePublicFiles()`, the way its `LambderIndexHtmlHandler` sibling
492
+ always was (`LambderPublicFilesOptions` is still exported).
493
+
494
+ ### Security
495
+
496
+ - **An `ip`-keyed rate limit runs before the session is read.** A request
497
+ carrying bogus session cookies reached the session store first, which scans a
498
+ partition and reads up to four candidates, and was answered `sessionExpired`
499
+ without the limiter ever running: ten requests against a `perMin: 1` policy
500
+ cost forty store reads and zero 429s. Policies whose key is known from the
501
+ request alone are checked first; `per: "session"` and custom key handlers
502
+ stay after the read, since they may need the session. The replay lookup is
503
+ behind the same gate, so a retry does count against an `ip` budget: those
504
+ limits bound store traffic, not handler runs.
505
+ - **`ctx.ip` is the address the gateway observed unless the app names a header
506
+ it trusts**, through the new `trustedClientIpHeaders` option. It used to read
507
+ `cf-connecting-ip`, then the leftmost entry of `x-forwarded-for`, then the
508
+ gateway, whether or not anything in front of the app wrote either; API
509
+ Gateway appends to `x-forwarded-for` rather than replacing it, so the
510
+ leftmost entry is the client's own claim. Every `per: "ip"` rate limit was
511
+ therefore keyed on a value the caller chose, which is not a limit. An app
512
+ behind Cloudflare or a rewriting proxy should set
513
+ `trustedClientIpHeaders: ["cf-connecting-ip"]`. This one also appears under
514
+ Breaking, because it changes the key of every existing `per: "ip"` limit on
515
+ upgrade and nothing in an app's code has to change for it to happen. An
516
+ invoke is not exempt: the `x-lambder-invoke` marker is a signal for guards
517
+ and hooks and authorizes nothing, and a genuine invoke carries the caller's
518
+ address in `requestContext.http.sourceIp`, which is where `ctx.ip` reads it
519
+ from anyway.
520
+ - **`ctx.ip` carries one spelling per address.** A forwarding proxy may write
521
+ the RFC 7239 bracket-and-port form (`[2001:db8::1]:443`) or a plain
522
+ `host:port`, and IPv6 has several textual forms for one address. Each
523
+ variant reaching a rate-limit key untouched was its own counter, which is a
524
+ limit that does not limit, so the port and brackets are stripped, the result
525
+ is lowercased, and anything longer than the longest valid address is
526
+ truncated rather than trusted as a key.
527
+ - **The session scan asks about identity alone, and the CSRF pairing once
528
+ afterwards, which is what makes a planted cookie reachable.** Asking "how
529
+ many sessions is this browser holding" and "does the posted CSRF token
530
+ match" as one question always answered "one": a sibling subdomain plants its
531
+ own CSRF cookie beside the session it planted, only one CSRF token is ever
532
+ posted, and no two sessions share a `csrfTokenHash`, so exactly one candidate
533
+ survived the pairing and the ambiguity was invisible on every API call. The
534
+ scan now runs on identity alone and the pairing is asked once, afterwards, of
535
+ the single session the cookies resolved to.
536
+ - **A planted CSRF cookie no longer produces a logout loop that cannot heal.**
537
+ The CSRF cookie name is counted in the same pass as the session cookie. The
538
+ browser client reads its token with `Cookies.get`, which returns the first
539
+ copy in `document.cookie`, and a browser orders a longer `Path` first, so a
540
+ sibling host that plants one CSRF cookie at a parent domain decided which
541
+ token every call posted: the session cookie resolved, the pairing failed, the
542
+ answer was a plain `sessionExpired` that emits no `Set-Cookie`, and the
543
+ planted copy survived the logout and the next sign-in. When the session
544
+ cookie names a live session and the posted token pairs with nothing, more
545
+ than one CSRF cookie under the name, or a posted token matching none of them,
546
+ now takes the ambiguity refusal and clears every scope this host can write. A
547
+ planted cookie beside the real one while the posted token DOES pair still
548
+ resolves: the client picked the real token, so the other copy is inert.
549
+ - **More session cookies than the reader will weigh is a refusal, not a trim.**
550
+ The first few used to be read and the rest dropped unread, so anyone able to
551
+ plant cookies at a parent domain could push the visitor's own copy out of the
552
+ read with four of their own at a longer `Path`: nothing validated, no
553
+ eviction was emitted, and the logout never healed. Over the cap the request
554
+ is refused and every scope cleared, without reading the store at all.
555
+ - **An oversized planted session cookie no longer turns a live session into a
556
+ 500.** Each candidate is checked against the minted token format (two hex
557
+ halves joined by a colon, neither over 1024 characters) before
558
+ any store read, so a malformed candidate is "no session" rather than a read
559
+ error. A cookie may carry 4000 characters and DynamoDB refuses a partition
560
+ key over 2048, so the unchecked candidate reached the store as a key it
561
+ cannot take and the victim's own live session answered 500 on every request.
562
+ - **A candidate session is not renewed until it is known to be the caller's.**
563
+ The read and the structural checks are `lookupSession`, the `dataRefresh`
564
+ callback and the sliding-expiration write are `renewSession`, and the
565
+ controller's scan uses the first. Weighing several cookies used to slide the
566
+ expiry of every one of them and run the app's `dataRefresh` for each, so a
567
+ planted cookie was kept alive indefinitely by the victim's own traffic.
568
+ - **Two session cookies that both validate are refused, not resolved, and
569
+ every scope this host can write is cleared.** One live session under a name
570
+ is ordinary; two are not, since any sibling subdomain can plant a cookie at
571
+ a parent domain that arrives beside the real one, with its own CSRF cookie
572
+ so the pairing check does not catch it. Picking one signed the visitor into
573
+ whichever arrived first. Clearing only the configured scope would have been
574
+ worse than picking: a deletion matches only a cookie carrying the same
575
+ Domain, so it would evict the visitor's own copy and leave the planted one
576
+ as the sole survivor. The refusal clears the host-only scope, the configured
577
+ one, and every parent domain of the request host.
578
+ - **A session cookie no longer keeps its creation expiry while the record
579
+ slides.** When a sliding write moves the expiry, the response re-issues both
580
+ cookies at the new `Expires`, so a continuously active visitor is no longer
581
+ signed out at `createdAt + ttl`, the one deadline sliding expiration exists
582
+ to push back. The CSRF cookie is re-issued only with a value known to pair
583
+ with the session.
584
+ - **A record a store hands back under keys other than the ones it was asked
585
+ for is no session.** `lookupSession` compares the record's two hashes against
586
+ the query in constant time, which costs no hashing and is what a store that
587
+ keys loosely runs into instead of handing back somebody else's session.
588
+ - **A `__Host-` or `__Secure-` session cookie name is checked against its own
589
+ preconditions at creation.** A browser silently discards a `__Host-` cookie
590
+ that carries a `Domain`, is not at `Path=/`, or is not `Secure`, and a
591
+ `__Secure-` cookie that is not `Secure`, which would look like an app with no
592
+ sessions at all, so the combination throws instead. The check is
593
+ `assertSessionCookiePrefixes` in the session layer, which is where the cookie
594
+ names live, rather than in the API pipeline. The prefix is the structural
595
+ answer to a sibling subdomain planting a session cookie; see docs/sessions.md.
596
+ - **Non-cryptographic session crypto cannot sit in front of a persistent
597
+ store.** `LambderPlainSessionCrypto` neither hashes nor draws random bytes,
598
+ so over a store that outlives the process every record would be a usable
599
+ credential and the `sessionSalt` would be readable out of the partition key.
600
+ The manager now refuses the pair outright.
601
+ - **A cookie named for an `Object.prototype` member no longer answers 500 on
602
+ every request.** The request's cookie map was a plain object, so a cookie
603
+ named `__proto__`, `constructor` or `toString` resolved to the prototype and
604
+ the push that followed threw before any handler ran; a sibling subdomain
605
+ could plant such a cookie at a parent domain for good, since nothing on the
606
+ 500 path clears cookies. Every adapter now builds the map through one
607
+ `cookieValuesByName` on a prototype-free object, and such a name reads as a
608
+ cookie like any other.
609
+ - **A guard's input is read as data.** `guardInputs` is client-supplied and was
610
+ read with a plain property access, so a guard named for something
611
+ `Object.prototype` carries (`toString`, `constructor`) received the
612
+ inherited function instead of the absent value the client did not send, and
613
+ a check for "no token" never fired. Guard and rate-limit policy names are
614
+ looked up in Maps for the same reason.
615
+ - **`guardInputs` must be an object, not an array.** An array is an object and
616
+ answers for its own properties, so a guard named `length` received a number
617
+ where the client had sent it nothing: the same reading the
618
+ `Object.prototype` fix above is about, one shape over.
619
+ - **A guard cannot deny by returning a response**, at build time through the
620
+ builder and at runtime through the engine. The returned value used to become
621
+ `ctx.guardData[name]` and the call carried on. A guard that answers on one
622
+ branch is rejected like one that always answers: the check used to distribute
623
+ over the return union, so `LambderResponse | undefined`, which is the
624
+ ordinary spelling of "return a response to deny", picked the harmless branch
625
+ and passed the builder.
626
+ - **A guard built for one adapter no longer compiles into the other's guards
627
+ map.** `LambderApiGuard` types its handler's context, the server's map is
628
+ pinned to the render contexts and the mock's to the mock call contexts. A
629
+ server guard in a mock map read `ctx.ip` as undefined and then authorized or
630
+ refused everything.
631
+ - **A validation refusal's body is bounded by bytes, not by issue count.** A
632
+ public API validates before any guard, so an unauthenticated caller chose the
633
+ size of this body: one `unrecognized_keys` issue carries every key the client
634
+ posted and zod's own `message` carries the tree a second time, so a
635
+ `strictObject` answered a 1MB request with 4MB, and past `maxResponseBytes`
636
+ the 422 became a 500. The serialized issue list is capped at about 32KB,
637
+ oversized values inside one issue are shortened, `issueCount` says the answer
638
+ was trimmed, and the generated summary always replaces zod's message.
639
+ - **A public API's idempotency scope can carry who the caller is**, through
640
+ the new `callerIdentity` on the idempotency option. A public API's scope was
641
+ the posted key alone, which makes the key a bearer token for its own stored
642
+ answer: the replay is served before guards run, so an API whose
643
+ authorization IS a guard handed its response to anyone presenting a known
644
+ key, with the guard never consulted. `callerIdentity` runs before guards and
645
+ sees only what the request carries, and is the app's to write because the
646
+ credential to read is the app's to know: a single-use guard input such as a
647
+ captcha would give the legitimate retry a different scope and defeat the
648
+ replay it needs.
649
+ - **The idempotency scope escapes its fields.** It joined API name and key
650
+ with a pipe and escaped neither, where the rate limiter escaped both;
651
+ `shared/LambderKeyFields.ts` now holds the one implementation both use.
652
+ - **A rate-limit tracker key escapes the field separator**, so two callers
653
+ whose keys differ only around a pipe cannot land on one counter.
654
+ - **An over-long idempotency scope key is refused with a clear error** rather
655
+ than reaching DynamoDB as a `ValidationException`, which an engine set to
656
+ fail open swallowed into no idempotency at all. `LambderDdbIdempotencyStore.begin()`
657
+ and every other method throws when the scope key plus its prefix would exceed
658
+ DynamoDB's 2048-byte partition key limit, and the message carries byte
659
+ counts, never the key.
660
+ - **The in-memory idempotency store no longer drops a pending claim to make
661
+ room.** At its ceiling the old eviction took whatever expired soonest, and a
662
+ claim's minutes always lost to a settled record's day, so two concurrent
663
+ retries could both be granted the scope and both execute. Claims are now held
664
+ back from eviction, settled records are spent instead, and a store with
665
+ nothing but live claims reports the duplicate as pending rather than running
666
+ it.
667
+ - **A failing rate limiter or idempotency store is logged.** Both engines
668
+ default to failing open, which is right and was indistinguishable from
669
+ working: the failure class includes a missing table and a missing IAM action,
670
+ so an app could run unmetered, or execute every retry twice, with nothing in
671
+ its logs. The log names the policy and its windows, or the API, and never the
672
+ tracker or scope key, which carry the caller's identity.
673
+ - **`serveIndexHtml`'s `redirectTrailingSlash` can no longer be talked into an
674
+ off-origin `Location`.** A leading run of slashes and backslashes collapses
675
+ to one slash, and a target that would still be protocol-relative falls
676
+ through to the route fallback, so `GET //evil.example/` no longer answers
677
+ `301 Location: //evil.example`.
678
+ - **A 304 carries the call's headers**: `Set-Cookie`, anything written with
679
+ `res.setHeader`, and the CORS headers, dropping only the three that describe
680
+ a body. A cacheable GET that also slides a session cookie keeps refreshing it
681
+ on revalidation, and a cross-origin revalidation is readable again.
682
+ - **The cookie-jar transport over fetch sends the jar's cookies as a `Cookie`
683
+ header**, so a session carried by a jar actually reaches the server outside a
684
+ browser. It used to collect every `Set-Cookie` and send none of them back,
685
+ leaving every session call answering `sessionExpired`.
686
+ - **`LambderCookieJar.cookiePairs()` returns RFC 6265 send order**, longest
687
+ `Path` first and then oldest first, so a server reading the first of a
688
+ repeated cookie name gets what a browser would have sent.
689
+ - **`LambderCookieJar`'s matching rules are tough-cookie's**, which is the
690
+ reference RFC 6265 implementation and carries the public suffix list. The
691
+ jar keeps its own shape (Set-Cookie header lists in, `name=value` pairs out,
692
+ a target given as a host and path rather than a URL, since a transport that
693
+ never speaks HTTP has no URL to give) and hands the rules to the library:
694
+ domain and path matching, default-path, Max-Age against Expires, Secure,
695
+ HttpOnly, and the `__Host-`/`__Secure-` prefixes. Three behaviours changed
696
+ with it: `Domain=co.uk` and every other multi-label public suffix is now
697
+ refused, which is the whole reason to carry the list and cannot be done by
698
+ counting labels; a `Domain` on an IP-literal host is refused outright rather
699
+ than kept host-only, and the same cookie without a `Domain` still works; and
700
+ `Domain=localhost` is accepted, because localhost is a special-use name
701
+ rather than a registry suffix and a local development setup runs on it.
702
+ `tough-cookie` is a dependency rather than a peer: its only dependency is
703
+ `tldts`, it imports no Node built-in, and a bundle that never imports the
704
+ jar never pulls it in.
705
+ - **`LambderCookieJar` refuses a `Domain` it cannot verify.** It kept whatever
706
+ a `Set-Cookie` claimed, so a jar talking to more than one host would accept
707
+ `Domain=example.com` from a sibling and send it to every host under the
708
+ parent, and `Domain=com` to every `.com` host. The sending host must now be
709
+ the domain or under it, and a one-label domain is refused outright. A jar
710
+ with no host refuses every `Domain` cookie rather than trusting it; give it
711
+ one with `new LambderCookieJar({ host })` where that matters.
712
+ - **A request header named `__proto__` reads as a header.** The lowercased
713
+ header map was a plain object, so the name reached Object.prototype's
714
+ setter and vanished, and `ctx.header("__proto__")` answered the prototype
715
+ itself rather than `undefined`. The map is prototype-free, like the cookie
716
+ map beside it.
717
+ - **The MSW adapter reads the document's cookies pair by pair**, so a name
718
+ the page holds at two scopes keeps both values and the session controller
719
+ can weigh them under MSW the way it does on the server; a whole-header
720
+ parse kept only the first.
721
+
722
+ - **The file reader's path rule refuses every way out of a source's root,
723
+ not just `..`.** It used to strip exactly ONE leading slash, so a request
724
+ for `//x` reached a source as `/x` and `///attacker.example/evil.html` as
725
+ `//attacker.example/evil.html`. `LambderHttpFileSource` resolves what it is
726
+ given as a URL reference against `baseUrl`, so the first form left the
727
+ configured folder and the second was protocol-relative and named a host of
728
+ the caller's choosing: one unauthenticated GET fetched an attacker-named
729
+ origin with the app's configured `headers` (an Authorization token for a
730
+ private origin, say), then served the attacker's `text/html` body from the
731
+ app's own domain, under the app's cookies, and cached it. The rule now
732
+ strips every leading slash and refuses an empty result, a trailing slash,
733
+ and any segment that is empty, `.`, `..`, or contains a backslash, and
734
+ `LambderHttpFileSource` re-checks the URL it built: anything whose `href`
735
+ does not start with the base reads as null. The local source has always had
736
+ the equivalent belt against its root; the remote one was the only layer
737
+ trusting the reader's contract rather than re-checking it.
738
+ - **A surplus key inside `cors` or `compression` is a compile error.**
739
+ `create()` is generic over `const TOptions`, which switches excess-property
740
+ checking off for the whole literal, so `cors: { credentials: true, origns:
741
+ [...] }` compiled, left the config with no allowlist at all, and
742
+ `applyCorsHeaders` read that as `"*"`, which with credentials on echoes
743
+ whatever Origin asked: an app that wrote an allowlist ran with none and
744
+ nothing said so. Both options join `session`, `session.cookie`,
745
+ `idempotency`, `rateLimits`, its `policies`, `guards` and the object form of
746
+ `files` under the nested surplus-key rule.
747
+ - **A rate-limit tracker key is bounded before any limiter sees it.** The
748
+ variable half of the key (a custom key handler's return, a `per: "session"`
749
+ session key) is replaced by its own SHA-256 past 1024 UTF-8 bytes, as
750
+ `custom:h:<hex>` or `session:h:<hex>`, with the api and policy names still
751
+ readable around it. A store refuses a key it cannot take by throwing, and a
752
+ throw from a limiter is exactly what `rateLimits.failOpen` swallows, so a
753
+ custom key derived from a payload field (the documented shape, an email
754
+ address) that a caller posts 3,000 characters long used to fail every
755
+ window of every policy the same way and let the request through unmetered,
756
+ with the limit silently off. Folding keeps distinct callers on distinct
757
+ counters and leaves a key that fits untouched. `LambderDdbRateLimiter`
758
+ refuses, on its own, a partition key DynamoDB will not take, before it
759
+ counts anything, with the byte count and never the key, the check its
760
+ idempotency sibling already made and both now share; a key that reaches
761
+ that refusal came from a direct caller.
762
+ - **`idempotency.callerIdentity` can no longer be written against a session
763
+ that is never there.** It is consulted only on public APIs, and a public
764
+ call reads no session, so `ctx.session` was `null` on every call: the
765
+ documented spelling `(ctx) => ctx.session?.data?.userId ?? null` compiled,
766
+ ran, returned null every time, and left every public replay key a bearer
767
+ token for its own stored answer, which is the hole `callerIdentity` exists
768
+ to close. Its context parameter is now `Omit<LambderApiCallContext,
769
+ "session">` on the server and the mock alike, so the mistake is a compile
770
+ error, and the option's documentation names what the request actually
771
+ carries: `guardInputs`, `payload`, `headers` and `ip`.
772
+ - **A synthesized invoke event owns its forwarded address and its invoke
773
+ markers.** `synthesizeLambdaHttpEvent` no longer writes `x-forwarded-for`
774
+ at all (the address the call asserts travels in
775
+ `requestContext.http.sourceIp`, which is what `resolveClientIp` reads), and
776
+ it deletes any caller-supplied `x-forwarded-for`, `x-lambder-invoke` and
777
+ `x-lambder-invoked-by` before writing the ones it owns. A gateway lambda
778
+ that forwards an incoming browser request's headers into a call's
779
+ `headers`, an ordinary pattern, used to hand a callee configured with
780
+ `trustedClientIpHeaders: ["x-forwarded-for"]` an end-user-chosen `ctx.ip`:
781
+ a `per: "ip"` rate limit could then be evaded per request, and any guard or
782
+ audit row keyed on `ctx.ip` recorded a fiction. The same ownership rule
783
+ stops a browser-shaped in-process event from claiming to be an invoke.
784
+ - **`LambderCaller.createIdempotencyKey()` has no guessable path left.** The
785
+ `Math.random` fallback is gone: it is `crypto.randomUUID`, then
786
+ `crypto.getRandomValues`, and a runtime with neither throws, saying why.
787
+ The key scopes the replay record for a logged-out client, so a guessable
788
+ one hands that client's stored response to whoever guesses it, and
789
+ `getRandomValues` is not secure-context gated, so the branch was only
790
+ reachable on a runtime with no `crypto` at all.
791
+ - **A stored idempotency body's declared length is bounded.** `bodyBytes` is
792
+ the budget the restore decompresses under, so a record claiming petabytes
793
+ let a few hundred kilobytes of Brotli expand until the function died.
794
+ Anything past 32MB is a record this store did not write and is refused, the
795
+ way the cache already bounds a manifest against `maxValueBytes`.
796
+
797
+ ### Added
798
+
799
+ - **The API core** (`src/api/`): `LambderApiRequest`, `LambderApiAnswer`,
800
+ `LambderApiEnvelope` (the one place the envelope is written and every
801
+ refusal rendered), `LambderApiValidationRefusal`, `LambderApiCallContext`,
802
+ `LambderApiDefinition` and `LambderApiPipeline`, the pipeline both the
803
+ Lambda server and the mock runtime run. `Lambder.ts` is now an adapter over
804
+ it. `pipeline.prepare()` is the protocol's pre-pass (the version gate and
805
+ the compressed-payload restore) as one named step, so the server can run it
806
+ before its hooks and route matching without the order living in two places.
807
+ See docs/api-core.md.
808
+ - **The root entry exports the core's building blocks by name**, so an app can
809
+ write its own adapter over the pipeline instead of reaching into `dist/`:
810
+ the envelope functions (`buildApiEnvelope`, `envelopeAnswer`,
811
+ `refusalAnswer`, `validationAnswer`, `apiNotFoundAnswer`,
812
+ `sessionExpiredAnswer`, `versionExpiredAnswer`, `invalidPayloadAnswer`,
813
+ `crashAnswer`), `readApiEnvelope` and `restoreCompressedPayload`,
814
+ `createApiCallContext`, the answer-header helpers (`LambderAnswerHeaders`,
815
+ `getAnswerHeader`, `setAnswerHeader`, `addAnswerHeader`, `toHttpAnswer`),
816
+ and `LambderApiPipeline` with its option, request, answer and result types.
817
+ They are part of the public surface and bound by semver like every other
818
+ export of an entry point.
819
+ - **`pipeline.run(request, ctx, definition, exec, trace?)` writes into a trace
820
+ the adapter owns**, so a handler that throws still leaves `guardsRun` and
821
+ `replayed` behind for the adapter's catch.
822
+ - **`LambderAppRefusalMessage`**, the refusal message type an app's own options
823
+ take, exported from the root and the client entry.
824
+ - **Store interfaces and memory stores**: `LambderRateLimiter`,
825
+ `LambderIdempotencyStore` and `LambderSessionStore`, with
826
+ `LambderMemoryRateLimiter`, `LambderMemoryIdempotencyStore` and
827
+ `LambderMemorySessionStore`. The whole policy and session layer is testable
828
+ in-process with no AWS SDK; the session tests moved off the SDK mock. All
829
+ three sit on `LambderExpiringMap`, which expires an entry on read and on an
830
+ amortized sweep and holds a ceiling so a key space that never repeats cannot
831
+ grow the process without bound.
832
+ - **`maxEntries` on all three memory stores**, `LambderMemorySessionStore`,
833
+ `LambderMemoryIdempotencyStore` and `LambderMemoryRateLimiter`, so the ceiling is
834
+ reachable from the deployment instead of being the map's hardcoded default.
835
+ Reaching it evicts the entry that expires soonest, which for a session is a
836
+ logout for whoever held it, and each store's docs say what it costs.
837
+ - **`now` on `LambderDdbIdempotencyStore` and `LambderDdbRateLimiter`**, the
838
+ injectable clock the memory stores already had, so the conformance suite
839
+ drives both implementations through one clock.
840
+ - **`LambderExpiringMap.set(key, value, expiresAt, { evictable })` and the
841
+ exported `LambderExpiringMapFullError`**: an entry can be kept out of the
842
+ ceiling eviction, and a write that cannot be made room for is refused rather
843
+ than paid for by somebody else's entry.
844
+ - **Isomorphic sessions**: `LambderSessionManager` and
845
+ `LambderSessionController` work on the call context, hash through
846
+ `LambderSessionCrypto` (WebCrypto by default, `LambderPlainSessionCrypto`
847
+ where a runtime has none and the store dies with the process), and run in a
848
+ browser. The controller gained `issueSession()`, `createSession` handing the
849
+ raw tokens back, and `reissueSession()`, the same for `regenerateSession`: a
850
+ client that holds its CSRF token rather than reading `document.cookie` (a
851
+ native app, an invoke caller) needs the new one after a rotation.
852
+ - **`LambderSessionNotFoundError` and `LambderSessionAmbiguousError`**, the two
853
+ "no session" exits of a session read. `fetchSessionIfExists()` answers null
854
+ for these and for nothing else, so a `TypeError` from a custom store or a bug
855
+ in the session layer is a crash rather than a silent logout: the old
856
+ catch-all turned any defect into `sessionExpired`, which makes the client
857
+ clear its cookies.
858
+ - **Transports**: `LambderApiTransport` as the browser caller's `transport`
859
+ option and `setTransport()`; `lambderFetchTransport` (the default),
860
+ `lambderHandlerTransport` (a real Lambder handler in-process through a
861
+ browser-shaped event), `lambderCookieJarTransport` and `LambderCookieJar`
862
+ (a browser's cookie storage for transports that have no browser), and
863
+ `LambderTransportFailure` with `isLambderTransportFailure`, so a transport
864
+ says why it could not deliver instead of leaving the caller to assume the
865
+ network. The caller reads the site host from `globalThis.location` and runs
866
+ in Node.
867
+ - **`LambderCaller` takes a `logListHandler`**, the browser twin of
868
+ `LambderInvokeCaller`'s `onLogList`, overridable per call; the default still
869
+ prints each entry with `console.log`.
870
+ - **Every `LambderInvokeOutcome`, success or failure, carries `cookies`**, the
871
+ answer's `Set-Cookie` values, so a session the callee rotated or cleared is
872
+ visible to the caller carrying it.
873
+ - **`DEFAULT_SESSION_TOKEN_COOKIE_KEY` and `DEFAULT_SESSION_CSRF_COOKIE_KEY`
874
+ are exported from `lambder/client` as well**, from their one definition in
875
+ `shared/LambderSessionCookieNames.ts`, so a browser reading a cookie name
876
+ names the same constant the server writes.
877
+ - **The contract carries `mode`, `rateLimit` and `idempotency`** beside
878
+ `guards`, and `lambder/client` exports helpers that read a contract type:
879
+ `LambderContractMode`, `LambderContractKeysWithMode`,
880
+ `LambderContractGuardNames`, `LambderContractGuardInput` and the rest.
881
+ - **`lambder/mock` and `LambderMockApp`**: `initLambderMock<Contract,
882
+ SessionData>()`, name-first entry builders (`publicApi`, `sessionApi`,
883
+ `notMocked`), `apiSlice` and an exhaustive `register` checked by the
884
+ compiler for completeness, strays, modes, guards and overlap,
885
+ `registerPartial` and restorable `override`s, mock guards through the same
886
+ engine, sessions carried by cookie jars, failure injection, latency,
887
+ `reset`, a keyed subscription and a call log, `lambderMockConsoleLogger`,
888
+ `lambderMockMswHandler` (one MSW handler over the runtime, with
889
+ `onUnmocked: "passthrough"` for a partially mocked app) and
890
+ `lambderMockInvokeTransport` (the mock as a callee of
891
+ `LambderInvokeCaller`). A mock entry may carry its own `input` schema, so
892
+ the 422 path can be exercised in development; it is deliberately the mock's
893
+ own, because the contract is a type and the server's schemas do not exist on
894
+ that side. The mock context carries an `envelope` handle for the `message` a
895
+ server handler would pass to `res.api`, and `revealHandlerErrors` (default
896
+ true) answers a thrown handler with the message it threw. See docs/mock.md.
897
+ - **`mockApp.restNotMocked(reason)`**: one entry, passed to the same
898
+ `register()` call as the slices, standing for every endpoint they leave out.
899
+ `register()` stays exhaustive by construction and strays and duplicates in
900
+ the slices are refused exactly as before, so a contract the mocks only partly
901
+ cover can be adopted in one line: a call to an endpoint nothing registered
902
+ answers the `lambder/not-mocked` refusal carrying the reason rather than
903
+ `apiNotFound`, and is logged with the outcome `notMocked`. Its one limit is
904
+ the session read, which it cannot run: the answer is processed as a public
905
+ endpoint, because the mode of a name nothing registered is not knowable at
906
+ runtime, so a signed-out call to an unmocked session endpoint says "not
907
+ mocked" where the server says `sessionExpired`. Declare such an endpoint with
908
+ `sessionNotMocked` and leave the rest to the rest entry. It is the
909
+ alternative to the MSW adapter's `onUnmocked: "passthrough"`, and wins over
910
+ it: every name has an entry, so nothing is passed to the network.
911
+ - **`cookieHost` on the mock app**: one host for every cookie the runtime
912
+ holds, defaulting to the page's own host. `signIn` plants cookies there and
913
+ the transport's jar sends them there.
914
+ - **`lambder/mock` exports `LambderMockOverride`,
915
+ `LambderMockIdempotencyOptions`, `LambderMockInvokeEvent`,
916
+ `LambderMockInvokeResult`, `LambderCreatedSession` and
917
+ `LambderSessionManager`**, all of them return or option types the entry
918
+ already handed out.
919
+ - **`requireSessionApiGuards` and `requirePublicApiGuards` keep their
920
+ compile-time half when the options are spread from a separately typed object
921
+ or built in a helper**, instead of silently reading as off.
922
+ - **The import-graph gate covers `lambder/mock` as well as `lambder/client`**:
923
+ the mock entry may reach only the API core, the session layer, `shared/` and
924
+ the memory stores, and neither entry may reach `aws-lambda` or the AWS SDK,
925
+ at value or type level.
926
+ - `LAMBDER_REFUSAL_CODES.notMocked`, and `rateLimitRefusal()` for the 429 the
927
+ engine and the mock's failure injection both throw.
928
+ - `lambder.getResponseBuilder(ctx?)` is documented as what it returns, a
929
+ `LambderResponseBuilder` with no `res.die.*`.
930
+ - **Test suites that pin what nothing else did**: an adapter conformance suite
931
+ drives one declaration through the server and the mock and asserts identical
932
+ answers across the protocol matrix; a conformance suite per store interface
933
+ (tests/store-conformance) drives every implementation of all three through
934
+ one set of rules, every rate-limit window included; the wire format and the
935
+ session hash construction are frozen as fixtures (tests/wire-format), which
936
+ nothing else protected, since every other test computes its expectations
937
+ with the code it is testing; and tests/package-exports imports through the
938
+ real `exports` map, which nothing did.
939
+ - **The handler and hook types are exported from the root entry under
940
+ descriptive names**: `LambderRoutePath`, `LambderRouteHandler`,
941
+ `LambderSessionRouteHandler`, `LambderActionHandler`, `LambderActionFilter`,
942
+ `LambderHookEvent`, `LambderCreatedHook`, `LambderBeforeRenderHook`,
943
+ `LambderAfterRenderHook`, `LambderFallbackHook`, `LambderGlobalErrorHandler`,
944
+ `LambderFallbackHandler` and `LambderInputValidationHandler`. They appeared
945
+ in the public method signatures as unexported locals (`ActionFunction`,
946
+ `Path`, `HookBeforeRenderFunction`), so a hook written outside its
947
+ registration call had no type to be declared with.
948
+ - **The root entry exports, by name, the building blocks an adapter or a
949
+ test reaches for**: `answerFromResponse` and `responseFromAnswer` (a
950
+ `LambderResponse` to and from the core's answer), `API_ANSWER_CONTENT_TYPE`,
951
+ `DEFAULT_RATE_LIMIT_REFUSAL`, `buildTransportEnvelope`,
952
+ `synthesizeLambdaHttpEvent`, `decodeLambdaHttpResult`, `localLambdaContext`,
953
+ `parseSetCookie`, `isWebCryptoAvailable`, `isLambderApiValidationRefusal` and
954
+ the `LambderWebCrypto` class beside `LambderPlainSessionCrypto`.
955
+ - **`create()` checks the object form of `files` for surplus keys**
956
+ (`{ source, memoryCach: false }` is an error at the key), and the mock's
957
+ `create()` checks each guard in its `guards` map the same way, so
958
+ `sesion: true` on an inline mock guard no longer registers a public guard in
959
+ silence.
960
+
961
+ - **`servePublicFiles` takes the same `methods` option as `serveIndexHtml`,
962
+ default `["GET", "HEAD"]`.** A write method against an asset path used to be
963
+ served the file and never reached `setRouteFallbackHandler`. Both slots and
964
+ a route matcher's `method` share one rule, which also folds `HEAD` into
965
+ `GET` unless the list names `HEAD` itself, so narrowing a slot to `["GET"]`
966
+ no longer 404s every `HEAD`.
967
+ - **`LambderDdbCache` takes a `now`**, the clock entries are expired against,
968
+ like the rate limiter and the idempotency store, so the four DynamoDB stores
969
+ say the same thing about their clock.
970
+ - **`LambderLogListHandler`**, the `logListHandler` option's type, is exported
971
+ from the root and the client entry beside `LambderCallOptions` and
972
+ `LambderCallerOptions`, the way its invoke twin `LambderInvokeLogListHandler`
973
+ already was.
974
+ - **`rateLimits.failOpen` on `mock.create`**, the server's own option: a
975
+ limiter of the app's own that throws refuses the call instead of letting it
976
+ through. The idempotency option already carried its twin.
977
+ - **`LambderMockMswTarget.cookieHost`**, which the MSW adapter scopes its jar
978
+ by, the way it already read `defaultClientIp` from the app.
979
+ - **`package.json` exports `./package.json`.** With an exports map and no such
980
+ entry, `require.resolve("lambder/package.json")` and
981
+ `import pkg from "lambder/package.json"` were blocked, which some build
982
+ tooling and version probes do.
983
+
984
+ ### Changed
985
+
986
+ - **The directory graph is a DAG, type edges included.** Directories are
987
+ layers and imports only ever point down: `shared/` at the bottom, then
988
+ `stores/`, `session/`, `api/`, `client/`, then the three adapters `core/`,
989
+ `mock/` and `invoke/`, then the entries. A type-only import obeys the same
990
+ rule as a value import. The rule is written down in `docs/api-core.md`,
991
+ and `tests/package-exports.test.ts` enforces the
992
+ direction over the whole tree, one allow list per directory (`invoke/` may
993
+ name a `core/` type; `mock/` may import only the memory stores). The module
994
+ graph has zero cycles at value level and zero with type-only edges counted.
995
+ - **The session layer no longer names an API type.**
996
+ `LambderSessionController` reads exactly two fields of a call, so it is
997
+ typed over `LambderSessionCallSurface<SessionData>` (`{ session,
998
+ responseHeaders }`), declared in its own module, instead of importing
999
+ `LambderApiCallContext` from `api/`. The pipeline and both adapters keep
1000
+ passing their full context; `session/` depends on nothing above `shared/`.
1001
+ - **The pipeline's `guards` option pins the session context too.**
1002
+ `LambderApiPipelineOptions.guards` binds a guard's session context to the
1003
+ pipeline's own context with the session narrowed to a record, which is what
1004
+ both shipped adapters declare, so the last `any` in the guard binding is
1005
+ gone and a third adapter gets the binding this release advertises.
1006
+ - **`LambderApiPipeline.policies` is private**, and the mock's `pipeline`
1007
+ field is too; the mock's phantom `P` type parameter is off `LambderMockApp`
1008
+ (it stays on `create`, where it infers the policy names). Nothing read any
1009
+ of them.
1010
+ - **The minted-token format check moved into `LambderSessionManager`**
1011
+ (`isMintedSessionToken`, exported, with the 1024-character ceiling beside
1012
+ it). The manager mints and splits `sessionKeyHash:secret`, so the question
1013
+ "is this string that format" belongs beside the format rather than in the
1014
+ cookie reader; the controller imports it and still asks it of every
1015
+ candidate cookie before any store read.
1016
+ - **SHA-256 over text exists once**, in `shared/LambderTextDigest.ts`
1017
+ (`sha256HexOf`, `resolveWebCrypto`, `bytesToHexString`): `LambderWebCrypto`
1018
+ hashes through it and the rate-limit engine folds long keys with it. The
1019
+ session layer keeps its own unavailability message, the one that names
1020
+ `LambderPlainSessionCrypto` as the way out.
1021
+ - **The request envelope is built from one field set.** `buildEnvelopeFields`
1022
+ in `shared/LambderApiTransport.ts` states the fields a call sends and in
1023
+ what order; `buildTransportEnvelope` hands it the payload as a value, and
1024
+ the invoke caller's `buildEnvelopeJson` splices its already-serialized
1025
+ payload onto the end of it. The two used to list the fields separately, so
1026
+ a new envelope field could be added to one sender and missed by the other
1027
+ with nothing on the wire to catch it. Likewise the per-call compression
1028
+ threshold is decided once, by `resolveRequestCompressionMinBytes` beside
1029
+ the two compressors, where both callers had written out the same override
1030
+ rule.
1031
+ - **`lambderHandlerTransport` takes a `LambderHandler`** rather than a
1032
+ hand-written signature, and every in-process caller defaults its client
1033
+ address to the shared `LOOPBACK_CLIENT_IP`; the mock normalizes the address
1034
+ once, in `requestFromTransport`, so a `per: "ip"` limit keys one address as
1035
+ one counter whichever of its three paths a call arrived on.
1036
+ - **One base64 spelling.** `core/LambderResponse.ts` and
1037
+ `invoke/LambderLambdaEvent.ts` encode through `bytesToBase64` rather than
1038
+ their own `Buffer.toString("base64")`.
1039
+ - **The mock's option types live in `src/mock/LambderMockCreateOptions.ts`**
1040
+ (`LambderMockAppOptions`, `LambderMockSessionsOptions`,
1041
+ `LambderMockIdempotencyOptions`, `LambderMockTransport`,
1042
+ `LambderMockTransportOptions` and the guards option shapes), beside the
1043
+ rules that decide which keys `create()` accepts, the way
1044
+ `core/LambderCreateOptions.ts` holds the server's. No name changed, and
1045
+ `lambder/mock` exports them from the same entry as before.
1046
+ - **Twenty-seven declarations lost an `export` keyword they had no importer
1047
+ for**, so they no longer land in `dist/*.d.ts` as public noise (among them
1048
+ the guard-check helper types in `api/LambderApiGuards.ts`, the option-shape
1049
+ helpers in `core/LambderCreateOptions.ts`, the abort types in
1050
+ `shared/LambderCallAbort.ts`, the SDK loader types in
1051
+ `stores/LambderDdbSdk.ts`, `escapeKeyField`, `compressPayloadWith`,
1052
+ `normalizeHeaders` and `isCompressibleContentType`). `LambderCookieJar`
1053
+ carries a class doc comment, so `dist/shared/LambderCookieJar.d.ts` has
1054
+ one.
1055
+ - **Dead code and dead defaults are gone.** `LambderCaller`'s constructor no
1056
+ longer applies `apiPath ?? "/api"` or `isCorsEnabled = false` to options
1057
+ its own type requires; the unreachable `try/catch` around request body
1058
+ decoding in `createContext` is gone (`base64ToText` cannot throw, so it
1059
+ caught nothing reachable and would have hidden a real failure); the
1060
+ private `getRequestHeader` in `core/LambderResponse.ts` is gone with the
1061
+ `finalizeResponse` signature change; `LambderInvokeCaller.fail()` is
1062
+ `failureOutcome()`, `deliver()` is `deliverEvent()`, and
1063
+ `LambderCookieJar.matching()` is `matchingCookies()`, all private.
1064
+ - **`@types/cookie` and `@types/path-to-regexp` are gone from the dev
1065
+ dependencies** (both packages ship their own declarations) and `msw` joined
1066
+ them, so the MSW adapter's types are compiled against the real package.
1067
+ - **The gates grew.** `tests/package-exports.test.ts` reads Node's own
1068
+ `builtinModules` instead of a five-name list, derives the `browser` field
1069
+ from the import graph (every built-in a bundler would be asked to resolve
1070
+ must be mapped, and a mapping with nothing behind it fails too),
1071
+ classifies type-only imports per specifier and runs the external-dependency
1072
+ rules over type edges as well, gates the client entry by the same allow
1073
+ list shape as the mock entry (so "reaches neither `core/` nor `session/`"
1074
+ is pinned in both halves), enforces the layering direction over the whole
1075
+ tree, and checks `docs/exports.md` against the three entries in both
1076
+ directions. The store-conformance suite has a `LambderDdbIdempotencyStore`
1077
+ row at `minBytes: 0` and the in-memory DynamoDB double evaluates a
1078
+ condition against a missing or unreadable attribute as false, the way
1079
+ DynamoDB does, rather than as zero. The adapter-conformance suite gained an
1080
+ entry `input` schema's 422, `ctx.envelope.message` and `ctx.logList`, a
1081
+ handler-written response header, a non-string `idempotencyKey`, a
1082
+ `per: "session"` limit and `callerIdentity` scoping. The crypto seam and
1083
+ `LambderBase64`'s no-Buffer branch have tests of their own, the MSW type
1084
+ test compiles the documented wiring against the real `msw`, and the three
1085
+ wall-clock assertions in the suite are fake timers or ordering
1086
+ assertions.
1087
+
1088
+ ### Fixed
1089
+
1090
+ - **Headers survive a crash with no global error handler, which is the default
1091
+ configuration.** A global error handler is not the default, so the plainest
1092
+ app of all, one whose handler throws, wrote its session record and sent the
1093
+ browser nothing: signed in on the server, signed out in the browser, and a
1094
+ cross-origin caller could not read the error either, because the CORS headers
1095
+ went with them. The last-resort answer now carries the call's headers and its
1096
+ CORS headers. It is still emitted without finalizing, because finalization
1097
+ may be what failed; applying headers is plain object work and none of the
1098
+ compression, base64 or size handling that finalization does. The mock runtime
1099
+ had the same hole from the other side and applies the call's headers on every
1100
+ exit.
1101
+ - **Headers written during a call are no longer lost when an afterRender hook
1102
+ answers with a different response**, nor when the global error handler
1103
+ answers a crash. They belong to the call, not to the response that first
1104
+ carried them, which is how routes always treated them. A login API plus a
1105
+ response-rewrapping hook wrote its session record and sent the browser no
1106
+ cookie, with no error anywhere; a call that set a cookie and then threw lost
1107
+ the cookie and every CORS header with it, so a cross-origin caller could not
1108
+ even read the error.
1109
+ - **An `afterRender` hook can override or delete a header the handler wrote.**
1110
+ The call's headers are applied before the hooks run, and only what the hooks
1111
+ themselves wrote is applied afterwards; a hook that answers with a different
1112
+ response still ships the whole set.
1113
+ - **A `created` hook that fails no longer answers every later invocation on
1114
+ that warm container with the first failure.** The cached initialization is
1115
+ forgotten on rejection and the next invocation runs the hooks again.
1116
+ - **A thrown value that cannot be turned into a string no longer escapes.**
1117
+ `String(err)` was the first statement of the outermost catch, so an object
1118
+ with a null prototype, a Proxy, or a throwing `toString` made the catch
1119
+ itself throw: the error handler never ran, no envelope was produced, and the
1120
+ invocation rejected with a 502 carrying nothing a client could parse.
1121
+ - **A registration that throws no longer consumes the API name**, so a caller
1122
+ that catches the error and retries sees the problem it is fixing rather than
1123
+ a duplicate-name error. A session API whose registration is refused is the
1124
+ same case and no longer burns its name either.
1125
+ - **The "you forgot `guards`" error names what is wanted** instead of reading
1126
+ `guards: never`, and creation and registration errors from the server adapter
1127
+ all carry the `Lambder: ` prefix.
1128
+ - **Each policy subsystem reports its own absence.** An API declaring `guards`
1129
+ on an instance with no guards map was told that none of rate limits, guards
1130
+ or idempotency was configured, which reads as a question about all three; a
1131
+ second `guards` map at creation is now refused rather than merged, the way
1132
+ the other two engines already refused one.
1133
+ - **Annotating a policies map compiles.** `LambderRateLimitPer<Ctx>` and
1134
+ `Record<string, LambderApiRateLimitPolicyConfig<Ctx>>` were a hard TS7006 on
1135
+ `ctx`, because a union with a function member in each arm defeats contextual
1136
+ typing, so a policies map could not be declared apart from the `create()`
1137
+ call at all.
1138
+ - **The exported pipeline binds `rateLimits` to its own context type**, so a
1139
+ third adapter built over the documented core gets the binding this release
1140
+ fixes for the two shipped ones.
1141
+ - **`answerUnknownApi` carries the call's headers** and holds no version gate:
1142
+ both adapters run `prepare()` before a name is resolved, so a stale client
1143
+ has already been answered by then.
1144
+ - **The 422 carries the call's `logList`,** as every other answer does, and the
1145
+ server no longer carries its own copy of "no handler, standard 422":
1146
+ `inputValidationRefusal` answers `null` and the pipeline renders the standard
1147
+ body.
1148
+ - **The mock runs the protocol's pre-pass before it resolves the name**, which
1149
+ is where the server runs it. An unknown name reached the notFound refusal
1150
+ without the version gate or the payload restore, so a stale client or a
1151
+ malformed compressed payload was answered differently by the two adapters:
1152
+ the server said 400, the mock said 200 with an `api-not-found` refusal. The
1153
+ request event was emitted before the restore too, so a dev panel watching
1154
+ calls in flight showed no payload for exactly the compressed calls someone
1155
+ opens a panel for.
1156
+ - **A guard is recorded before its own input schema runs.** A guard that
1157
+ refuses by rejecting its `apiInput`/`guardInput` slice answers 422, and the
1158
+ trace ended on the name of the guard *before* it, which is the one
1159
+ misreading a trace cannot recover from.
1160
+ - **The guards that ran are reported, the refusing one last.** A guard is
1161
+ recorded before it runs, so the trace ends on the name that said no and the
1162
+ guards after it never appear. Both idempotency replay paths are called
1163
+ replays. All of it used to be assembled from side channels that a refusal
1164
+ discarded, and the mock's call log said "no guards ran" for exactly the
1165
+ calls someone was debugging. The mock's log reports them on a call that
1166
+ crashed too, because the pipeline's trace is handed in and survives the
1167
+ throw.
1168
+ - **`ctx.guardData` has no prototype.** Guard names are the app's to choose,
1169
+ and a check-only guard named `toString` read back as the inherited function.
1170
+ - **An `errorMessage`, `message` or `crash` an app set to an empty value now
1171
+ reaches the caller.** The envelope tested truthiness, so `errorMessage: ""`
1172
+ shipped as no `errorMessage` at all and the caller's handler never ran.
1173
+ - **A refusal's header replaces the envelope's own under any casing**, rather
1174
+ than shipping a second `Content-Type` beside it.
1175
+ - **The idempotency engine hands the store a copy of the answer.** The pipeline
1176
+ applies the call's own headers into the answer after the record is stored, so
1177
+ a store that kept the object it was given (the shipped ones copy; a custom
1178
+ one is under no compiler's supervision) had this call's `Set-Cookie` in the
1179
+ record and replayed it to everyone.
1180
+ - **`callerIdentity` runs once per call** rather than once at the replay lookup
1181
+ and again at the claim. It is app code that may verify a token or read a
1182
+ store.
1183
+ - **A pending idempotency claim's lifetime is configurable**, through
1184
+ `defaultPendingTtlSeconds` and a per-API `pendingTtlSeconds`. It was a fixed
1185
+ five minutes while a Lambda may run fifteen, so a claim could expire while
1186
+ its own handler was still working and hand the next retry a free scope,
1187
+ which runs the operation a second time. The default is unchanged at 300.
1188
+ - **The two idempotency stores agree about an owner settling after its claim
1189
+ expired.** `LambderMemoryIdempotencyStore` dropped the expired entry on read and
1190
+ reported "lost"; `LambderDdbIdempotencyStore` checked only the owner token and,
1191
+ because TTL deletion is lazy, usually still had the item and reported
1192
+ "stored", so the answer depended on whether AWS had swept yet. Both report
1193
+ "lost", and tests/store-conformance pins it.
1194
+ - **`LambderDdbIdempotencyStore` reads stored items as a typed record with its
1195
+ load-bearing fields checked.** An unreadable status code replays as 200
1196
+ instead of NaN, headers that are not a multi-value map are dropped instead of
1197
+ reaching the response builder, a stored `__proto__` header cannot touch the
1198
+ object being built, and an unreadable expiry counts as expired instead of
1199
+ immortal.
1200
+ - **`LambderDdbIdempotencyStore.complete()` takes the same record type as the memory
1201
+ store**, and `LambderMemoryIdempotencyStore.recordOf()` hands back a copy.
1202
+ `LambderIdempotencyStore` documents the copy-on-write rule the pipeline
1203
+ depends on, and the conformance suite asserts it: the suite's ten early
1204
+ returns are gone, which used to let every rule about a granted claim pass on
1205
+ a store that granted none.
1206
+ - **A cookie written before the handler no longer disables idempotency.** The
1207
+ engine refuses to store an answer carrying a `Set-Cookie`, and a stale
1208
+ session cookie evicted during the session read was being charged to the
1209
+ handler's answer, so an idempotent operation silently re-executed on every
1210
+ retry. The rule now weighs what the handler itself wrote.
1211
+ - **A store failure while recording an answer no longer turns a completed
1212
+ operation into a 500.** Settling happens after the handler has run, so
1213
+ failing closed there could not prevent anything: it answered 500 and
1214
+ released the claim, so the retry found no record and executed the operation
1215
+ a second time, which is precisely what idempotency exists to prevent.
1216
+ `failOpen` governs the decision before execution, where refusing still means
1217
+ refusing to act. A cleanup failure no longer masks the handler's own error
1218
+ either.
1219
+ - **`idempotency: false` no longer requires a configured store.** An explicit
1220
+ opt-out asks for nothing and needs nothing behind it.
1221
+ - **The idempotency budget applies on the uncompressed path too.** With
1222
+ compression off an oversized body reached DynamoDB and came back as a
1223
+ ValidationException, which is not a conditional-check failure, so it escaped
1224
+ as a store error instead of "too-large".
1225
+ - **The bounded memory map evicts what expires soonest, not what was written
1226
+ earliest.** Insertion order retired exactly the entries with the most life
1227
+ left in them, which are the long-window rate-limit counters and the day-long
1228
+ idempotency records, so a flood of short-lived keys could reset a monthly cap
1229
+ or drop somebody else's pending claim. A flood now evicts mostly itself.
1230
+ - **A write at the in-memory ceiling no longer costs a full sweep plus a full
1231
+ scan.** Eviction takes a batch of one percent of the ceiling and the sweep
1232
+ stays amortized, where the full sweep used to run from inside the eviction
1233
+ and double the cost of every write once the map sat at its ceiling: measured
1234
+ at 100,000 entries, 0.56 ms per write became 0.007 ms.
1235
+ - **The in-memory rate limiter joins its counter fields through the escaping
1236
+ join**, so no two tracker keys can land on one counter.
1237
+ - **A session record whose data will not decode reads as no session again**,
1238
+ which is what `docs/sessions.md` and the store's own docstring always said.
1239
+ Moving the Brotli decode inside `LambderDdbSessionStore.get` put it inside
1240
+ the manager's read-failure guard, so it surfaced as a 500 instead. The two
1241
+ have to stay apart: a read failure is transient infrastructure and signing
1242
+ somebody out over a DynamoDB blip is the worse answer, while a record that
1243
+ will not decode will not decode on the next request either, so a 500 there
1244
+ leaves a session the visitor can neither use nor clear until the TTL
1245
+ retires it.
1246
+ - **A failed sliding-expiration or `dataRefresh` write is logged with its
1247
+ reason** instead of being swallowed, so a store that fails every renewal is
1248
+ visible as something other than users being signed out early. The log carries
1249
+ neither the record nor the tokens.
1250
+ - **The "several cookies arrived, reading each" warning is emitted after the
1251
+ cap check**, where reading actually happens: over the cap nothing is read at
1252
+ all.
1253
+ - **`LambderDdbSessionStore.fromItem` validates the fields a session record is
1254
+ made of before casting**, so an item written by hand, by an older schema, or
1255
+ by another app sharing the table reads as no session instead of reaching the
1256
+ constant-time comparisons as a non-string.
1257
+ - **`LambderMemorySessionStore` joins its key through the shared
1258
+ `joinKeyFields` escaping and copies records through JSON** the way DynamoDB
1259
+ serializes them, so an `undefined` field drops and a cyclic value throws on
1260
+ both stores rather than only on the real one.
1261
+ - **`crypto.timingSafeEqual` is used again where the runtime has it**, with the
1262
+ constant-time loop as the fallback for browsers.
1263
+ - **The DynamoDB stores no longer keep private copies of the shared
1264
+ vocabulary**, the three with a store interface declare it, and all four share one
1265
+ `ready()` implementation (which is what let their region handling drift
1266
+ apart) and load the SDK through one document-client loader in
1267
+ `stores/LambderDdbSdk.ts`, so a supplied document client no longer pulls in
1268
+ `@aws-sdk/client-dynamodb` and a conditional-check failure is recognised
1269
+ through one predicate. The idempotency store loads `crypto` through the same
1270
+ optional seam as everything else instead of importing it statically.
1271
+ - **An API handler's binary body is compressed.** A `Buffer` reached
1272
+ finalization as the base64 the core carries it in, which is never
1273
+ compressed, so a large `res.file()` from an API shipped a third more bytes
1274
+ and could cross the Lambda response cap.
1275
+ - **Compressed payloads restore in a bundled browser build.** `fs`, `path`,
1276
+ `zlib` and `crypto` are mapped to `false` for the browser, and bundlers
1277
+ honour that with a stub module rather than a failed import, so the absence
1278
+ was read as presence and the first real call into it threw. Each module is
1279
+ now asked for a function it would really export.
1280
+ - **The package declares `sideEffects: false`**, so a bundler may drop what a
1281
+ consumer never imports. `lambder/client` re-exports `LambderCookieJar` as a
1282
+ value, and neither `tough-cookie` nor `tldts` declares the field, so the
1283
+ public suffix list rode along into every page that imported the client
1284
+ entry: measured at 371,677 bytes against 27,125 for the same program once
1285
+ the field is set. The jar stays on the browser entry, because a caller
1286
+ running under Node genuinely needs it; what matters is that nothing else
1287
+ reaches tough-cookie, which `tests/package-exports` now pins.
1288
+ - **A synthesized invoke event's own headers cannot be displaced by the
1289
+ caller's.** `headers` was spread last, so a per-call entry overwrote the
1290
+ forwarded address and could erase or downgrade the invoke markers.
1291
+ Forwarding an incoming request's headers into `options.headers` is an
1292
+ ordinary gateway-lambda pattern, which made this reachable from outside.
1293
+ - **`LambderInvokeCaller` no longer believes an answer that arrived after its
1294
+ own timeout**: a 20ms `timeoutMs` against a 300ms callee now reports
1295
+ `timeout` rather than `ok: true`, through `localTransport`, the mock invoke
1296
+ transport and any custom transport. `LambderInvokeCaller.localTransport`
1297
+ honours the signal by ending the wait, the way `lambderHandlerTransport`
1298
+ does.
1299
+ - **Both callers refuse a call whose signal had already aborted** before
1300
+ handing it to the transport, so an abandoned call never reaches the callee.
1301
+ - **`LambderCaller` detaches its abort listener when a call settles**, instead
1302
+ of leaving one per call on a shared external signal.
1303
+ - **`LambderCaller.api()` returns `undefined` on failure, not `null`**; the
1304
+ docs and the docstring said different things and both now match the code.
1305
+ - **`lambderFetchTransport` explains a relative apiPath outside a browser**,
1306
+ which fetch reports as a URL parse error and the caller used to pass on as
1307
+ `network`.
1308
+ - **`Content-Encoding: identity` is accepted on an invoke answer.** It is a
1309
+ legal value meaning no encoding, and a hook or a proxy may set it; it was
1310
+ read as unsupported and turned the whole invoke into a protocol failure.
1311
+ - **A `LambdaClient` passed as `client` keeps whatever `maxAttempts` it was
1312
+ built with**, which the option docstring and the docs now say: `clientConfig`,
1313
+ and the one-attempt default, apply only to a client the caller creates.
1314
+ - **`clientIp` on an invoke is documented as what it is**, the event's
1315
+ `requestContext.http.sourceIp`; no IP header is trusted at either end.
1316
+ - **The documented MSW wiring compiles against msw 2 again.** The adapter's
1317
+ resolver type matches msw's own, and the handler it returns keeps the
1318
+ module's handler type, so `setupWorker(handler)` takes it. The declaration is
1319
+ pinned against a replica of msw's signature rather than against itself.
1320
+ - **The MSW adapter reads calls from the app's `defaultClientIp`** instead of a
1321
+ hardcoded `127.0.0.1`, and the mock's invoke transport reads the caller's
1322
+ address from the synthesized event's `sourceIp`, as the server's
1323
+ `createContext` does and through the same `normalizeClientIp`; it used to
1324
+ prefer `x-forwarded-for`, the trust this release removed from the server, and
1325
+ did not normalize the value. One client is one address under both adapters,
1326
+ so a per-IP rate limit counts it once.
1327
+ - **The mock no longer signs a browser out on any host but plain `localhost`.**
1328
+ `signIn` planted cookies at `localhost` while the transport's jar scoped them
1329
+ by the caller's site host, so every session call on a dev host such as
1330
+ `transit.localhost:5173` answered sessionExpired with a full jar.
1331
+ - **`sessionNotMocked` runs the same registration checks the other builders
1332
+ run**, so a session endpoint on a mock without the `sessions` option fails
1333
+ where it is written instead of answering 500 at the first call with a message
1334
+ naming the server's option.
1335
+ - **A mock entry's `input` schema is pinned to the contract in both
1336
+ directions.** The schema is the mock's own, because the contract is a type
1337
+ and the server's schemas do not exist on that side, but what it parses to is
1338
+ the server's. A bare `z.ZodType` let a restated shape drift from the endpoint
1339
+ it stands for, and the mock then answered 422 to every payload the server
1340
+ accepts, which is the exact failure the schema exists to reproduce; a schema
1341
+ stricter than the endpoint (an extra required field, a literal) is refused
1342
+ for the same reason.
1343
+ - **A mock registration that throws no longer consumes the names of the
1344
+ slices before it.** Slices were added one entry at a time, so a later slice
1345
+ failing a check left the earlier ones registered, and a caller that caught
1346
+ the error, fixed its slices and called again hit a duplicate-name error from
1347
+ its own first attempt instead of the problem it had fixed. Slices are staged
1348
+ and committed together now. (The server side of this was already fixed; the
1349
+ mock had the same shape.)
1350
+ - **The mock's record of a call is built in one place**, the call recorder, so
1351
+ its event and its log row cannot say different things about the same call,
1352
+ and the browser-cookie mirror lives in its own collaborator.
1353
+ - **`lambder/mock`'s type graph no longer reaches `aws-lambda` or
1354
+ `@aws-sdk/client-lambda`**: the mock's invoke transport declares the event
1355
+ and result shapes it uses. The entry reaches no `core/` module at all.
1356
+ - **`examples/mock-app-example.ts` compiles under the repository's own
1357
+ typecheck**, with no `@ts-nocheck` over it.
1358
+ - **`LambderHttpStatusCode` moved to `shared/LambderHttpStatus.ts`** (same name from
1359
+ the root entry, which now reads it from the module that declares it). It is
1360
+ HTTP vocabulary with no relationship to the server's response class, and
1361
+ importing it from `core/` was the one edge that pointed the wrong way: it
1362
+ pulled `core/LambderResponse.ts`, and with it `aws-lambda`, into the type
1363
+ graph of `lambder/client`, so a browser-only consumer needed
1364
+ `@types/aws-lambda` resolvable to typecheck a status union. `lambder/client`
1365
+ now reaches neither `core/` nor `session/`, which `tests/package-exports`
1366
+ pins.
1367
+ - **`LambderResponse`'s three header methods are the API core's header
1368
+ helpers** rather than a second copy of them, and the cookie serializer lives
1369
+ in `shared/LambderCookie.ts`, which was the one runtime edge from the
1370
+ isomorphic layers into `core/`. Root-entry names are unchanged.
1371
+ - **The response brand the guards engine tests for is one constant** in
1372
+ `shared/LambderResponseBrand.ts`, imported by `LambderResponse` and by the
1373
+ engine, where the engine used to retype the symbol's name by hand: a rename
1374
+ of the symbol would have reopened the guard fail-open with every test green.
1375
+ - **Copies that had already drifted are one implementation each**: base64
1376
+ encoding and decoding in `shared/LambderBase64.ts`, the default session
1377
+ cookie names in `shared/LambderSessionCookieNames.ts` (one definition where
1378
+ there were four), the positive- and non-negative-integer option checks in
1379
+ `shared/LambderOptionChecks.ts`, coercing a thrown value to an `Error` in
1380
+ `shared/LambderCrashDetail.ts` (a caller failure no longer reports the
1381
+ literal message `"Error: "`), and the abort wiring both callers share.
1382
+ - **`maxRequestPayloadBytes` is validated once**, by the pipeline that owns it,
1383
+ rather than by an identical check in the server adapter as well.
1384
+ - **The contract's `mode` vocabulary has one declaration.** It was written out
1385
+ twice as a type and three more times as inline literals.
1386
+ - **Docstrings attach to their declarations again** on `LambderApiPipeline`,
1387
+ `LambderApiGuard`, `LambderGuardOf` and the idempotency engine, and `src/api/`
1388
+ no longer imports from `core/` at all: `lambderGuard` and
1389
+ `lambderRateLimitKey` are built in `core/LambderPolicyBuilders.ts` from the
1390
+ generic builders, the answer-header accumulator is `shared/LambderAnswerHeaders.ts`,
1391
+ and `LambderApiCallTrace` sits beside the call context in
1392
+ `api/LambderApiCallContext.ts`, which dissolves every module cycle inside
1393
+ `src/api/`. Root-entry names are unchanged.
1394
+ - **`Lambder.ts` is shorter and sectioned**, with the API pipeline moved out
1395
+ on top of that: the option and handler types and the option
1396
+ validation live in `core/LambderCreateOptions.ts`, the index-HTML layer in
1397
+ `core/LambderIndexHtml.ts`, and the class reads as construction,
1398
+ registration, accessors, the request path and the API path.
1399
+ - **`LambderExpiringMap` never answers a write it did not keep.** At a ceiling
1400
+ made entirely of protected entries, an evictable write used to evict itself
1401
+ and return normally with nothing stored; the write is now refused with
1402
+ `LambderExpiringMapFullError`, and the entry just written is never its own
1403
+ victim. An expired entry is reclaimed before any live one is evicted,
1404
+ protected or not, in the same walk the batch already makes.
1405
+
1406
+ - **`maxResponseBytes` counts bytes.** It compared `String.length` against
1407
+ the ceiling, which is UTF-16 code units, so an uncompressed non-ASCII body
1408
+ passed a guard it was up to three times over and Lambda then refused the
1409
+ invocation with an opaque payload-size error and no envelope, which is the
1410
+ outcome the option exists to replace. A base64 body keeps `.length` (it is
1411
+ ASCII); everything else is measured with `Buffer.byteLength(body, "utf8")`,
1412
+ and the error message reports bytes.
1413
+ - **`beforeRender` hooks run for `servePublicFiles` and `serveIndexHtml`
1414
+ answers.** The loop sat after route matching, so every static asset and
1415
+ every app-shell page skipped the one hook that can inspect a request,
1416
+ replace its context or answer in its place: a `Content-Security-Policy` set
1417
+ in a hook reached the API answers and not the HTML it was written for, and
1418
+ a maintenance-mode or blocklist hook served the whole frontend anyway. The
1419
+ loop is one method now, run for a matched route (after `ctx.pathParams` is
1420
+ populated, as before) and at the top of the unmatched path, before the
1421
+ `fallback` hooks, which are still typed `void` and still cannot answer.
1422
+ - **CORS is applied once to a preflight.** The 204 was given the preflight
1423
+ headers where it was built and the ordinary ones again at the end of
1424
+ `render()`, so an allowlisted app answered every preflight with
1425
+ `Vary: Origin, Origin` and an `Access-Control-Expose-Headers` that means
1426
+ nothing before a request.
1427
+ - **A v1 event's cookies are read from `multiValueHeaders` when it carries
1428
+ them.** API Gateway REST APIs keep only the LAST value of a repeated header
1429
+ in `headers`, and HTTP/2 lets a client split its cookies across several
1430
+ `Cookie` headers, so a copy could be dropped before the session scan that
1431
+ weighs every copy of a name. v2 events already delivered them all.
1432
+ - **`<!--if:name-->` looks up its own properties only.** It read `data[name]`
1433
+ straight, so `<!--if:toString-->` was unconditionally true on every render;
1434
+ it uses the same own-property check the slot lookup uses.
1435
+ - **The host-only eviction no longer deletes a live cookie on its own.** When
1436
+ a request carries several session cookies and a `cookie.domain` is
1437
+ configured, the response evicts the host-only twin. Which copy is stale is
1438
+ an assumption (the request carries no scope), and for an app that
1439
+ configured a domain after running host-only it is the wrong one: the
1440
+ visitor's live cookie IS the host-only copy, and any sibling host can make
1441
+ the twin arrive by planting a well-formed dead cookie at the parent domain.
1442
+ The eviction now always ships with the resolved session re-issued at the
1443
+ configured scope in the same response, so the visitor stays signed in, and
1444
+ a domain migration converges on the first request instead of waiting for a
1445
+ sliding write.
1446
+ - **A refusal an app spells out as the empty string is read as a refusal.**
1447
+ The envelope writer keeps `errorMessage: ""` on purpose; `resolveApiOutcome`
1448
+ tested truthiness, so the refusal arrived and was dropped and the call
1449
+ resolved `ok: true` with a null payload, on both callers, with
1450
+ `errorMessageHandler` never running. Both halves now test presence, the
1451
+ browser caller's `message` check included.
1452
+ - **The `logList` of a failed answer reaches its handler.**
1453
+ `resolveApiOutcome` carries `logList` on every arm it can: the envelope's on
1454
+ a success or an envelope refusal, the parsed 500 body's on a server failure,
1455
+ and the validation body's on a 422, which the server writes there as it
1456
+ does onto a success. Each caller surfaces it once, immediately after
1457
+ reading the answer and before any failure branch. The browser caller
1458
+ surfaced logs only after its early returns for `server` and `validation`,
1459
+ so a 500 whose global error handler attached `crash` and `logList`, the
1460
+ answer whose log trail is worth the most, was the one answer whose logs
1461
+ were never printed; the invoke caller read them off the envelope only, and
1462
+ dropped a 422's.
1463
+ - **A per-call header cannot displace the ones `lambderFetchTransport`
1464
+ owns.** The caller's `headers` go on first and `Content-Type` and the jar's
1465
+ `Cookie` after them, matching the synthesized event's rule. A Node script
1466
+ or test that added one `Cookie` header of its own used to replace the whole
1467
+ Cookie header a `LambderCookieJar` had just built, losing the session and
1468
+ getting `sessionExpired` with nothing in the failure pointing at the cause.
1469
+ - **`lambderCookieJarTransport` scopes the jar by where the call actually
1470
+ goes.** The host is `apiPath`'s own host, then the `host` option, then the
1471
+ caller's `siteHost`. An `apiPath` that names a host is a fact about this
1472
+ request; the option is the fallback for a relative path. The old order let
1473
+ a transport configured with `{ jar, host: "app.example.com" }` and an
1474
+ absolute cross-origin `apiPath` send app.example.com's session to another
1475
+ host.
1476
+ - **`LambderInvokeCaller.request()` refuses an oversized event like an API
1477
+ call does.** The `LAMBDER_INVOKE_MAX_EVENT_BYTES` guard moved into the
1478
+ delivery path both methods share, so a large `body` comes back as
1479
+ `payloadTooLarge` with the byte count instead of the SDK's
1480
+ `RequestEntityTooLargeException` classified as `protocol`.
1481
+ - **A throttled DynamoDB `Query` no longer deletes a healthy cache entry.**
1482
+ `LambderDdbCache.get` treated anything thrown while reading an entry's
1483
+ chunks as corruption: one throttle, a partition split or a socket timeout
1484
+ dropped the manifest, orphaned its chunks until their TTL, and sent every
1485
+ later reader to the origin, which is the load the cache exists to absorb.
1486
+ Only a failure the read can prove (chunks that do not match the manifest, a
1487
+ checksum that does not hold, a restore the compression codec will not vouch
1488
+ for) drops the entry now; anything else propagates, the way a failed
1489
+ manifest read always has, and `getOrSet`'s fail-open handles it as the
1490
+ infrastructure failure it is.
1491
+ - **`LambderDdbIdempotencyStore` never compresses an empty body.** Under
1492
+ `compression: { minBytes: 0 }`, the documented "compress everything"
1493
+ setting the cache and the session store both ship, a 204 or an empty 200
1494
+ was stored as compressed bytes declaring a length of zero, which the codec
1495
+ refuses on the way back: `peek` and `begin` both threw for the record's
1496
+ whole TTL, the engine failed open on each, and every retry executed the
1497
+ operation again. An empty body takes the plain path and replays as the
1498
+ empty body it was.
1499
+ - **`begin()` no longer replays a record whose expiry cannot be read, and a
1500
+ record with no expiry no longer deadlocks its scope.** DynamoDB evaluates a
1501
+ comparison against an attribute it cannot read as false, so a claim
1502
+ condition that only asked `expiresAt <= :now` refused such an item for
1503
+ ever: a settled one replayed its stored answer with no expiry test of its
1504
+ own (where `peek` called the same item expired), and a pending one answered
1505
+ 409 for ever with no TTL able to retire it. The condition takes an absent
1506
+ expiry as free, and `begin` runs the expiry test `peek` runs before handing
1507
+ an answer back.
1508
+ - **`signIn()` works behind the MSW adapter**, the documented browser path.
1509
+ Two causes, both fixed: the adapter scoped its cookie jar by the request
1510
+ URL's host rather than by the app's `cookieHost`, so an app whose cookie
1511
+ host is not the page's held the session at a host it never sent it to; and
1512
+ `signIn` was the one cookie writer that skipped the runtime's
1513
+ `document.cookie` mirror, so the page had no readable CSRF cookie, the
1514
+ browser caller posted an empty token, and every session endpoint answered
1515
+ `sessionExpired`. `signOut` is symmetric: it clears the jar's cookies and
1516
+ the mirrored ones the way `reset()` does.
1517
+ - **The mock's four session members name the mock's own option.** `signIn`,
1518
+ `signOut`, `expireSessionData` and the `sessionManager` getter threw the
1519
+ pipeline's "Configure the session option at creation", which names the
1520
+ server's `session`; the mock's is `sessions`.
1521
+ - **The mock picks the plain crypto stand-in from the store's own
1522
+ `isMemoryOnly`**, not from whether the runtime created the store. An app
1523
+ passing its own `LambderMemorySessionStore` on a plain-http page got
1524
+ `LambderWebCrypto` and threw on its first session call where the default
1525
+ path degrades.
1526
+ - **The MSW adapter builds its header map through `lowercaseHeaderNames`**,
1527
+ like every other adapter: a request header named `__proto__` was dropped
1528
+ there and landed as an own key on the server, and `headers["toString"]`
1529
+ handed a guard an inherited function.
1530
+
1531
+ ### Documentation
1532
+
1533
+ - **The documented starting point turns `requireSessionApiGuards` and
1534
+ `requirePublicApiGuards` on.** Both still default to off in the framework,
1535
+ and that is deliberate: a named no-op guard satisfies either flag, so the
1536
+ default buys a written declaration rather than any authorization, and
1537
+ demanding one before a new app's first endpoint compiles is a cost with no
1538
+ security behind it. An app is where the declaration pays, though, because
1539
+ "which of these endpoints are open, and why?" stops being answerable by
1540
+ reading them somewhere past the first handful. So `docs/getting-started.md`
1541
+ and `examples/secure-session-example.ts` now declare the two no-op guards
1542
+ (`sessionOnly`, and `open` carrying its reason as a parameter) and turn both
1543
+ flags on, with the reason each public endpoint is open written at its
1544
+ registration. An app that copies either one starts out declaring who may
1545
+ call what, and drops both lines if it would rather not.
1546
+ - **`docs/sessions.md` no longer calls the one planted-cookie case that IS a
1547
+ takeover harmless.** A visitor with no session who receives a planted
1548
+ session cookie and its matching CSRF cookie is signed into the planting
1549
+ account until they sign in themselves: the scan finds one live candidate,
1550
+ the posted token is the planted one because there is no other for the
1551
+ client to read, the pairing succeeds, and the visitor's own traffic renews
1552
+ the session and re-issues it at the app's scope. The server cannot tell it
1553
+ apart from a real session. `__Host-` is the defence, not a nicety, and it is
1554
+ now in the option table, in the page's first example, in
1555
+ `docs/configuration.md`'s cookie rows and in
1556
+ `examples/secure-session-example.ts`. The page also states what
1557
+ `cookie.domain` costs (the cookie is SENT to every current and future host
1558
+ under the domain), that the everywhere-clear reaches no cookie planted at a
1559
+ longer `Path` (that variant is refused but not healed), that session APIs
1560
+ require the posted CSRF token while session routes do not (a route that
1561
+ mutates state checks the token itself, and `sameSite: "None"` leaves such a
1562
+ route with nothing), that `isSessionTokenValid` exists for an app verifying
1563
+ a token itself, and the `sessionSalt` caveat (the record stores
1564
+ `sessionKey` in plaintext beside its own hash, so a dumped table is a
1565
+ known-plaintext pair for the salt).
1566
+ - **`examples/secure-session-example.ts` and
1567
+ `examples/zod-chained-api-example.ts` build their instance with
1568
+ `initLambder().create({...})`** and chain `addApi` onto it, rather than the
1569
+ `new Lambder({...})` form the docs tell readers not to use, and the session
1570
+ example no longer teaches a password-change sequence that signs the user
1571
+ out (`endSessionAll()` deletes every record under the subject, a
1572
+ replacement created before it included). The rendered form posts to a
1573
+ session route that validates the CSRF token it rendered.
1574
+ - **`docs/templating.md` and the engine's docstring state the slot-default
1575
+ rule correctly.** Only `undefined`, or a key the data object does not carry,
1576
+ keeps a slot's default content; `null`, `false` and `""` are values and
1577
+ render the slot empty. Write `value ?? undefined` for the other intent.
1578
+ - **`docs/responses.md` describes `crash` honestly.** The framework never sets
1579
+ it and never withholds it: it reaches whoever the handler that set it
1580
+ answered, so a browser that asked receives the stack trace whether or not
1581
+ anything on the page displays it. `LambderCaller` not surfacing the field is
1582
+ a display choice in one client, not a gate, and `x-lambder-invoke`
1583
+ authorizes nothing. The honest gate for an invoke-only function is that it
1584
+ has no HTTP trigger and is reachable only through IAM, which is what
1585
+ `docs/invoke.md`'s `invokeOnly` example now shows instead of the header
1586
+ check. The page also lists `res.versionExpired()`.
1587
+ - **`docs/api-policies.md`** documents `per: "ip"` policies as a phase of
1588
+ their own (checked before the session read, so list order governs which
1589
+ counter is charged only among policies of one phase), the custom-key bound,
1590
+ and what `callerIdentity` actually sees; `docs/apis.md` shows the same
1591
+ request flow (the version gate and the payload restore lead both lists).
1592
+ - **`docs/invoke.md`** says per-call `headers` are the caller's own assertion
1593
+ and names the three headers the event owns, tells the same narrowing story
1594
+ `docs/client.md` tells, says `message` is surfaced by the browser caller
1595
+ only, points at `LambderCaller.createIdempotencyKey()` for a key, says a
1596
+ session rotation arrives on `outcome.cookies` with no `LambderInvokeSession`
1597
+ rebuilt, and names `nodejs20.x` as the first runtime that ships the AWS SDK.
1598
+ - **`docs/client.md`** shows how to narrow `LambderAppRefusalMessage | string`
1599
+ (every migrating `errorMessageHandler` hits it), states that an answer's
1600
+ `logList` reaches the handler whatever the outcome, matches the jar's new
1601
+ precedence, and drops an unverifiable bundle-size figure.
1602
+ - **`docs/mock.md`** no longer contradicts itself about the entry forms,
1603
+ documents `restore()` in place of `using`, `rateLimits.failOpen`, the MSW
1604
+ jar's `cookieHost` scoping, the plain-crypto rule, `requestFromTransport`
1605
+ and `signOut`'s options, and says under "What the mock cannot do" that
1606
+ response finalization is the server's alone.
1607
+ - **`docs/ddb-cache.md`** documents `maxValueBytes`, `chunkBytes`, `client`
1608
+ and `now`, the per-call `ttlSeconds`, `leaseSeconds` and `waitForFillMs`,
1609
+ and that a value updated from another container is served stale here until
1610
+ its own copy expires; `docs/ddb-rate-limiter.md` states the per-request
1611
+ cost of a multi-window policy and the two key bounds;
1612
+ `docs/ddb-idempotency.md` says an empty body is never compressed and a
1613
+ stored `bodyBytes` past 32MB is refused; `docs/frontend-hosting.md` states
1614
+ the path rule a source can rely on.
1615
+ - **`README.md`** no longer calls `zod` an optional peer (it is a required
1616
+ peer; the AWS SDK clients and `msw` are the optional ones), and it and
1617
+ `docs/getting-started.md` say Node 20 rather than `nodejs18.x`; the
1618
+ getting-started tutorial uses `z.email()` rather than the deprecated
1619
+ `z.string().email()`. `docs/api-core.md` writes down the layering rule and
1620
+ the naming vocabulary, and `CONTRIBUTING.md` is gone: the contribution notes
1621
+ it held are a short section of `README.md` now.
1622
+
1623
+ ## [6.0.2] - 2026-09-13
1624
+
1625
+ No source changes: TypeScript 6, ESLint and the tsconfigs moved forward, and
1626
+ the tests were adjusted to the stricter checking that came with them.
1627
+
1628
+ ## [6.0.1] - 2026-09-13
1629
+
1630
+ A major because this one does break: five exports were renamed with no alias
1631
+ kept, and one overload stops compiling code that used to. The migration is
1632
+ mechanical and the compiler finds every site.
1633
+
1634
+ - `restoreBoundedText(bytes, declaredBytes, encoding)` is now
1635
+ `restoreText(bytes, encoding, { declaredBytes })`, beside `restoreBytes`
1636
+ with the same signature for bytes that are not text.
1637
+ - `COMPRESSED_PAYLOAD_FIELD` is `COMPRESSED_PAYLOAD_GZ_FIELD`,
1638
+ `LambderCompressedPayload` is `LambderCompressedGzipPayload`, and
1639
+ `compressPayloadJson` / `decompressPayloadJson` are `compressPayloadGzip` /
1640
+ `decompressPayloadGzip`. Each has a new Brotli sibling.
1641
+ - `DEFAULT_MAX_REQUEST_PAYLOAD_BYTES` is `DEFAULT_MAX_RESTORED_PAYLOAD_BYTES`,
1642
+ the same 20,000,000.
1643
+ - `res.api(null)` no longer compiles on an API whose output schema does not
1644
+ allow null. Answer the declared output, or pass the reason beside the null
1645
+ (`res.api(null, { errorMessage })`). A handler that returned a bare null on
1646
+ a non-nullable output was the bug this catches.
1647
+
1648
+ The wire format is unchanged in both directions, so a deployed callee and an
1649
+ older client still understand each other.
1650
+
1651
+ ### Added
1652
+
1653
+ - **`LambderInvokeCaller`**, a server-side caller that invokes a Lambder app
1654
+ running in another Lambda function directly, with no API Gateway in between.
1655
+ It synthesizes the payload-format-2.0 event the gateway would have delivered,
1656
+ invokes the function with `RequestResponse`, and reads the response object
1657
+ Lambder returns, so the callee is an unmodified Lambder app and everything it
1658
+ offers over HTTP applies unchanged: zod validation, the inferred contract
1659
+ (imported type-only, so `api("sendEmail", payload)` is typed end to end),
1660
+ refusals, guards and guard inputs, rate limits, idempotency keys, sessions
1661
+ carried on a user's behalf, `logList`, and compression both ways. `api()`
1662
+ throws a `LambderInvokeError` on any failure (a failed dependency is a failed
1663
+ request) while `apiOutcome()` resolves to a discriminated outcome; `request()`
1664
+ reaches any route of the callee; `onFailure` is awaited for every failed call,
1665
+ whichever method the site used, so failures are reported in one place.
1666
+ `LambderInvokeCaller.localTransport(handler)` runs a callee's real handler
1667
+ in-process for tests and `createEvent` builds the event a call would send, for
1668
+ boot checks. The callee tells an invoke from a browser by the
1669
+ `x-lambder-invoke` marker header, which is for guards and hooks and never an
1670
+ authorization: the `lambda:InvokeFunction` grant is that. A hook that throws
1671
+ (`onFailure`, `onLogList`) is logged and ignored, so `apiOutcome()` keeps its
1672
+ promise never to throw, and the event is serialized exactly once per call
1673
+ (the transport receives that JSON as `eventJson`). Documented in
1674
+ [docs/invoke.md](./docs/invoke.md), with `LambderInvokeError`,
1675
+ `isLambderInvokeError`, the outcome, failure and handler types, the
1676
+ transport and event types, and the protocol constants exported from
1677
+ `lambder`. Nothing a call is built from may escape as a throw either: a
1678
+ guardInputs provider that rejects, or a payload holding a cycle or a BigInt,
1679
+ fails as an `unknown` outcome through `onFailure` like any other failure,
1680
+ so `apiOutcome()` keeps its promise never to throw and `api()` always
1681
+ throws a `LambderInvokeError`. An external `AbortSignal` a call is given is
1682
+ detached from when the call ends, so a signal shared across calls does not
1683
+ accumulate one listener per call.
1684
+ - **A `crash` field on the API envelope**, with `describeCrash(err, ctx)` to
1685
+ build it and `errorFromCrashDetail(crash)` to rebuild an Error from it. A
1686
+ global error handler answering a caller it trusts can now hand back the whole
1687
+ failure (name, message, stack, cause chain, the request id and function it
1688
+ happened in) instead of hand-rolling a serialization, and the invoke caller
1689
+ chains it as the `cause` of the error it throws, so an error reporter that
1690
+ walks causes stores the callee's stack without being taught anything.
1691
+ `LambderCaller` ignores the field, so a callee that also faces browsers is
1692
+ unaffected. Both helpers are dependency-free and exported from `lambder` and
1693
+ `lambder/client`.
1694
+ - **`payloadBr`**, a Brotli request payload beside the browser's gzip
1695
+ `payloadGz`. The server accepts either (never both) under the same declared
1696
+ byte length, bound and exact-length verification, so a Node caller compresses
1697
+ with the better algorithm while browsers keep sending what they can produce.
1698
+ `compressPayloadBrotli` builds the pair under the same threshold and
1699
+ only-when-smaller rules as `compressPayloadGzip`.
1700
+ - **`@aws-sdk/client-lambda` as an optional peer dependency**, imported on the
1701
+ first invoke the way the S3 client is imported on the first read. The Lambda
1702
+ Node runtimes provide it, so a deployed function installs nothing new.
1703
+
1704
+ ### Changed
1705
+
1706
+ - **A null API answer needs a reason.** `res.api` (and `res.apiBinary`,
1707
+ `res.die.api`) is overloaded: the declared output, or `null` beside a
1708
+ config (a refusal flag, an `errorMessage`, a `message`). A bare
1709
+ `res.api(null)` compiles only when the output schema allows null, so a
1710
+ success payload is always the declared output and `LambderInvokeCaller.api()`
1711
+ promises exactly that type instead of `TOutput | null`. Untyped resolvers
1712
+ (routes, hooks, `getResponseBuilder`) accept anything as before; a handler
1713
+ that answered a bare null on a non-nullable output is the one thing that
1714
+ stops compiling, and it was the bug this catches. `LambderApiAnswer` is the
1715
+ exported signature.
1716
+ - **The DynamoDB SDK is loaded on first use.** `LambderSessionManager`,
1717
+ `LambderDdbCache`, `LambderDdbRateLimiter` and `LambderDdbIdempotency` used to
1718
+ import `@aws-sdk/client-dynamodb` (and the session manager
1719
+ `@aws-sdk/lib-dynamodb`) at module level, so importing `lambder` loaded both
1720
+ packages and a bundled app referenced them whether or not it kept sessions
1721
+ or used a store. They now import types only and take the classes from one
1722
+ loader (`src/stores/LambderDdbSdk.ts`) the first time a table is touched,
1723
+ the way `LambderS3FileSource` and `LambderInvokeCaller` load theirs. An app
1724
+ without them installed still imports and constructs everything; only the
1725
+ first table access fails, with the install hint naming the store. The
1726
+ `client` option of the stores is honoured as before.
1727
+ - **`restoreBytes(bytes, encoding, bound)` and `restoreText(...)` replace
1728
+ `restoreBoundedText`**, with no alias kept: one restore that takes either
1729
+ `{ declaredBytes }` (the bytes' original length, bounding and verifying the
1730
+ result, what records at rest and request payloads use) or `{ maxBytes }` (a
1731
+ ceiling alone, for bytes whose sender recorded no length, what a compressed
1732
+ HTTP answer read by the invoke caller uses). A nonsense ceiling throws a
1733
+ plain Error, since that is the caller's configuration, not a restore
1734
+ failure. `restoreBytes` hands back the buffer and `restoreText` is that plus
1735
+ the UTF-8 decode, because the one restore without a declared length is also
1736
+ the one whose bytes may not be text: a route may answer a compressed wasm
1737
+ module or an image it forced compression on, and decoding those as UTF-8
1738
+ would replace every byte that is not a valid sequence and hand back a body
1739
+ that is silently not what was sent.
1740
+ - **`DEFAULT_MAX_RESTORED_PAYLOAD_BYTES` replaces `DEFAULT_MAX_REQUEST_PAYLOAD_BYTES`**:
1741
+ the same 20,000,000, which now also defaults the invoke caller's
1742
+ `maxResponsePayloadBytes`, so the name says what it bounds (any restored
1743
+ payload) rather than one direction.
1744
+ - **The `zodError` of a validation outcome is typed as `LambderValidationError`**
1745
+ (`{ name, message, issues }`), the shape that actually crosses the wire,
1746
+ instead of `z.ZodError`, which advertised methods a caller could not call.
1747
+ `LambderCaller`'s `apiInputValidationErrorHandler` receives the same type.
1748
+ Exported from `lambder` and `lambder/client`.
1749
+ - **The envelope to outcome mapping moved out of `LambderCaller`** into
1750
+ `src/shared/LambderApiOutcome.ts`, and the contract-driven call option types
1751
+ (`LambderCallOptionsArg`, the guard-input types) into
1752
+ `src/shared/LambderCallOptions.ts`. Both callers now read one implementation,
1753
+ so which status is a crash, in what order the envelope flags are honoured and
1754
+ what an API demands of its caller cannot drift between them. No behavior
1755
+ change for `LambderCaller` except one: a 5xx answer now keeps the parsed
1756
+ envelope on the outcome's `response` when the server sent one, where it
1757
+ previously kept `errorMessage` alone and dropped the rest, which is what makes
1758
+ `crash` and `logList` readable on exactly the answers that carry them.
1759
+
1760
+ - **Renamed the gzip request-compression names to match their new Brotli
1761
+ siblings**, with no aliases kept: `COMPRESSED_PAYLOAD_FIELD` is now
1762
+ `COMPRESSED_PAYLOAD_GZ_FIELD` (beside `COMPRESSED_PAYLOAD_BR_FIELD`),
1763
+ `LambderCompressedPayload` is `LambderCompressedGzipPayload` (beside
1764
+ `LambderCompressedBrotliPayload`), and `compressPayloadJson` and
1765
+ `decompressPayloadJson` are `compressPayloadGzip` and
1766
+ `decompressPayloadGzip` (beside `compressPayloadBrotli`). The wire field
1767
+ itself (`payloadGz`) is unchanged.
1768
+
1769
+ ### Fixed
1770
+
1771
+ - The 422 validation body carries the zod issues again. zod 4 keeps
1772
+ `ZodError.issues` as a non-enumerable property, so serializing the error
1773
+ as-is left the issues only inside its message string, and a client's
1774
+ `apiInputValidationErrorHandler` received a `ZodError` with nothing to
1775
+ branch on. The refusal now spells the body out as `{ name, message, issues }`.
1776
+
1777
+ ## [5.1.3] - 2026-09-12
1778
+
1779
+ ### Changed
1780
+
1781
+ - Bumped the `zod` dependency and peer range from `^4.1.12` to `^4.6.2`, matching
1782
+ the version already resolved everywhere else in a typical install.
1783
+ - Replaced the deprecated `z.ZodTypeAny` with `z.ZodType` across `Lambder.ts`,
1784
+ `LambderApiGuards.ts` and `LambderApiRateLimits.ts`. Purely a type-level
1785
+ change; runtime behavior is unchanged.
1786
+
1787
+ ## [5.1.1] - 2026-09-11
1788
+
1789
+ ### Added
1790
+
1791
+ - **`LambderHttpFileSource`**, a file source that reads over HTTP(S) from any
1792
+ origin serving files by path: a CDN, a public bucket's own domain (a
1793
+ Cloudflare R2 custom domain, an S3 website endpoint) or another server.
1794
+ `files: new LambderHttpFileSource({ baseUrl: "https://assets.example.com/v42/" })`
1795
+ serves public files, index.html and templates from there through the same
1796
+ reader, memory cache and template cache as every other source. It reads with
1797
+ the runtime's `fetch`, so it needs no SDK and, for a public origin, no
1798
+ credentials, and its reads come out of the origin's edge cache rather than
1799
+ the bucket. A 404 or 410 reads as null and the request falls through; any
1800
+ other failed status, a network error or a timeout (`timeoutMs`, default 10
1801
+ seconds) is an error. Path segments are percent-encoded, so a relative path
1802
+ names the same object it would as an S3 key, and `headers` go with every
1803
+ read for an origin that wants an Authorization header or a known
1804
+ User-Agent.
1805
+
1806
+ ## [5.0.0] - 2026-09-10
1807
+
1808
+ The v4 line is closed and its accumulated surface is released as v5. **There
1809
+ are no breaking API changes**: code written against 4.9.1 compiles and runs
1810
+ unchanged. The major marks the documentation and packaging milestone rather
1811
+ than a migration.
1812
+
1813
+ ### Added
1814
+
1815
+ - **A documentation set**, replacing the single 78KB readme. Eighteen guides
1816
+ under [docs/](./docs/README.md), organized by task: getting started,
1817
+ configuration, routing and actions, APIs and refusals, responses, sessions,
1818
+ API policies, the frontend client, frontend hosting, templating,
1819
+ translations, testing, the four DynamoDB tables, the three standalone
1820
+ stores, and a full exports reference. The readme is now an overview that
1821
+ links into them.
1822
+ - **This changelog.** Release notes used to accumulate as "New in 4.x" blocks
1823
+ at the top of the readme; they now live here, back to 3.0.0, with the 3.4
1824
+ through 3.8 entries reconstructed from the source history rather than left
1825
+ as a pointer at the git log.
1826
+ - **`docs/exports.md`**, a reference for all 154 exported names across the
1827
+ three entry points, grouped by purpose. Every export is now documented.
1828
+ - **Guides for `LambderDdbRateLimiter` and `LambderDdbIdempotency`**, which
1829
+ shipped as standalone modules but had no documentation of their own.
1830
+ - **`CONTRIBUTING.md`**: setup, the test and lint commands, the conventions a
1831
+ change is expected to follow, and the source layout.
1832
+ - **Package metadata**: `description`, `author`, `keywords`, `homepage`,
1833
+ `bugs` and `engines` were empty or absent, so the npm page showed nothing
1834
+ about the package.
1835
+
1836
+ ### Changed
1837
+
1838
+ - **Standard file names**: `Readme.md` is `README.md` and `License.md` is
1839
+ `LICENSE`; the `docs/` pages moved from `SCREAMING_SNAKE.md` to kebab-case.
1840
+ - **The quick start was rewritten.** It had gone stale at v2.0: it taught
1841
+ `new Lambder({...})` instead of `initLambder().create({...})` and imported
1842
+ `LambderCaller` from `lambder` rather than `lambder/client`.
1843
+ - **`eslint` runs again.** The config extended `standard-with-typescript`,
1844
+ which was never installed, so `npm run lint` failed to start. It now uses
1845
+ the `@typescript-eslint` packages the repo already carries, with the
1846
+ generics-heavy rules (`no-explicit-any`, `{}` as a generic default, unused
1847
+ handler arguments) relaxed deliberately and the rest of
1848
+ `eslint:recommended` on. The tree lints clean.
1849
+
1850
+ ### Fixed
1851
+
1852
+ - `res.file` was documented as taking a `fallback` option, which 4.5.1
1853
+ removed.
1854
+ - A handful of dead imports in the test suite, and three type-level
1855
+ assertions that now carry the codebase's `_` prefix for declarations that
1856
+ exist to be typechecked rather than used.
1857
+
1858
+ ## [4.9.1] - 2026-09-10
1859
+
1860
+ ### Added
1861
+
1862
+ - **Mandatory authorization on public APIs.** `requirePublicApiGuards: true` at
1863
+ creation makes `guards` a required field of every `addApi`, the same way
1864
+ `requireSessionApiGuards` does for session APIs, at the type level and at
1865
+ registration. Public APIs are open by default and that stays the default;
1866
+ what turning it on buys is that a public endpoint's openness becomes a
1867
+ written decision rather than an omission. The ones anybody may call declare a
1868
+ named no-op guard carrying the reason
1869
+ (`guards: { open: "Static strings already in the bundle." }`), the ones that
1870
+ authorize their caller some other way (a signature, a device secret, a
1871
+ one-shot token) name where that happens, and one grep over the guard names
1872
+ then lists every public door and why it is open. The two flags are
1873
+ independent, so an app can require either or both.
1874
+
1875
+ ### Fixed
1876
+
1877
+ - **An empty `guards` option is refused.** `guards: {}` and `guards: []` were
1878
+ inhabited by the option type and passed the `require*ApiGuards` field check
1879
+ while normalizing to zero entries, so a declaration that authorized nothing
1880
+ satisfied a requirement that exists to make authorization explicit. Both are
1881
+ now compile errors (every form of the option is non-empty by construction)
1882
+ and a registration error for a plain-JS caller, whichever flag is on or off.
1883
+ Requiring the chosen key also rejects `guards: { theGuard: undefined }`,
1884
+ which an optional property accepted and which reached the guard's handler
1885
+ with an undefined param.
1886
+
1887
+ ## [4.8.1] - 2026-09-08
1888
+
1889
+ ### Added
1890
+
1891
+ - **Grouped cache keys.** `LambderDdbCache` keys may be a `{ pk, sk }` pair
1892
+ instead of a string, which stores related entries in one partition:
1893
+ `{ pk: "division:ist-34", sk: "1700:1800" }` keeps every cached window of one
1894
+ division together. `deletePartition(pk)` then drops the whole group without
1895
+ knowing which sort keys exist, and `listSortKeys(pk, { prefix, limit })`
1896
+ reads back what is currently cached under it. The group invalidation a cache
1897
+ of derived, per-entity values needs, in place of remembering every key ever
1898
+ written or waiting out the TTL. Reads stay one request, and the memory layer,
1899
+ single-flight and fill lease stay per entry. Only the `pk` part is hashed, so
1900
+ the sort key is queryable; a caller's `#` is escaped rather than refused
1901
+ (`~` to `~0`, `#` to `~1`). Plain string keys keep their exact item layout,
1902
+ so a live table needs no migration and both forms can share a partition.
1903
+ - **`guards` on the API contract.** Each contract entry now carries the `guards`
1904
+ option exactly as declared (`ApiContractType["getUser"]["guards"]` is the
1905
+ literal `{ readonly orgPermission: "USERS.MANAGE" }`), so a client-side map
1906
+ of what an API needs can be pinned to the server's own declaration with
1907
+ `satisfies` instead of a test that reads the server source.
1908
+
1909
+ ## [4.7.3] - 2026-09-08
1910
+
1911
+ ### Fixed
1912
+
1913
+ - `use()` listed one generic short of the class's parameter list, so the
1914
+ missing one fell back to its default and an instance carrying a non-default
1915
+ value became unassignable to its own plugins (`requireSessionApiGuards` did
1916
+ exactly that in 4.7.1).
1917
+
1918
+ ## [4.7.1] - 2026-09-08
1919
+
1920
+ ### Added
1921
+
1922
+ - **Compressed request payloads.** `requestCompression` on `LambderCaller`
1923
+ gzips the payload of any call whose JSON reaches a threshold (`true` is
1924
+ `{ minBytes: 4096 }`), sending it as `payloadGz` beside its byte length
1925
+ instead of `payload` whenever that is actually smaller; the server restores
1926
+ it before rate-limit key slices, guards and input validation, so no call
1927
+ site, handler or schema changes. Chiefly a way to fit a large payload under
1928
+ Lambda's ~6MB invoke cap, which applies to the compressed bytes. The envelope
1929
+ stays `application/json` with its routing fields in plain text, so gateways,
1930
+ CDNs and mocks are unaffected. `maxRequestPayloadBytes` (default 20MB) bounds
1931
+ what a body may expand to.
1932
+ - **Mandatory authorization on session APIs.** `requireSessionApiGuards: true`
1933
+ at creation makes `guards` a required field of every `addSessionApi`, at the
1934
+ type level (a missing declaration is a compile error at the registration
1935
+ site) and at registration (a plain-JS caller throws). An API the session
1936
+ alone authorizes declares a named no-op session guard, so every opt-out is
1937
+ explicit and one grep lists them all. The class of defect this closes is
1938
+ "the guard existed and the endpoint did not use it", which review discipline
1939
+ does not catch as a surface grows.
1940
+ - **Brotli responses.** Response compression now negotiates `br` before `gzip`,
1941
+ smaller at comparable speed (15-25% on markup and prose, substantially more
1942
+ on the repetitive record lists API responses tend to be), which is bandwidth
1943
+ saved and headroom gained against the ~6MB response cap.
1944
+ `compression: { encodings: ["gzip"] }` opts out, `quality` (default 5) tunes
1945
+ it.
1946
+
1947
+ ### Changed
1948
+
1949
+ - **One compression codec.** `shared/LambderCompressionCodec.ts` is now the only
1950
+ place Lambder compresses or decompresses bytes. Its
1951
+ `restoreBoundedText(bytes, declaredBytes, encoding)` carries the guarantee
1952
+ every compressed value in Lambder depends on, at rest and on the wire: the
1953
+ declared UTF-8 byte length bounds the decompression AND must match the result
1954
+ exactly, so a truncated, tampered or endlessly-expanding input fails instead
1955
+ of decoding to something merely plausible. Compression is split across three
1956
+ modules by what each one needs: the codec (zlib), the option and its resolver
1957
+ (pure, so the browser entry can resolve the caller's option), and the request
1958
+ payload format (the browser's `CompressionStream`).
1959
+ - **One compression option, now everywhere.** The HTTP response option and the
1960
+ new request option resolve through the same `resolveCompressionOption` the
1961
+ DynamoDB stores and sessions use, and every site's option is the one generic
1962
+ `LambderCompressionOption<Settings>`. Same vocabulary at every site (`true`
1963
+ for that site's defaults, `false` for off, an object to override, `minBytes`
1964
+ as the threshold, `quality` as the Brotli quality, `encodings` as the
1965
+ negotiation order), same `Settings | null` resolved shape, and the same
1966
+ startup validation: `compression: { quality: 99 }` or `{ encodings: [] }` on
1967
+ a response is now a construction error instead of being silently ignored, and
1968
+ a field set to `undefined` keeps its default.
1969
+
1970
+ ### Removed
1971
+
1972
+ - `stores/LambderDdbCompression.ts`, retired into the modules above.
1973
+
1974
+ ## [4.6.2] - 2026-09-07
1975
+
1976
+ ### Changed
1977
+
1978
+ - **`zod` and the AWS SDK clients are optional peer dependencies** rather than
1979
+ dependencies, so installing lambder never drags them into a tree that has no
1980
+ use for them: a frontend importing only `lambder/client` skips the ~21MB SDK
1981
+ entirely, and a Lambda deployment package does not ship a second copy of what
1982
+ the runtime already provides. Install whatever the code you actually import
1983
+ needs; see the peer dependency table in the README.
1984
+
1985
+ ## [4.6.1] - 2026-09-07
1986
+
1987
+ ### Added
1988
+
1989
+ - **Cookies as a first-class concern.** `res.setCookie(name, value, options)`
1990
+ and `res.clearCookie(name, options)` serialize Set-Cookie headers through the
1991
+ `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form
1992
+ `domain` resolves against the request hostname, the same option the session
1993
+ takes), replacing hand-built header strings; `serializeCookie` and
1994
+ `serializeClearCookie` are exported for code holding a response.
1995
+ `ctx.cookieList` keeps every value a cookie name arrived with beside the
1996
+ first-wins `ctx.cookie`.
1997
+
1998
+ ### Fixed
1999
+
2000
+ - **Session cookie scope changes heal.** A cookie's identity is
2001
+ (name, domain, path), so changing the session's `cookie.domain` or `path` on
2002
+ a live deployment leaves the old copy in every browser beside the new one,
2003
+ and a whole-header parse silently picks whichever the browser lists first.
2004
+ The controller now tries every copy of the session cookie (record and CSRF
2005
+ pairing checked per copy), logs the ambiguity, and evicts the stale host-only
2006
+ twin from the response, so a migrated browser recovers on its first request
2007
+ instead of answering `sessionExpired` until the old cookie expires.
2008
+
2009
+ ## [4.5.1] - 2026-09-07
2010
+
2011
+ ### Added
2012
+
2013
+ - **`files` at creation.** One `LambderFileSource` configured once:
2014
+ `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for
2015
+ the folder bundled with the deployment, `new LambderS3FileSource({...})` for
2016
+ S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader
2017
+ over it (`lambder.files`): path rule, in-memory file cache and
2018
+ compiled-template cache in one place, shared by `servePublicFiles`,
2019
+ `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a
2020
+ bucket serves its index.html and templates from the bucket too, cached the
2021
+ same way as its assets. The cache is tuned or disabled beside the source,
2022
+ `files: { source, memoryCache }`.
2023
+
2024
+ ### Removed
2025
+
2026
+ - `publicPath` at creation and `servePublicFiles({ source })`, both replaced by
2027
+ `files`; `memoryCache` leaves `servePublicFiles` for the same reason.
2028
+ - `res.file`'s SPA-era `fallback` option, which the fallback chain replaced.
2029
+
2030
+ ## [4.4.1] - 2026-09-07
2031
+
2032
+ ### Added
2033
+
2034
+ - **`guardInputsProvider`** on `LambderCaller`: supply guardInput-mode guard
2035
+ values for every call from one place (the organization the UI is on, a device
2036
+ token) instead of at each call site; per-call `guardInputs` merge on top.
2037
+ Name the covered guards in the caller's second type parameter,
2038
+ `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider, ... })`:
2039
+ calls to APIs whose guardInput guards are all covered no longer require the
2040
+ options argument, uncovered ones (a Turnstile token) still do, and naming
2041
+ guards makes the provider itself mandatory.
2042
+ - **Public file sources.** `servePublicFiles({ source })` serves from any
2043
+ `LambderPublicFileSource`: `LambderLocalFileSource` (a folder; the default),
2044
+ `LambderS3FileSource` (S3, or Cloudflare R2 and other S3-compatible stores
2045
+ via `clientConfig.endpoint`; `@aws-sdk/client-s3` is an optional peer
2046
+ dependency loaded on first read), or your own `{ read(relativePath) }`. The
2047
+ handler's traversal check, memory cache, mime fallback from the extension,
2048
+ Cache-Control, ETag and compression apply to every source. The `cacheControl`
2049
+ callback receives the relative file path.
2050
+ - **`expireSessionDataAllByKey(sessionKey)`** on the session manager and
2051
+ controller: marks the data of every session of a subject stale, so each
2052
+ renews via `dataRefresh` on its next read. The way to apply a role or
2053
+ permission change to a user immediately, without logging them out
2054
+ (`deleteSessionAllByKey`) and without waiting for the data TTL.
2055
+
2056
+ ## [4.3.2] - 2026-09-07
2057
+
2058
+ ### Changed
2059
+
2060
+ - **One compression option everywhere.** `LambderDdbCache`,
2061
+ `LambderDdbIdempotency` and sessions take the same `compression` option
2062
+ (`true` for that store's defaults, `false` for off, `{ minBytes, quality }`
2063
+ to override), resolved by one shared function, and each store records a
2064
+ value's encoding so the option can be switched on or off on a live table.
2065
+ Defaults keep the previous behavior: the cache compresses everything, the
2066
+ idempotency store from 1KB. HTTP `compression` accepts `true` as
2067
+ `{ minBytes: 860 }`.
2068
+
2069
+ ### Removed
2070
+
2071
+ - `compressionQuality` on the cache and idempotency store, replaced by
2072
+ `compression: { quality }`.
2073
+
2074
+ ## [4.3.1] - 2026-09-07
2075
+
2076
+ ### Added
2077
+
2078
+ - **Compressed sessions.** `session.data` is stored Brotli-compressed by
2079
+ default, as `dataBr` + `dataBytes` on the record, the same scheme
2080
+ `LambderDdbCache` and `LambderDdbIdempotency` use (one shared
2081
+ implementation). A session that caches roles, permissions or product lists
2082
+ shrinks 2-3x and stays within one DynamoDB read unit for longer.
2083
+ `session.compression` is `true` by default (the same as `{ minBytes: 0 }`:
2084
+ every record compressed); `false` turns it off and `{ minBytes }` compresses
2085
+ only from that JSON size. Records written under either setting read back, so
2086
+ it can be switched on or off on a live table.
2087
+
2088
+ ## [4.2.3] - 2026-09-06
2089
+
2090
+ ### Added
2091
+
2092
+ - **One refusal shape, with codes.** `LambderRefusalMessage` gained an optional
2093
+ machine-readable `code` (`refuse(content, { code })`), so clients branch and
2094
+ translate on an identifier instead of string-matching prose. Every refusal
2095
+ the framework itself authors (rate limit 429, idempotency 409 and 400,
2096
+ unknown API) is a `LambderRefusalMessage` stamped with a
2097
+ `LAMBDER_REFUSAL_CODES` constant under the reserved `lambder/` prefix; a
2098
+ rate-limit policy's own `errorMessage` (typed as a refusal message) inherits
2099
+ `lambder/rate-limited` unless it sets a code.
2100
+
2101
+ ## [4.2.1] - 2026-09-06
2102
+
2103
+ ### Added
2104
+
2105
+ - **Rate-limit budgets.** A policy's `budget` is `"perApi"` (default: each
2106
+ referencing API gets its own counter, so the numbers are a per-API ceiling
2107
+ and three APIs on a 60/min policy allow one IP 180/min in total) or
2108
+ `"perPolicy"` (one counter shared by every API referencing the policy). The
2109
+ policy is the group, and two separate shared budgets are two policies.
2110
+ - **Per-API tuning.** The `rateLimit` option gained a map form like guards,
2111
+ `rateLimit: { lookupPerIp: { perMin: 20 } }`, which merges window overrides
2112
+ over a perApi policy's own (a tighter burst keeps the policy's daily cap).
2113
+ Overriding the windows of a perPolicy policy is a startup error;
2114
+ `errorMessage` is overridable on either.
2115
+ - **Retry-After.** A 429 carries the exceeded window's reset as a `Retry-After`
2116
+ header (CORS exposes it by default via the new `exposeHeaders` option),
2117
+ `LambderCaller` failure outcomes surface it as `retryAfterSeconds`,
2118
+ `LambderDdbRateLimiter.isRateLimited()` answers
2119
+ `false | { window, limit, resetAt }`, and `LambderApiError` and `refuse()`
2120
+ accept `headers`.
2121
+
2122
+ ### Changed
2123
+
2124
+ - **One validation path.** Preflight slices (guard `apiInput` and `guardInput`,
2125
+ rate-limit `apiInput` keys) answer through
2126
+ `setApiInputValidationErrorHandler` exactly like the API's own schema.
2127
+
2128
+ ## [4.1.1] - 2026-09-06
2129
+
2130
+ ### Changed
2131
+
2132
+ - **Configuration at creation.** `initLambder<SessionData>().create({...})`
2133
+ takes the WHOLE configuration (serving options, session, cors, rate limits,
2134
+ guards, idempotency) in one declaration, so nothing can be half-configured or
2135
+ wired in the wrong order, and api modules annotate with `typeof lambderApp`
2136
+ derived from the real instance.
2137
+
2138
+ ### Removed
2139
+
2140
+ - The enable/define chain methods `enableApiRateLimits`,
2141
+ `enableApiIdempotency` and `defineApiGuards`, in favor of the `rateLimits`,
2142
+ `idempotency` and `guards` options of `create()`.
2143
+
2144
+ ## [4.0.1] - 2026-09-06
2145
+
2146
+ ### Added
2147
+
2148
+ - **Declarative auth as guards.** Guards take per-API params
2149
+ (`guards: { orgPermission: "SOME.PERMISSION" }`), can require a session
2150
+ (`session: true`, compile-checked), and RETURN typed values that land on the
2151
+ handler's `ctx.guardData[name]`. Together with the apiInput/guardInput input
2152
+ modes, permission checks and device auth become registration-time
2153
+ declarations instead of per-handler boilerplate.
2154
+ - **Three package entry points.** `lambder` (server), `lambder/client`
2155
+ (browser-safe by construction: no AWS SDK, no Node built-ins),
2156
+ `lambder/testing` (`LambderMSW`); sources organized into core, policies,
2157
+ session, stores, client and shared.
2158
+ - **`LambderCaller.createIdempotencyKeyScope()`** for one self-rotating key per
2159
+ logical operation.
2160
+
2161
+ ### Changed
2162
+
2163
+ - **Hardened policy layer.** Rate-limit policies can share one counter across
2164
+ APIs (now `budget: "perPolicy"`); idempotency replays answer before rate
2165
+ limits, survive client IP changes (key-scoped for public APIs, 16-char
2166
+ minimum keys), store full response headers, refuse to store Set-Cookie
2167
+ responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay
2168
+ budget applies to compressed bytes.
2169
+ - **Secrets hashed at rest.** Session records store only sha256 hashes of the
2170
+ bearer secrets, so a session-table read yields no usable cookies;
2171
+ `LambderSessionReadError` keeps a DynamoDB blip from reading as a logout.
2172
+ - Fail-open rate limiting logs its passes.
2173
+ - `LambderDdbIdempotency.complete()` answers `"stored" | "too-large" | "lost"`.
2174
+ - Idempotency keys must be 16-200 characters.
2175
+
2176
+ ### Removed
2177
+
2178
+ - `LambderCaller.apiRaw()`; use `apiOutcome()`, whose failure outcomes carry
2179
+ the envelope on `response`.
2180
+ - The `multiValueHeaders` alias on `res.raw()`; use `headers`.
2181
+
2182
+ ### Breaking
2183
+
2184
+ Upgrading from 3.x:
2185
+
2186
+ - Configuration moved entirely to creation, removing `enableCors`,
2187
+ `enableDdbSession`, `setSessionCookieKey`, `enableApiRateLimits`,
2188
+ `enableApiIdempotency` and `defineApiGuards` in favor of the `cors`,
2189
+ `session`, `rateLimits`, `guards` and `idempotency` options of
2190
+ `initLambder().create({...})`. (The chain methods were removed in 4.1.1.)
2191
+ - Session records are reshaped (hashes at rest); live sessions invalidate once
2192
+ on upgrade and clients just re-login.
2193
+ - The manager-level `createSession` and `regenerateSession` return
2194
+ `LambderCreatedSession` (`{ session, sessionToken, csrfToken }`); the
2195
+ controller API is unchanged.
2196
+ - `LambderMSW` moved from the root entry to `lambder/testing`.
2197
+ - `LambderCaller.apiRaw()`, the `multiValueHeaders` alias, and the old
2198
+ idempotency `complete()` return shape are gone (see Removed and Changed).
2199
+
2200
+ ## [3.8.1] - 2026-09-06
2201
+
2202
+ ### Added
2203
+
2204
+ - Guards and custom rate-limit keys gained two typed input modes: **apiInput**
2205
+ (checks a slice of the API's own payload, declarable only where the schema
2206
+ carries those fields) and **guardInput** (a separate client-sent
2207
+ `guardInputs` channel the contract makes mandatory at the call site).
2208
+
2209
+ ## [3.7.1] - 2026-09-06
2210
+
2211
+ ### Added
2212
+
2213
+ - Guards and custom rate-limit keys became payload-sliced
2214
+ (`{ input, handler }`): the slice is validated before the handler runs, typed
2215
+ in the handler, and force-merged into the contract input.
2216
+
2217
+ ## [3.6.1] - 2026-09-06
2218
+
2219
+ ### Added
2220
+
2221
+ - **`refuse()`** and the standard `LambderRefusalMessage` shape: a one-liner
2222
+ callable from anywhere in an API call's stack, with never-return narrowing,
2223
+ over `LambderApiError`.
2224
+
2225
+ ## [3.5.2] - 2026-09-06
2226
+
2227
+ ### Added
2228
+
2229
+ - **Typed API refusals** with `LambderApiError`, so a refusal never pollutes
2230
+ crash logging and clients get a parseable response.
2231
+ - **Caller outcomes and timeouts**: `apiOutcome()` resolves to a discriminated
2232
+ union instead of collapsing every failure to `null`, and `timeoutMs` aborts a
2233
+ call in the constructor or per call.
2234
+ - **Declarative per-API policies**: rate limits, guards and idempotency,
2235
+ including `LambderDdbIdempotency`.
2236
+
2237
+ ## [3.4.2] - 2026-09-06
2238
+
2239
+ ### Added
2240
+
2241
+ - **`dataRefresh`** on the session: session data derived from external state
2242
+ (roles, permissions, feature flags) gets a shelf life, renewed in place on
2243
+ the same record past its `ttlSeconds` by an app callback, so changes reach
2244
+ every live session without a mass invalidation. Returning `null` ends the
2245
+ session; a thrown error surfaces as `LambderSessionDataRefreshError` and
2246
+ leaves the session untouched.
2247
+
2248
+ ## [3.3.3] - 2026-08-26
2249
+
2250
+ ### Added
2251
+
2252
+ - **`LambderDdbRateLimiter`**, a standalone DynamoDB fixed-window rate limiter.
2253
+
2254
+ ## [3.3.1] - 2026-08-26
2255
+
2256
+ ### Added
2257
+
2258
+ - Session cookie `Domain` may be resolved per request host, for one deployment
2259
+ serving several apex domains.
2260
+
2261
+ ## [3.3.0] - 2026-08-16
2262
+
2263
+ ### Changed
2264
+
2265
+ - **`serveIndexHtml` stopped guessing whether a path is a file.**
2266
+ `servePublicFiles` has already served every real file by then, so anything
2267
+ reaching this slot is an app route, dotted ones included. `skipFilePaths: true`
2268
+ opts back into 404ing paths whose last segment contains a dot.
2269
+
2270
+ ## [3.2.6] - 2026-08-03
2271
+
2272
+ ### Fixed
2273
+
2274
+ - Payload v2: the named stage prefix is stripped from `rawPath`, for parity
2275
+ with the v1 path.
2276
+
2277
+ ## [3.2.5] - 2026-08-03
2278
+
2279
+ ### Fixed
2280
+
2281
+ - Payload v2 hardening: format-aware error-path responses.
2282
+
2283
+ ## [3.2.1] - 2026-08-03
2284
+
2285
+ ### Added
2286
+
2287
+ - **`createLambderI18n`**: typed translations with enforced and optional
2288
+ languages, component-level extension, runtime dictionaries, and automatic
2289
+ language detection. Isomorphic and dependency-free.
2290
+
2291
+ ## [3.1.0] - 2026-08-02
2292
+
2293
+ ### Added
2294
+
2295
+ - **`LambderDdbCache`**, a standalone Brotli-compressed DynamoDB cache with an
2296
+ in-memory LRU layer, single-flight deduplication and a fill lease.
2297
+
2298
+ ## [3.0.0] - 2026-08-02
2299
+
2300
+ ### Added
2301
+
2302
+ - Public file serving with `servePublicFiles()` and `serveIndexHtml()`.
2303
+ - Unified `addAction()` for non-HTTP triggers (EventBridge, SQS, custom
2304
+ events), dispatched by the same handler.
2305
+ - Automatic gzip and ETag on the response pipeline.
2306
+ - Thrown responses with a real `die`.
2307
+ - The comment-based `LambderTemplatingEngine` and type-safe `html`/`xml`
2308
+ tagged templates.
2309
+ - API Gateway HTTP API (payload v2) and Lambda Function URL support, detected
2310
+ per event.
2311
+ - Typed AWS contracts and session hardening.
2312
+
2313
+ ## Earlier versions
2314
+
2315
+ 2.x and earlier are documented in the git history.
2316
+