lambder 6.0.1 → 7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /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
|
+
|