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