lambder 6.0.2 → 7.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +21 -19
- 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
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
5
|
-
/**
|
|
6
|
-
* Session compression defaults: every record compressed (see
|
|
7
|
-
* LambderCompressionOption for the option's shape and toggle semantics).
|
|
8
|
-
* A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
9
|
-
* beside its byte length (`dataBytes`), the scheme LambderDdbCache and
|
|
10
|
-
* LambderDdbIdempotency use; below minBytes, or with compression off, the
|
|
11
|
-
* record keeps a plain `data` attribute.
|
|
12
|
-
*/
|
|
13
|
-
const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
1
|
+
import { LambderWebCrypto } from "./LambderSessionCrypto.js";
|
|
2
|
+
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
3
|
+
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
14
4
|
/**
|
|
15
5
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
16
6
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -19,157 +9,162 @@ const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
|
19
9
|
*/
|
|
20
10
|
export class LambderSessionDataRefreshError extends Error {
|
|
21
11
|
constructor(cause) {
|
|
22
|
-
super(`Session dataRefresh failed: ${cause
|
|
12
|
+
super(`Session dataRefresh failed: ${coerceToError(cause).message}`, { cause });
|
|
23
13
|
this.name = "LambderSessionDataRefreshError";
|
|
24
14
|
}
|
|
25
15
|
}
|
|
26
16
|
/**
|
|
27
|
-
* Wraps
|
|
17
|
+
* Wraps store failures during a session read so they stay distinguishable
|
|
28
18
|
* from "no session": fetchSessionIfExists() swallows missing or invalid
|
|
29
|
-
* sessions but rethrows this. Without the distinction a transient
|
|
19
|
+
* sessions but rethrows this. Without the distinction a transient store
|
|
30
20
|
* error would answer sessionExpired, and the caller would then clear the
|
|
31
21
|
* client's session cookies: an infra blip forcing a real logout.
|
|
32
22
|
*/
|
|
33
23
|
export class LambderSessionReadError extends Error {
|
|
34
24
|
constructor(cause) {
|
|
35
|
-
super(`Session read failed: ${cause
|
|
25
|
+
super(`Session read failed: ${coerceToError(cause).message}`, { cause });
|
|
36
26
|
this.name = "LambderSessionReadError";
|
|
37
27
|
}
|
|
38
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The longest either half of a session token may be. A minted token is two
|
|
31
|
+
* 64-character hex halves, so this is sixteen times the room the default
|
|
32
|
+
* crypto needs, which leaves a custom LambderSessionCrypto free to mint
|
|
33
|
+
* longer hashes or secrets without this file knowing its lengths, and leaves
|
|
34
|
+
* LambderPlainSessionCrypto, which hex-encodes its input rather than hashing
|
|
35
|
+
* it, room for a session key and salt of several hundred characters.
|
|
36
|
+
* Deriving the exact length from the crypto instead would tie the check to
|
|
37
|
+
* whichever crypto is configured today and reject every session minted by
|
|
38
|
+
* the previous one, and LambderSessionCrypto exposes no length to read.
|
|
39
|
+
*
|
|
40
|
+
* The number that matters is the one this is comfortably under: DynamoDB
|
|
41
|
+
* refuses a partition key over 2048 bytes, and a cookie may carry 4000, so
|
|
42
|
+
* without a bound a planted oversized cookie reaches the store as a key it
|
|
43
|
+
* cannot take, the read throws, and a live session beside it answers 500 on
|
|
44
|
+
* every request.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_SESSION_TOKEN_HALF_CHARS = 1024;
|
|
47
|
+
/** Hex in either case: what every LambderSessionCrypto's sha256Hex and randomHex produce, whichever case a custom one picks. */
|
|
48
|
+
const SESSION_TOKEN_HALF_PATTERN = /^[0-9a-fA-F]+$/;
|
|
49
|
+
/**
|
|
50
|
+
* Whether a string has the shape a minted session token has:
|
|
51
|
+
* `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
|
|
52
|
+
*
|
|
53
|
+
* It lives beside the code that mints and splits that format rather than
|
|
54
|
+
* beside the cookie reader, so the ceiling is a property of the model and
|
|
55
|
+
* anyone handing lookupSession a token they did not mint can ask the same
|
|
56
|
+
* question. The session controller asks it of every candidate cookie before
|
|
57
|
+
* any store read, so a malformed candidate is "no session" and never a read
|
|
58
|
+
* error. Nothing a browser legitimately holds fails it, because the only
|
|
59
|
+
* writer of these cookies is the code that mints them.
|
|
60
|
+
*/
|
|
61
|
+
export const isMintedSessionToken = (token) => {
|
|
62
|
+
const halves = token.split(":");
|
|
63
|
+
if (halves.length !== 2)
|
|
64
|
+
return false;
|
|
65
|
+
return halves.every((half) => half.length > 0 && half.length <= MAX_SESSION_TOKEN_HALF_CHARS && SESSION_TOKEN_HALF_PATTERN.test(half));
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The session model: how a session is minted, what its tokens look like,
|
|
69
|
+
* how it is found from a presented token, when it expires, when its data is
|
|
70
|
+
* renewed, and how it is rotated or ended. Storage and cryptography are
|
|
71
|
+
* injected, so the same class runs on Lambda over DynamoDB, in a test over
|
|
72
|
+
* a Map, and in a browser under the mock runtime.
|
|
73
|
+
*
|
|
74
|
+
* Token format: the session cookie carries `sessionKeyHash:secret`, where
|
|
75
|
+
* the hash locates the partition and only the secret's hash is stored as
|
|
76
|
+
* the sort key, so the lookup itself proves possession of the raw secret
|
|
77
|
+
* and a store read yields no usable token. The CSRF token is a second
|
|
78
|
+
* random value the client sends back in the request body; its hash is
|
|
79
|
+
* stored too.
|
|
80
|
+
*/
|
|
39
81
|
export default class LambderSessionManager {
|
|
40
|
-
|
|
82
|
+
store;
|
|
41
83
|
sessionSalt;
|
|
42
|
-
partitionKey;
|
|
43
|
-
sortKey;
|
|
44
|
-
tableRegion;
|
|
45
|
-
/** The document client and the SDK it came from, created the first time the table is touched. */
|
|
46
|
-
readyPromise;
|
|
47
84
|
enableSlidingExpiration;
|
|
48
85
|
slidingWriteIntervalSeconds;
|
|
49
86
|
dataRefresh;
|
|
50
|
-
|
|
51
|
-
constructor({
|
|
52
|
-
this.
|
|
87
|
+
crypto;
|
|
88
|
+
constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, crypto, }) {
|
|
89
|
+
this.store = store;
|
|
53
90
|
this.sessionSalt = sessionSalt;
|
|
54
|
-
this.partitionKey = partitionKey;
|
|
55
|
-
this.sortKey = sortKey;
|
|
56
91
|
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
57
|
-
this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds
|
|
92
|
+
this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds === undefined
|
|
93
|
+
? null
|
|
94
|
+
: assertPositiveInteger(slidingWriteIntervalSeconds, "session.slidingWriteIntervalSeconds");
|
|
95
|
+
if (dataRefresh)
|
|
96
|
+
assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
|
|
58
97
|
this.dataRefresh = dataRefresh ?? null;
|
|
59
|
-
this.
|
|
60
|
-
this.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
98
|
+
this.crypto = crypto ?? new LambderWebCrypto();
|
|
99
|
+
if (!this.crypto.isCryptographic && !store.isMemoryOnly) {
|
|
100
|
+
throw new Error("Lambder: this session crypto does not hash and does not draw cryptographically random bytes, " +
|
|
101
|
+
"so it may only sit in front of a store that dies with the process. Over a persistent store every " +
|
|
102
|
+
"record would be a usable credential and the sessionSalt would be readable from it.");
|
|
103
|
+
}
|
|
104
|
+
if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
|
|
105
|
+
throw new Error("Lambder: session sessionSalt is empty. It salts the hash that partitions the store, so it has to be a real, stable secret.");
|
|
106
|
+
}
|
|
68
107
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The salted partition hash of a sessionKey: sha256 of the key followed
|
|
110
|
+
* by the salt, with NO separator between them.
|
|
111
|
+
*
|
|
112
|
+
* The missing separator is frozen by the wire guarantee, not chosen
|
|
113
|
+
* again here: every live session in every deployed table was partitioned
|
|
114
|
+
* under this exact string, so inserting a separator would relocate every
|
|
115
|
+
* partition key at once and read to everyone signed in as being logged
|
|
116
|
+
* out. What it costs is worth naming so nobody reintroduces it by
|
|
117
|
+
* accident: without a separator the split between key and salt is not
|
|
118
|
+
* recoverable from the string, so two deployments SHARING one table
|
|
119
|
+
* collide when the difference between their salts can be absorbed into a
|
|
120
|
+
* sessionKey ("ab" + "cd" and "a" + "bcd" hash alike). Two deployments
|
|
121
|
+
* over one table must therefore not stand in a prefix relationship over
|
|
122
|
+
* their salts. Separate tables, or salts that are independent random
|
|
123
|
+
* strings, both rule it out.
|
|
124
|
+
*/
|
|
125
|
+
sessionKeyHashOf(sessionKey) {
|
|
126
|
+
return this.crypto.sha256Hex(`${sessionKey}${this.sessionSalt}`);
|
|
73
127
|
}
|
|
74
128
|
/**
|
|
75
129
|
* At-rest hash for the bearer secrets (session sort-key secret, CSRF
|
|
76
130
|
* token). Fast unsalted sha256 is the right construction here: the
|
|
77
131
|
* inputs are 256-bit random values, so there is nothing to brute-force;
|
|
78
|
-
* hashing just ensures a leaked
|
|
132
|
+
* hashing just ensures a leaked store read yields no usable cookies.
|
|
79
133
|
*/
|
|
80
134
|
hashToken(value) {
|
|
81
|
-
return crypto.
|
|
82
|
-
}
|
|
83
|
-
constantTimeCompare(a, b) {
|
|
84
|
-
if (a.length !== b.length)
|
|
85
|
-
return false;
|
|
86
|
-
const bufferA = Buffer.from(a, 'utf8');
|
|
87
|
-
const bufferB = Buffer.from(b, 'utf8');
|
|
88
|
-
return crypto.timingSafeEqual(new Uint8Array(bufferA), new Uint8Array(bufferB));
|
|
89
|
-
}
|
|
90
|
-
async ddbGetItem(key) {
|
|
91
|
-
const { client, sdk } = await this.ready();
|
|
92
|
-
const response = await client.send(new sdk.GetCommand({ TableName: this.tableName, Key: key, ConsistentRead: true }));
|
|
93
|
-
if (response.Item)
|
|
94
|
-
return response.Item;
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
;
|
|
98
|
-
/**
|
|
99
|
-
* Persists a session record. With compression on, `data` is stored as
|
|
100
|
-
* Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
|
|
101
|
-
* once the JSON reaches minBytes; otherwise it stays a plain attribute.
|
|
102
|
-
*/
|
|
103
|
-
async ddbPutItem(session) {
|
|
104
|
-
const { data, ...item } = session;
|
|
105
|
-
const raw = this.compression && Buffer.from(JSON.stringify(data), "utf8");
|
|
106
|
-
if (this.compression && raw && raw.byteLength >= this.compression.minBytes) {
|
|
107
|
-
item.dataBr = await compressText(raw, "br", this.compression.quality);
|
|
108
|
-
item.dataBytes = raw.byteLength;
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
item.data = data;
|
|
112
|
-
}
|
|
113
|
-
const { client, sdk } = await this.ready();
|
|
114
|
-
return await client.send(new sdk.PutCommand({ TableName: this.tableName, Item: item, }));
|
|
115
|
-
}
|
|
116
|
-
;
|
|
117
|
-
async ddbDeleteItem(key) {
|
|
118
|
-
const { client, sdk } = await this.ready();
|
|
119
|
-
return await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: key, }));
|
|
120
|
-
}
|
|
121
|
-
;
|
|
122
|
-
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
123
|
-
async ddbQueryAllByPartitionKey(partitionValue) {
|
|
124
|
-
const params = {
|
|
125
|
-
TableName: this.tableName,
|
|
126
|
-
KeyConditionExpression: "#pk = :pv",
|
|
127
|
-
ProjectionExpression: "#sk",
|
|
128
|
-
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
129
|
-
ExpressionAttributeValues: { ":pv": partitionValue },
|
|
130
|
-
};
|
|
131
|
-
const queryResults = [];
|
|
132
|
-
do {
|
|
133
|
-
const { client, sdk } = await this.ready();
|
|
134
|
-
const { Items, LastEvaluatedKey } = await client.send(new sdk.QueryCommand(params));
|
|
135
|
-
if (Items)
|
|
136
|
-
queryResults.push(...Items);
|
|
137
|
-
params.ExclusiveStartKey = LastEvaluatedKey;
|
|
138
|
-
if (typeof LastEvaluatedKey == "undefined")
|
|
139
|
-
return queryResults;
|
|
140
|
-
// eslint-disable-next-line no-constant-condition
|
|
141
|
-
} while (true);
|
|
142
|
-
}
|
|
143
|
-
;
|
|
144
|
-
async ddbDeleteAllByPartitionKey(partitionValue) {
|
|
145
|
-
const queryResults = await this.ddbQueryAllByPartitionKey(partitionValue);
|
|
146
|
-
for (const item of queryResults) {
|
|
147
|
-
const { client, sdk } = await this.ready();
|
|
148
|
-
await client.send(new sdk.DeleteCommand({
|
|
149
|
-
TableName: this.tableName,
|
|
150
|
-
Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] }
|
|
151
|
-
}));
|
|
152
|
-
}
|
|
135
|
+
return this.crypto.sha256Hex(value);
|
|
153
136
|
}
|
|
154
137
|
async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60, options) {
|
|
155
|
-
|
|
138
|
+
// Checked rather than trusted: the TTL becomes the record's expiresAt
|
|
139
|
+
// and the store's own expiry attribute, so a NaN or a fraction from an
|
|
140
|
+
// unparsed environment variable would write a record nothing ever
|
|
141
|
+
// retires and no read ever accepts.
|
|
142
|
+
assertPositiveInteger(ttlInSeconds, "createSession ttlInSeconds");
|
|
143
|
+
// Refused for the same reason as a NaN TTL: an empty sessionKey
|
|
144
|
+
// writes a record that lookupSession rejects on every read (a session
|
|
145
|
+
// has to name its subject), so the caller would be handed a
|
|
146
|
+
// valid-looking cookie pair for a session nobody can ever sign in
|
|
147
|
+
// with, and every request after it would read as a silent logout.
|
|
148
|
+
if (!sessionKey)
|
|
149
|
+
throw new Error("Lambder: createSession sessionKey is empty. It names the subject the session belongs to and partitions the store, so an empty one writes a record no read accepts.");
|
|
150
|
+
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
156
151
|
// The sort-key SECRET goes to the client; only its hash becomes the
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
const sessionToken = `${sessionKeyHash}:${
|
|
160
|
-
const csrfToken = crypto.
|
|
152
|
+
// store's range key, so the store never contains a usable token.
|
|
153
|
+
const secret = await this.crypto.randomHex(32);
|
|
154
|
+
const sessionToken = `${sessionKeyHash}:${secret}`;
|
|
155
|
+
const csrfToken = await this.crypto.randomHex(32);
|
|
161
156
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
162
157
|
const lastAccessedAt = createdAt;
|
|
163
158
|
const expiresAt = Number(createdAt) + Number(ttlInSeconds);
|
|
164
159
|
const session = {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
csrfTokenHash: this.hashToken(csrfToken),
|
|
160
|
+
sessionKeyHash,
|
|
161
|
+
secretHash: await this.hashToken(secret),
|
|
162
|
+
csrfTokenHash: await this.hashToken(csrfToken),
|
|
168
163
|
sessionKey, data,
|
|
169
164
|
createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
|
|
170
165
|
...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
|
|
171
166
|
};
|
|
172
|
-
await this.
|
|
167
|
+
await this.store.put(session);
|
|
173
168
|
return { session, sessionToken, csrfToken };
|
|
174
169
|
}
|
|
175
170
|
async updateSessionData(session, newData) {
|
|
@@ -185,14 +180,23 @@ export default class LambderSessionManager {
|
|
|
185
180
|
if (this.enableSlidingExpiration) {
|
|
186
181
|
session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
|
|
187
182
|
}
|
|
188
|
-
await this.
|
|
183
|
+
await this.store.put(session);
|
|
189
184
|
return session;
|
|
190
185
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
186
|
+
/**
|
|
187
|
+
* The record a token names, read and structurally checked, with nothing
|
|
188
|
+
* renewed. Split out of getSession so a caller weighing several candidate
|
|
189
|
+
* cookies can decide which one is this visitor's BEFORE anything is
|
|
190
|
+
* written on their behalf: renewing slides an expiry and may run the
|
|
191
|
+
* app's dataRefresh callback, and a cookie a sibling host planted must
|
|
192
|
+
* not get either from the victim's traffic.
|
|
193
|
+
*/
|
|
194
|
+
async lookupSession(sessionToken) {
|
|
195
|
+
const [sessionKeyHash, secret] = sessionToken.split(":");
|
|
196
|
+
if (!sessionKeyHash || !secret)
|
|
194
197
|
return null;
|
|
195
|
-
|
|
198
|
+
const secretHash = await this.hashToken(secret);
|
|
199
|
+
// A store read failure propagates typed: null means "no such
|
|
196
200
|
// session", which callers translate to sessionExpired, and the caller
|
|
197
201
|
// then clears the client's session cookies. A transient infra error
|
|
198
202
|
// must surface as a 500, not force a logout.
|
|
@@ -200,32 +204,45 @@ export default class LambderSessionManager {
|
|
|
200
204
|
try {
|
|
201
205
|
// The lookup itself proves possession of the raw secret: the
|
|
202
206
|
// range key is its hash, so only the true secret finds the item.
|
|
203
|
-
session = await this.
|
|
204
|
-
[this.partitionKey]: sessionKeyHash,
|
|
205
|
-
[this.sortKey]: this.hashToken(sessionSortKeySecret)
|
|
206
|
-
});
|
|
207
|
+
session = await this.store.get(sessionKeyHash, secretHash);
|
|
207
208
|
}
|
|
208
209
|
catch (err) {
|
|
209
210
|
throw new LambderSessionReadError(err);
|
|
210
211
|
}
|
|
211
212
|
if (!session)
|
|
212
213
|
return null;
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
214
|
+
// The record has to be the one that was asked for. A correct store
|
|
215
|
+
// answers with the item under the two keys it was given, and this is
|
|
216
|
+
// what a store that does not (a cache keyed loosely, a query that
|
|
217
|
+
// forgot its partition) runs into instead of handing back somebody
|
|
218
|
+
// else's session. The hashes are already in hand, so it costs a
|
|
219
|
+
// comparison and no hashing.
|
|
220
|
+
if (!this.crypto.constantTimeEqual(session.sessionKeyHash ?? "", sessionKeyHash))
|
|
221
|
+
return null;
|
|
222
|
+
if (!this.crypto.constantTimeEqual(session.secretHash ?? "", secretHash))
|
|
223
|
+
return null;
|
|
221
224
|
if (!session.csrfTokenHash)
|
|
222
225
|
return null;
|
|
223
226
|
if (!session.sessionKey)
|
|
224
227
|
return null;
|
|
225
228
|
if (!session.createdAt)
|
|
226
229
|
return null;
|
|
230
|
+
// Expiry is enforced here, on every read, because the store contract
|
|
231
|
+
// allows a record past its expiresAt: a DynamoDB TTL deletes within
|
|
232
|
+
// days rather than at the second, and a store over a plain table
|
|
233
|
+
// sweeps nothing at all.
|
|
227
234
|
if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
|
|
228
235
|
return null;
|
|
236
|
+
return session;
|
|
237
|
+
}
|
|
238
|
+
;
|
|
239
|
+
/**
|
|
240
|
+
* The renewal half of a session read: the dataRefresh callback once its
|
|
241
|
+
* shelf life has passed, and the sliding-expiration write. Returns null
|
|
242
|
+
* when a refresh says the session is over (a deleted or disabled login),
|
|
243
|
+
* which ends it the same way a missing record does.
|
|
244
|
+
*/
|
|
245
|
+
async renewSession(session) {
|
|
229
246
|
const now = Math.floor(Date.now() / 1000);
|
|
230
247
|
let needsWrite = false;
|
|
231
248
|
// Renew session.data once its shelf life has passed (opt-in
|
|
@@ -250,7 +267,7 @@ export default class LambderSessionManager {
|
|
|
250
267
|
needsWrite = true;
|
|
251
268
|
}
|
|
252
269
|
// Update last accessed time if sliding expiration is enabled.
|
|
253
|
-
// Throttled: skip the
|
|
270
|
+
// Throttled: skip the store write when the session was refreshed
|
|
254
271
|
// recently, to avoid a write on every request. A due data renewal
|
|
255
272
|
// above forces the write anyway, so both updates share one put.
|
|
256
273
|
if (this.enableSlidingExpiration) {
|
|
@@ -265,8 +282,16 @@ export default class LambderSessionManager {
|
|
|
265
282
|
if (needsWrite) {
|
|
266
283
|
// Wait for the update to ensure it persists before Lambda freezes.
|
|
267
284
|
// A failed put is not fatal: the data served is fresh, and an
|
|
268
|
-
// unpersisted renewal simply runs again on the next read.
|
|
269
|
-
|
|
285
|
+
// unpersisted renewal simply runs again on the next read. It is
|
|
286
|
+
// still logged, because a store that fails every renewal write
|
|
287
|
+
// means sliding expiration has quietly stopped working and every
|
|
288
|
+
// session now ends at its creation TTL, which otherwise shows up
|
|
289
|
+
// only as users being signed out sooner than the app promises.
|
|
290
|
+
// The reason only, never the record or the token: a log line is
|
|
291
|
+
// not the place for anything that identifies a session.
|
|
292
|
+
await this.store.put(session).catch((err) => {
|
|
293
|
+
console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
|
|
294
|
+
});
|
|
270
295
|
}
|
|
271
296
|
return session;
|
|
272
297
|
}
|
|
@@ -300,22 +325,29 @@ export default class LambderSessionManager {
|
|
|
300
325
|
if (this.enableSlidingExpiration) {
|
|
301
326
|
session.expiresAt = now + session.ttlInSeconds;
|
|
302
327
|
}
|
|
303
|
-
await this.
|
|
328
|
+
await this.store.put(session);
|
|
304
329
|
return session;
|
|
305
330
|
}
|
|
306
331
|
;
|
|
307
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Checks a record against the session token presented with it: the
|
|
334
|
+
* partition hash and the bearer secret the cookie carries, plus the
|
|
335
|
+
* structural checks and the expiry. This is the half a route needs, and
|
|
336
|
+
* the half lookupSession has already proved for a record it just found by
|
|
337
|
+
* that token's own hash, so the read path does not ask it again.
|
|
338
|
+
*/
|
|
339
|
+
async isSessionTokenValid(session, sessionToken) {
|
|
308
340
|
if (!session)
|
|
309
341
|
return false;
|
|
310
|
-
if (!sessionToken
|
|
342
|
+
if (!sessionToken)
|
|
311
343
|
return false;
|
|
312
344
|
// Presented raw secrets are checked against the stored hashes.
|
|
313
|
-
const [sessionKeyHash,
|
|
314
|
-
if (!sessionKeyHash || !
|
|
345
|
+
const [sessionKeyHash, secret] = sessionToken.split(":");
|
|
346
|
+
if (!sessionKeyHash || !secret)
|
|
315
347
|
return false;
|
|
316
|
-
if (!this.
|
|
348
|
+
if (!this.crypto.constantTimeEqual(session.sessionKeyHash, sessionKeyHash))
|
|
317
349
|
return false;
|
|
318
|
-
if (!this.
|
|
350
|
+
if (!this.crypto.constantTimeEqual(session.secretHash, await this.hashToken(secret)))
|
|
319
351
|
return false;
|
|
320
352
|
if (!session.csrfTokenHash)
|
|
321
353
|
return false;
|
|
@@ -325,24 +357,30 @@ export default class LambderSessionManager {
|
|
|
325
357
|
return false;
|
|
326
358
|
if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
|
|
327
359
|
return false;
|
|
328
|
-
if (!skipCsrfTokenCheck) {
|
|
329
|
-
if (!csrfToken || typeof csrfToken !== "string")
|
|
330
|
-
return false;
|
|
331
|
-
if (!this.constantTimeCompare(session.csrfTokenHash, this.hashToken(csrfToken)))
|
|
332
|
-
return false;
|
|
333
|
-
}
|
|
334
360
|
return true;
|
|
335
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Checks a record against the CSRF token the request posted: the other
|
|
364
|
+
* half, asked of an API call and not of a route. Separate methods rather
|
|
365
|
+
* than one with a skip flag, because a boolean at the call site says
|
|
366
|
+
* nothing about which half it turns off, and the two are asked in
|
|
367
|
+
* different places for different reasons.
|
|
368
|
+
*/
|
|
369
|
+
async isSessionCsrfTokenValid(session, csrfToken) {
|
|
370
|
+
if (!session?.csrfTokenHash)
|
|
371
|
+
return false;
|
|
372
|
+
if (!csrfToken)
|
|
373
|
+
return false;
|
|
374
|
+
return this.crypto.constantTimeEqual(session.csrfTokenHash, await this.hashToken(csrfToken));
|
|
375
|
+
}
|
|
336
376
|
async deleteSession(session) {
|
|
337
|
-
await this.
|
|
338
|
-
[this.partitionKey]: session[this.partitionKey],
|
|
339
|
-
[this.sortKey]: session[this.sortKey],
|
|
340
|
-
});
|
|
377
|
+
await this.store.delete(session.sessionKeyHash, session.secretHash);
|
|
341
378
|
return true;
|
|
342
379
|
}
|
|
343
380
|
;
|
|
381
|
+
/** Deletes every session that shares the record's subject: "log this subject out everywhere". */
|
|
344
382
|
async deleteSessionAll(session) {
|
|
345
|
-
await this.
|
|
383
|
+
await this.deleteAllUnder(session.sessionKeyHash);
|
|
346
384
|
return true;
|
|
347
385
|
}
|
|
348
386
|
;
|
|
@@ -352,41 +390,31 @@ export default class LambderSessionManager {
|
|
|
352
390
|
* session record.
|
|
353
391
|
*/
|
|
354
392
|
async deleteSessionAllByKey(sessionKey) {
|
|
355
|
-
await this.
|
|
393
|
+
await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
|
|
356
394
|
return true;
|
|
357
395
|
}
|
|
358
396
|
;
|
|
397
|
+
async deleteAllUnder(sessionKeyHash) {
|
|
398
|
+
for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
|
|
399
|
+
await this.store.delete(sessionKeyHash, secretHash);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
359
402
|
/**
|
|
360
403
|
* Marks the data of every session of the given sessionKey stale, so each
|
|
361
404
|
* renews via dataRefresh on its next read: "this subject's roles or
|
|
362
405
|
* permissions changed, apply it now", without logging the subject out
|
|
363
406
|
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
364
|
-
* dataExpiresAt only,
|
|
365
|
-
*
|
|
366
|
-
*
|
|
407
|
+
* dataExpiresAt only, on records that still exist, so it neither
|
|
408
|
+
* resurrects a session deleted in between nor overwrites a concurrent
|
|
409
|
+
* write. Requires dataRefresh to be configured.
|
|
367
410
|
*/
|
|
368
411
|
async expireSessionDataAllByKey(sessionKey) {
|
|
369
412
|
if (!this.dataRefresh)
|
|
370
413
|
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
371
|
-
const
|
|
414
|
+
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
372
415
|
const now = Math.floor(Date.now() / 1000);
|
|
373
|
-
for (const
|
|
374
|
-
|
|
375
|
-
const { client, sdk } = await this.ready();
|
|
376
|
-
await client.send(new sdk.UpdateCommand({
|
|
377
|
-
TableName: this.tableName,
|
|
378
|
-
Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
|
|
379
|
-
UpdateExpression: "SET #dataExpiresAt = :now",
|
|
380
|
-
ConditionExpression: "attribute_exists(#sk)",
|
|
381
|
-
ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
|
|
382
|
-
ExpressionAttributeValues: { ":now": now },
|
|
383
|
-
}));
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
// Deleted between the query and the update: nothing left to expire.
|
|
387
|
-
if (err.name !== "ConditionalCheckFailedException")
|
|
388
|
-
throw err;
|
|
389
|
-
}
|
|
416
|
+
for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
|
|
417
|
+
await this.store.markDataExpired(sessionKeyHash, secretHash, now);
|
|
390
418
|
}
|
|
391
419
|
return true;
|
|
392
420
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LambderI18n
|
|
2
|
+
* LambderI18n: standalone, framework-free, isomorphic typed translation module.
|
|
3
3
|
*
|
|
4
4
|
* Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
|
|
5
5
|
* safe to import in both lambda backends and frontend bundles.
|
|
@@ -21,7 +21,7 @@ export interface LambderLanguageMeta {
|
|
|
21
21
|
/** Extracts `{param}` placeholder names from a string literal type. */
|
|
22
22
|
export type LambderI18nExtractParams<S extends string> = S extends `${string}{${infer P}}${infer Rest}` ? P | LambderI18nExtractParams<Rest> : never;
|
|
23
23
|
/**
|
|
24
|
-
* Typed translator: `t(key)
|
|
24
|
+
* Typed translator: `t(key)`, and when the key's contract value contains
|
|
25
25
|
* `{tokens}`, a params object with exactly those tokens is required.
|
|
26
26
|
*/
|
|
27
27
|
export type LambderI18nTranslator<TContract extends Record<string, string>> = <K extends keyof TContract & string>(...args: LambderI18nExtractParams<TContract[K]> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K]>, string | number>]) => string;
|
|
@@ -60,7 +60,7 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
60
60
|
forLanguage(code: keyof TLanguages & string): LambderI18nTranslator<TContract>;
|
|
61
61
|
/**
|
|
62
62
|
* Strict extension: every language must provide every new key. Keys must
|
|
63
|
-
* be new
|
|
63
|
+
* be new: redeclaring a parent key is a compile-time and runtime error.
|
|
64
64
|
* Returns a new instance whose key space = parent keys + new keys.
|
|
65
65
|
*/
|
|
66
66
|
extend<const TExt extends {
|
|
@@ -72,8 +72,8 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
72
72
|
} & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
|
|
73
73
|
/**
|
|
74
74
|
* Partial extension: only the `enforced` languages are required; all other
|
|
75
|
-
* languages are optional (and may provide a subset of keys)
|
|
76
|
-
* translations fall back to the default language. Keys must be new
|
|
75
|
+
* languages are optional (and may provide a subset of keys), and missing
|
|
76
|
+
* translations fall back to the default language. Keys must be new:
|
|
77
77
|
* redeclaring a parent key is a compile-time and runtime error.
|
|
78
78
|
*/
|
|
79
79
|
extendPartial<const TExt extends {
|
|
@@ -116,7 +116,7 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
116
116
|
isLanguageCode(value: string): value is keyof TLanguages & string;
|
|
117
117
|
readonly languages: TLanguages;
|
|
118
118
|
readonly languageList: (keyof TLanguages & string)[];
|
|
119
|
-
/** Ordered language metadata (declaration order), with `code` injected
|
|
119
|
+
/** Ordered language metadata (declaration order), with `code` injected, ready for switcher menus. */
|
|
120
120
|
readonly languageMetaList: (TLanguages[keyof TLanguages] & {
|
|
121
121
|
code: keyof TLanguages & string;
|
|
122
122
|
})[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LambderI18n
|
|
2
|
+
* LambderI18n: standalone, framework-free, isomorphic typed translation module.
|
|
3
3
|
*
|
|
4
4
|
* Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
|
|
5
5
|
* safe to import in both lambda backends and frontend bundles.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
|
|
2
|
+
export type LambderFile = {
|
|
3
|
+
body: Buffer;
|
|
4
|
+
mimeType?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Where an app's files come from: the `files` option at creation, read by
|
|
8
|
+
* servePublicFiles, serveIndexHtml, res.file and res.templateFile alike,
|
|
9
|
+
* through the instance's one reader (LambderFiles). Implement `read` over
|
|
10
|
+
* any backing store: LambderLocalFileSource (a folder), LambderS3FileSource
|
|
11
|
+
* (S3, or R2 and other S3-compatible stores), LambderHttpFileSource (any
|
|
12
|
+
* origin serving files by path), or your own. The reader does the rest for
|
|
13
|
+
* every source: path rule, memory cache, mime fallback from the extension.
|
|
14
|
+
*/
|
|
15
|
+
export interface LambderFileSource {
|
|
16
|
+
/**
|
|
17
|
+
* The file at a relative path, or null when there is no such file, which
|
|
18
|
+
* lets a request fall through to the route fallback.
|
|
19
|
+
*
|
|
20
|
+
* The reader has already refused everything that is not a plain relative
|
|
21
|
+
* path: no leading slash, no empty, "." or ".." segment, no backslash.
|
|
22
|
+
* A source that resolves the value against a base an attacker must not
|
|
23
|
+
* leave (a URL, a filesystem root) still re-checks the result, because a
|
|
24
|
+
* contract is not a boundary.
|
|
25
|
+
*/
|
|
26
|
+
read(relativePath: string): Promise<LambderFile | null>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A file a remote store returned: the store's Content-Type unless it is a
|
|
30
|
+
* generic octet-stream, in which case the extension decides, as for local
|
|
31
|
+
* files.
|
|
32
|
+
*/
|
|
33
|
+
export declare const remoteStoreFile: (body: Buffer, contentType: string | null | undefined) => LambderFile;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where an app's files come from: the interface, and the one helper its
|
|
3
|
+
* remote implementations share.
|
|
4
|
+
*
|
|
5
|
+
* Declared in shared/ beside LambderSessionStore, LambderRateLimiter and
|
|
6
|
+
* LambderIdempotencyStore, so all four store families read the same way: the
|
|
7
|
+
* interface here, the implementations in stores/ (LambderLocalFileSource,
|
|
8
|
+
* LambderS3FileSource, LambderHttpFileSource). core/ names the interface and
|
|
9
|
+
* nothing else of the family.
|
|
10
|
+
*
|
|
11
|
+
* What reads through a source is the instance's reader, LambderFiles: it owns
|
|
12
|
+
* the path rule, the memory cache and the mime fallback.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* A file a remote store returned: the store's Content-Type unless it is a
|
|
16
|
+
* generic octet-stream, in which case the extension decides, as for local
|
|
17
|
+
* files.
|
|
18
|
+
*/
|
|
19
|
+
export const remoteStoreFile = (body, contentType) => contentType && !contentType.endsWith("octet-stream") ? { body, mimeType: contentType } : { body };
|