lambder 6.0.2 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -1,16 +1,6 @@
1
- import crypto from "crypto";
2
- import { loadDynamoClientSdk, loadDynamoDocumentSdk } from "../stores/LambderDdbSdk.js";
3
- import { compressText, restoreText } from "../shared/LambderCompressionCodec.js";
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 instanceof Error ? cause.message : String(cause)}`, { cause });
12
+ super(`Session dataRefresh failed: ${coerceToError(cause).message}`, { cause });
23
13
  this.name = "LambderSessionDataRefreshError";
24
14
  }
25
15
  }
26
16
  /**
27
- * Wraps DynamoDB failures during a session read so they stay distinguishable
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 DynamoDB
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 instanceof Error ? cause.message : String(cause)}`, { 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
- tableName;
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
- compression;
51
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression, }) {
52
- this.tableName = tableName;
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 ?? null;
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.compression = resolveCompressionOption(compression, SESSION_COMPRESSION_DEFAULTS);
60
- this.tableRegion = tableRegion;
61
- }
62
- /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
63
- ready() {
64
- this.readyPromise ??= Promise.all([loadDynamoClientSdk("LambderSessionManager"), loadDynamoDocumentSdk("LambderSessionManager")])
65
- .then(([clientSdk, sdk]) => ({ sdk, client: sdk.DynamoDBDocumentClient.from(new clientSdk.DynamoDBClient({ region: this.tableRegion })) }))
66
- .catch((error) => { this.readyPromise = undefined; throw error; });
67
- return this.readyPromise;
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
- sessionUserKeyHasher(password) {
70
- return crypto.createHash("sha256")
71
- .update(`${password}${this.sessionSalt}`)
72
- .digest("hex");
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 table read yields no usable cookies.
132
+ * hashing just ensures a leaked store read yields no usable cookies.
79
133
  */
80
134
  hashToken(value) {
81
- return crypto.createHash("sha256").update(value).digest("hex");
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
- const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
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
- // DynamoDB range key, so the table never contains a usable token.
158
- const sessionSortKeySecret = crypto.randomBytes(32).toString("hex");
159
- const sessionToken = `${sessionKeyHash}:${sessionSortKeySecret}`;
160
- const csrfToken = crypto.randomBytes(32).toString("hex");
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
- [this.partitionKey]: sessionKeyHash,
166
- [this.sortKey]: this.hashToken(sessionSortKeySecret),
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.ddbPutItem(session);
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.ddbPutItem(session);
183
+ await this.store.put(session);
189
184
  return session;
190
185
  }
191
- async getSession(sessionToken) {
192
- const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
193
- if (!sessionKeyHash || !sessionSortKeySecret)
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
- // A DynamoDB read failure propagates typed: null means "no such
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.ddbGetItem({
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
- // A compressed record (see ddbPutItem) decodes back into `data`. One
214
- // that fails to decode throws, which the controller treats like any
215
- // malformed record: no session.
216
- if (session.dataBr) {
217
- session.data = JSON.parse(await restoreText(session.dataBr, "br", { declaredBytes: session.dataBytes }));
218
- delete session.dataBr;
219
- delete session.dataBytes;
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 DynamoDB write when the session was refreshed
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
- await this.ddbPutItem(session).catch(() => { });
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.ddbPutItem(session);
328
+ await this.store.put(session);
304
329
  return session;
305
330
  }
306
331
  ;
307
- isSessionValid(session, sessionToken, csrfToken, skipCsrfTokenCheck = false) {
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 || typeof sessionToken !== "string")
342
+ if (!sessionToken)
311
343
  return false;
312
344
  // Presented raw secrets are checked against the stored hashes.
313
- const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
314
- if (!sessionKeyHash || !sessionSortKeySecret)
345
+ const [sessionKeyHash, secret] = sessionToken.split(":");
346
+ if (!sessionKeyHash || !secret)
315
347
  return false;
316
- if (!this.constantTimeCompare(String(session[this.partitionKey] ?? ""), sessionKeyHash))
348
+ if (!this.crypto.constantTimeEqual(session.sessionKeyHash, sessionKeyHash))
317
349
  return false;
318
- if (!this.constantTimeCompare(String(session[this.sortKey] ?? ""), this.hashToken(sessionSortKeySecret)))
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.ddbDeleteItem({
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.ddbDeleteAllByPartitionKey(session[this.partitionKey]);
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.ddbDeleteAllByPartitionKey(this.sessionUserKeyHasher(sessionKey));
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, conditionally on the record still existing, so it
365
- * neither resurrects a session deleted in between nor overwrites a
366
- * concurrent write. Requires dataRefresh to be configured.
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 partitionValue = this.sessionUserKeyHasher(sessionKey);
414
+ const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
372
415
  const now = Math.floor(Date.now() / 1000);
373
- for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
374
- try {
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 — standalone, framework-free, isomorphic typed translation module.
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)` — and when the key's contract value contains
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 — redeclaring a parent key is a compile-time and runtime error.
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) — missing
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 — ready for switcher menus. */
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 — standalone, framework-free, isomorphic typed translation module.
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 };