lambder 7.2.5 → 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.
Files changed (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +161 -69
  41. package/dist/core/Lambder.js +370 -226
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +3 -1
  93. package/dist/mock.js +5 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,6 +1,8 @@
1
1
  import { LambderWebCrypto } from "./LambderSessionCrypto.js";
2
2
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
3
3
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
4
+ import { canonicalJson } from "../shared/util/canonicalJson.js";
5
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
4
6
  /**
5
7
  * Wraps errors thrown by the dataRefresh callback so they stay
6
8
  * distinguishable from "no session": fetchSessionIfExists() swallows missing
@@ -28,35 +30,43 @@ export class LambderSessionReadError extends Error {
28
30
  }
29
31
  /**
30
32
  * 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.
33
+ * 64-character hex halves; sixteen times that leaves a custom
34
+ * LambderSessionCrypto free to mint longer hashes or secrets, and leaves
35
+ * LambderPlainSessionCrypto (which hex-encodes rather than hashes) room for a
36
+ * session key and salt of several hundred characters. The bound is not
37
+ * derived from the crypto: LambderSessionCrypto exposes no length, and tying
38
+ * it to the configured crypto would reject every session a previously
39
+ * configured one minted.
39
40
  *
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.
41
+ * What matters is staying under DynamoDB's 2048-byte partition key limit. A
42
+ * cookie may carry 4000, so without a bound a planted oversized cookie
43
+ * reaches the store as a key it cannot take, the read throws, and a live
44
+ * session beside it answers 500 on every request.
45
45
  */
46
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. */
47
+ /** How many store writes one "every session of a subject" operation runs at once. */
48
+ const SUBJECT_WRITE_CONCURRENCY = 16;
49
+ /**
50
+ * How many times deleting every session of a subject lists them in all,
51
+ * when each pass finds a listed record already gone (see deleteAllUnder).
52
+ * Each further pass needs a rotation to complete inside the previous pass's
53
+ * gap between listing and deleting; the bound keeps a client rotating in a
54
+ * tight loop from holding the call open, and a call that reaches it answers
55
+ * false.
56
+ */
57
+ const SUBJECT_DELETE_MAX_PASSES = 4;
58
+ /** Hex in either case: what every LambderSessionCrypto's hmacSha256Hex (the first half) and randomHex (the second) produce, whichever case a custom one picks. */
48
59
  const SESSION_TOKEN_HALF_PATTERN = /^[0-9a-fA-F]+$/;
49
60
  /**
50
61
  * Whether a string has the shape a minted session token has:
51
62
  * `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
52
63
  *
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.
64
+ * It lives beside the code that mints and splits the format, not the cookie
65
+ * reader, so the ceiling belongs to the model and anyone handing
66
+ * lookupSession a token they did not mint can ask it too. The session
67
+ * controller checks every candidate cookie with it before any store read, so
68
+ * a malformed one is "no session", never a read error. No legitimate cookie
69
+ * fails it, since only the minting code writes them.
60
70
  */
61
71
  export const isMintedSessionToken = (token) => {
62
72
  const halves = token.split(":");
@@ -79,6 +89,7 @@ export const isMintedSessionToken = (token) => {
79
89
  * stored too.
80
90
  */
81
91
  export default class LambderSessionManager {
92
+ /** Replaceable through the backend swap alone; see LAMBDER_BACKEND_SWAP. */
82
93
  store;
83
94
  sessionSalt;
84
95
  enableSlidingExpiration;
@@ -96,34 +107,43 @@ export default class LambderSessionManager {
96
107
  assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
97
108
  this.dataRefresh = dataRefresh ?? null;
98
109
  this.crypto = crypto ?? new LambderWebCrypto();
110
+ this.assertCryptoFitsStore(store);
111
+ if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
112
+ throw new Error("Lambder: session sessionSalt is empty. It keys the hash that partitions the store, so it has to be a real, stable secret.");
113
+ }
114
+ }
115
+ /** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
116
+ assertCryptoFitsStore(store) {
99
117
  if (!this.crypto.isCryptographic && !store.isMemoryOnly) {
100
118
  throw new Error("Lambder: this session crypto does not hash and does not draw cryptographically random bytes, " +
101
119
  "so it may only sit in front of a store that dies with the process. Over a persistent store every " +
102
120
  "record would be a usable credential and the sessionSalt would be readable from it.");
103
121
  }
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
- }
107
122
  }
108
123
  /**
109
- * The salted partition hash of a sessionKey: sha256 of the key followed
110
- * by the salt, with NO separator between them.
124
+ * Puts the manager over another store, for `lambder/testing`. The model
125
+ * (salt, tokens, expiry, dataRefresh) stays this manager's own, so a test
126
+ * runs the app's sessions as configured over a store that dies with the
127
+ * process. Sessions held by the store it leaves are simply out of reach.
128
+ */
129
+ [LAMBDER_BACKEND_SWAP](store) {
130
+ this.assertCryptoFitsStore(store);
131
+ this.store = store;
132
+ }
133
+ /**
134
+ * The salted partition hash of a sessionKey: HMAC-SHA256 with the salt
135
+ * as the key and the sessionKey as the message.
111
136
  *
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.
137
+ * Keyed rather than hashed over the two run together, because a plain
138
+ * concatenation cannot tell where the sessionKey ends and the salt
139
+ * begins: "ab" + "cd" and "a" + "bcd" would hash alike, so two
140
+ * deployments sharing one table could collide whenever the difference
141
+ * between their salts fits into a sessionKey. With the salt as the HMAC
142
+ * key the two inputs never meet in one string, so no choice of salts
143
+ * lets one deployment's subject land in another's partition.
124
144
  */
125
145
  sessionKeyHashOf(sessionKey) {
126
- return this.crypto.sha256Hex(`${sessionKey}${this.sessionSalt}`);
146
+ return this.crypto.hmacSha256Hex(this.sessionSalt, sessionKey);
127
147
  }
128
148
  /**
129
149
  * At-rest hash for the bearer secrets (session sort-key secret, CSRF
@@ -140,11 +160,11 @@ export default class LambderSessionManager {
140
160
  // unparsed environment variable would write a record nothing ever
141
161
  // retires and no read ever accepts.
142
162
  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.
163
+ // Refused like a NaN TTL: lookupSession rejects a record with no
164
+ // sessionKey on every read (a session has to name its subject), so
165
+ // the caller would get a valid-looking cookie pair for a session
166
+ // nobody can use, and every later request would read as a silent
167
+ // logout.
148
168
  if (!sessionKey)
149
169
  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
170
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
@@ -163,33 +183,67 @@ export default class LambderSessionManager {
163
183
  sessionKey, data,
164
184
  createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
165
185
  ...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
186
+ dataVersion: 0,
166
187
  };
167
- await this.store.put(session);
188
+ await this.store.create(session);
168
189
  return { session, sessionToken, csrfToken };
169
190
  }
191
+ /**
192
+ * Writes new session data onto the record, and nothing else: the expiry
193
+ * slides only in renewSession, which also re-issues the cookies, so a
194
+ * data write never moves the record's expiry away from the browser's.
195
+ * Returns the updated record, or null when the session was ended in
196
+ * between (the write does not bring it back).
197
+ *
198
+ * With dataRefresh, the write leaves dataExpiresAt where it is: only the
199
+ * dataRefresh callback's output is stamped fresh. The data an app writes
200
+ * is almost always the session's own with a field changed, still carrying
201
+ * whatever the callback derived last, so a write that pushed the deadline
202
+ * would let an app writing more often than ttlSeconds never refresh at
203
+ * all, and would cancel the due start regenerateSession gives a rotated
204
+ * session. The write is still conditioned on the dataVersion this record
205
+ * was read with. If that moved in between (expireSessionDataAllByKey
206
+ * marked the data stale, or another request refreshed it, possibly
207
+ * applying a revocation already), the data is written and marked due, so
208
+ * a revocation that landed during this request is not undone by data
209
+ * derived before it.
210
+ *
211
+ * The returned record carries the dataVersion this write produced only
212
+ * when a conditioned write applied, which is then exactly one past the
213
+ * version it named. Otherwise it keeps the version it was read with,
214
+ * which the store has already passed, so a later conditioned write from
215
+ * the same request answers "stale" and lands marked due: the safe side.
216
+ */
170
217
  async updateSessionData(session, newData) {
171
218
  if (!session)
172
219
  throw new Error("Invalid session");
173
- session.data = newData;
174
- session.lastAccessedAt = Math.floor(Date.now() / 1000);
175
- // Explicitly written data is fresh by definition.
176
- if (this.dataRefresh) {
177
- session.dataExpiresAt = session.lastAccessedAt + this.dataRefresh.ttlSeconds;
178
- }
179
- // Update expiration if sliding expiration is enabled
180
- if (this.enableSlidingExpiration) {
181
- session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
220
+ const { sessionKeyHash, secretHash } = session;
221
+ if (!this.dataRefresh) {
222
+ const result = await this.store.update(sessionKeyHash, secretHash, { data: newData });
223
+ return result === "missing" ? null : { ...session, data: newData };
182
224
  }
183
- await this.store.put(session);
184
- return session;
225
+ const result = await this.store.update(sessionKeyHash, secretHash, { data: newData }, { dataVersion: session.dataVersion });
226
+ if (result === "updated")
227
+ return { ...session, data: newData, dataVersion: session.dataVersion + 1 };
228
+ if (result === "missing")
229
+ return null;
230
+ // The data or its deadline was written in between: a revocation was
231
+ // marked, or another request refreshed the data, possibly applying
232
+ // that revocation already. The app's data lands, and is marked due,
233
+ // so the next read runs it through dataRefresh again rather than
234
+ // serving what this request derived before the change.
235
+ const now = Math.floor(Date.now() / 1000);
236
+ if (await this.store.update(sessionKeyHash, secretHash, { data: newData, dataExpiresAt: now }) === "missing")
237
+ return null;
238
+ return { ...session, data: newData, dataExpiresAt: now };
185
239
  }
186
240
  /**
187
241
  * 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
242
+ * renewed. Kept apart from renewSession so a caller weighing several
243
+ * candidate cookies can decide which is this visitor's BEFORE anything is
190
244
  * written on their behalf: renewing slides an expiry and may run the
191
245
  * app's dataRefresh callback, and a cookie a sibling host planted must
192
- * not get either from the victim's traffic.
246
+ * get neither from the victim's traffic.
193
247
  */
194
248
  async lookupSession(sessionToken) {
195
249
  const [sessionKeyHash, secret] = sessionToken.split(":");
@@ -197,9 +251,9 @@ export default class LambderSessionManager {
197
251
  return null;
198
252
  const secretHash = await this.hashToken(secret);
199
253
  // A store read failure propagates typed: null means "no such
200
- // session", which callers translate to sessionExpired, and the caller
201
- // then clears the client's session cookies. A transient infra error
202
- // must surface as a 500, not force a logout.
254
+ // session", which callers answer as sessionExpired, clearing the
255
+ // client's session cookies. A transient infra error must surface as a
256
+ // 500, not force a logout.
203
257
  let session;
204
258
  try {
205
259
  // The lookup itself proves possession of the raw secret: the
@@ -211,12 +265,11 @@ export default class LambderSessionManager {
211
265
  }
212
266
  if (!session)
213
267
  return null;
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.
268
+ // The record has to be the one asked for. A correct store answers
269
+ // with the item under the two keys it was given; a store that does
270
+ // not (a loosely keyed cache, a query missing its partition) stops
271
+ // here instead of handing back somebody else's session. The hashes
272
+ // are in hand, so this costs a comparison and no hashing.
220
273
  if (!this.crypto.constantTimeEqual(session.sessionKeyHash ?? "", sessionKeyHash))
221
274
  return null;
222
275
  if (!this.crypto.constantTimeEqual(session.secretHash ?? "", secretHash))
@@ -227,6 +280,10 @@ export default class LambderSessionManager {
227
280
  return null;
228
281
  if (!session.createdAt)
229
282
  return null;
283
+ // Every conditioned write names it and renewal adds to it, so a store
284
+ // that dropped it would turn each into a NaN version no write matches.
285
+ if (typeof session.dataVersion !== "number")
286
+ return null;
230
287
  // Expiry is enforced here, on every read, because the store contract
231
288
  // allows a record past its expiresAt: a DynamoDB TTL deletes within
232
289
  // days rather than at the second, and a store over a plain table
@@ -239,15 +296,17 @@ export default class LambderSessionManager {
239
296
  /**
240
297
  * The renewal half of a session read: the dataRefresh callback once its
241
298
  * 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.
299
+ * when the session is over: a refresh said so (a deleted or disabled
300
+ * login), or the record was deleted while this request read it (a
301
+ * logout, a password change), which a renewal must not undo.
244
302
  */
245
303
  async renewSession(session) {
246
304
  const now = Math.floor(Date.now() / 1000);
247
- let needsWrite = false;
305
+ const refreshed = {};
306
+ const slid = {};
248
307
  // Renew session.data once its shelf life has passed (opt-in
249
- // dataRefresh). Records from before the feature was enabled have no
250
- // dataExpiresAt, so they renew on first read.
308
+ // dataRefresh). A record created while dataRefresh was off has no
309
+ // dataExpiresAt, so it renews on its first read.
251
310
  if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
252
311
  let newData;
253
312
  try {
@@ -262,45 +321,58 @@ export default class LambderSessionManager {
262
321
  await this.deleteSession(session);
263
322
  return null;
264
323
  }
265
- session.data = newData;
266
- session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
267
- needsWrite = true;
324
+ refreshed.data = newData;
325
+ refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
268
326
  }
269
- // Update last accessed time if sliding expiration is enabled.
270
- // Throttled: skip the store write when the session was refreshed
271
- // recently, to avoid a write on every request. A due data renewal
272
- // above forces the write anyway, so both updates share one put.
327
+ // Sliding expiration, throttled: no store write while lastAccessedAt
328
+ // is recent, to avoid a write on every request. A due data renewal
329
+ // above forces the write, so both updates share one write.
273
330
  if (this.enableSlidingExpiration) {
274
331
  const minInterval = this.slidingWriteIntervalSeconds
275
332
  ?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
276
- if (needsWrite || now - (session.lastAccessedAt || 0) >= minInterval) {
277
- session.lastAccessedAt = now;
278
- session.expiresAt = now + session.ttlInSeconds;
279
- needsWrite = true;
333
+ if (refreshed.data !== undefined || now - (session.lastAccessedAt || 0) >= minInterval) {
334
+ slid.lastAccessedAt = now;
335
+ slid.expiresAt = now + session.ttlInSeconds;
280
336
  }
281
337
  }
282
- if (needsWrite) {
283
- // Wait for the update to ensure it persists before Lambda freezes.
284
- // A failed put is not fatal: the data served is fresh, and an
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
- });
338
+ const renewed = { ...session, ...refreshed, ...slid };
339
+ if (refreshed.data === undefined && slid.expiresAt === undefined)
340
+ return renewed;
341
+ // Awaited, so it persists before Lambda freezes. A failing write is
342
+ // not fatal: the refreshed data is served, the expiry stays where the
343
+ // cookies have it, and an unpersisted renewal runs again on the next
344
+ // read. It is logged because a store failing
345
+ // every renewal write silently ends every session at its creation
346
+ // TTL, visible otherwise only as users signed out too soon. The log
347
+ // carries the reason only, never anything identifying the session.
348
+ try {
349
+ const result = await this.store.update(session.sessionKeyHash, session.secretHash, { ...refreshed, ...slid }, refreshed.data !== undefined ? { dataVersion: session.dataVersion } : undefined);
350
+ if (result === "missing")
351
+ return null;
352
+ // Refreshed data that landed moved the version exactly once past
353
+ // the one the write named (see updateSessionData); a slide alone
354
+ // leaves it.
355
+ if (result === "updated")
356
+ return refreshed.data !== undefined ? { ...renewed, dataVersion: session.dataVersion + 1 } : renewed;
357
+ // Written over by a newer write, or marked stale, while the
358
+ // refresh ran: that write stands, and the expiry still slides.
359
+ if (slid.expiresAt !== undefined && await this.store.update(session.sessionKeyHash, session.secretHash, slid) === "missing")
360
+ return null;
295
361
  }
296
- return session;
362
+ catch (err) {
363
+ console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
364
+ return { ...session, ...refreshed };
365
+ }
366
+ return renewed;
297
367
  }
298
368
  ;
299
369
  /**
300
- * Runs the dataRefresh callback now, regardless of dataExpiresAt, and
301
- * persists the result onto the same record. Returns the updated session,
302
- * or null when the callback ended it (the record is deleted). Requires
303
- * dataRefresh to be configured.
370
+ * Runs the dataRefresh callback immediately, regardless of
371
+ * dataExpiresAt, and persists the result onto the same record, over the
372
+ * data it was computed from only (see LambderSessionDataRefreshConfig).
373
+ * Returns the refreshed session, or null when the callback ended it (the
374
+ * record is deleted) or the session is gone. The expiry does not slide
375
+ * here; renewSession slides it. Requires dataRefresh to be configured.
304
376
  */
305
377
  async refreshSessionData(session) {
306
378
  if (!this.dataRefresh)
@@ -318,23 +390,21 @@ export default class LambderSessionManager {
318
390
  await this.deleteSession(session);
319
391
  return null;
320
392
  }
321
- const now = Math.floor(Date.now() / 1000);
322
- session.data = newData;
323
- session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
324
- session.lastAccessedAt = now;
325
- if (this.enableSlidingExpiration) {
326
- session.expiresAt = now + session.ttlInSeconds;
327
- }
328
- await this.store.put(session);
329
- return session;
393
+ const changes = { data: newData, dataExpiresAt: Math.floor(Date.now() / 1000) + this.dataRefresh.ttlSeconds };
394
+ const result = await this.store.update(session.sessionKeyHash, session.secretHash, changes, { dataVersion: session.dataVersion });
395
+ if (result === "missing")
396
+ return null;
397
+ // "stale": served, not written, and the record keeps the version it
398
+ // was read with (see updateSessionData).
399
+ return { ...session, ...changes, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
330
400
  }
331
401
  ;
332
402
  /**
333
403
  * Checks a record against the session token presented with it: the
334
404
  * 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.
405
+ * structural checks and the expiry. This is the half a route needs; a
406
+ * record lookupSession just found by that token's own hash has already
407
+ * passed it, so the read path does not ask again.
338
408
  */
339
409
  async isSessionTokenValid(session, sessionToken) {
340
410
  if (!session)
@@ -362,9 +432,8 @@ export default class LambderSessionManager {
362
432
  /**
363
433
  * Checks a record against the CSRF token the request posted: the other
364
434
  * 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.
435
+ * than one with a skip flag, because a boolean at the call site does not
436
+ * say which half it turns off.
368
437
  */
369
438
  async isSessionCsrfTokenValid(session, csrfToken) {
370
439
  if (!session?.csrfTokenHash)
@@ -373,61 +442,133 @@ export default class LambderSessionManager {
373
442
  return false;
374
443
  return this.crypto.constantTimeEqual(session.csrfTokenHash, await this.hashToken(csrfToken));
375
444
  }
445
+ /** Deletes the session; false when there was none left to delete. */
376
446
  async deleteSession(session) {
377
- await this.store.delete(session.sessionKeyHash, session.secretHash);
378
- return true;
447
+ return (await this.store.delete(session.sessionKeyHash, session.secretHash)) !== null;
379
448
  }
380
449
  ;
381
- /** Deletes every session that shares the record's subject: "log this subject out everywhere". */
450
+ /**
451
+ * Deletes every session that shares the record's subject: "log this
452
+ * subject out everywhere". False when a rotation racing it may have left
453
+ * a session behind (see deleteAllUnder).
454
+ */
382
455
  async deleteSessionAll(session) {
383
- await this.deleteAllUnder(session.sessionKeyHash);
384
- return true;
456
+ return await this.deleteAllUnder(session.sessionKeyHash);
385
457
  }
386
458
  ;
387
459
  /**
388
460
  * Deletes every session created for the given sessionKey (e.g. a user
389
461
  * id): "log this subject out everywhere", without needing a fetched
390
- * session record.
462
+ * session record. False when a rotation racing it may have left a
463
+ * session behind (see deleteAllUnder).
391
464
  */
392
465
  async deleteSessionAllByKey(sessionKey) {
393
- await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
394
- return true;
466
+ return await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
395
467
  }
396
468
  ;
469
+ /**
470
+ * Deletes every session of a subject, and answers whether it is sure
471
+ * none is left. A pass that finds a record it listed already gone lists
472
+ * again: a rotation writes its new record before deleting the old one
473
+ * (see regenerateSession), so the old one vanishing between this listing
474
+ * and this delete means a new record may have appeared after the
475
+ * listing. When the last pass the bound allows still finds one gone,
476
+ * such a record may be standing, so the call is logged and answers
477
+ * false: a caller ending a subject's sessions after a password change
478
+ * can run it again.
479
+ */
397
480
  async deleteAllUnder(sessionKeyHash) {
398
- for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
399
- await this.store.delete(sessionKeyHash, secretHash);
481
+ for (let pass = 0; pass < SUBJECT_DELETE_MAX_PASSES; pass++) {
482
+ let foundGone = false;
483
+ await this.forEachSessionOf(sessionKeyHash, async (secretHash) => {
484
+ if (await this.store.delete(sessionKeyHash, secretHash) === null)
485
+ foundGone = true;
486
+ });
487
+ if (!foundGone)
488
+ return true;
489
+ }
490
+ // The count only, never the subject: a log line is no place for who it was.
491
+ console.error(`Lambder session: deleting every session of a subject found a listed session already gone on each of its ${SUBJECT_DELETE_MAX_PASSES} passes, so a rotation racing it may have left a session behind. It answered false; run it again to be sure.`);
492
+ return false;
493
+ }
494
+ /**
495
+ * One write per session of a subject, a bounded number at a time: a
496
+ * subject with hundreds of sessions is not hundreds of round trips in a
497
+ * row, and not hundreds at once either.
498
+ */
499
+ async forEachSessionOf(sessionKeyHash, write) {
500
+ const secretHashes = await this.store.listSecretHashes(sessionKeyHash);
501
+ for (let start = 0; start < secretHashes.length; start += SUBJECT_WRITE_CONCURRENCY) {
502
+ await Promise.all(secretHashes.slice(start, start + SUBJECT_WRITE_CONCURRENCY).map(write));
400
503
  }
401
504
  }
402
505
  /**
403
506
  * Marks the data of every session of the given sessionKey stale, so each
404
507
  * renews via dataRefresh on its next read: "this subject's roles or
405
- * permissions changed, apply it now", without logging the subject out
406
- * (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
508
+ * permissions changed, apply that right away", without logging them out
509
+ * (deleteSessionAllByKey) and without waiting for the data TTL. Writes
407
510
  * dataExpiresAt only, on records that still exist, so it neither
408
511
  * resurrects a session deleted in between nor overwrites a concurrent
409
- * write. Requires dataRefresh to be configured.
512
+ * write. The write moves each record's dataVersion, even when the
513
+ * deadline already reads this second, so a refresh or data write in
514
+ * flight, conditioned on the version it read, answers "stale" rather
515
+ * than landing data derived before the change. Requires dataRefresh to
516
+ * be configured.
410
517
  */
411
518
  async expireSessionDataAllByKey(sessionKey) {
412
519
  if (!this.dataRefresh)
413
520
  throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
414
521
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
415
522
  const now = Math.floor(Date.now() / 1000);
416
- for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
417
- await this.store.markDataExpired(sessionKeyHash, secretHash, now);
418
- }
523
+ // An update, so a session deleted in between stays deleted.
524
+ await this.forEachSessionOf(sessionKeyHash, (secretHash) => this.store.update(sessionKeyHash, secretHash, { dataExpiresAt: now }));
419
525
  return true;
420
526
  }
421
527
  ;
528
+ /**
529
+ * Replaces the session with a new one under new tokens, carrying over
530
+ * the data as the delete removed it rather than as this request read
531
+ * it, so data another request wrote meanwhile stays. Returns null, and
532
+ * leaves no new session behind, when the record was already gone or
533
+ * over: a logout, "log out everywhere" or a password change that landed
534
+ * during this request stays in force.
535
+ *
536
+ * The new record is written before the old one is deleted. The other
537
+ * way round, a subject-wide delete that listed the subject's sessions
538
+ * between the two would find neither, and the new session would outlive
539
+ * the password change it was racing. Written first, the new record is in
540
+ * that listing, or the old one still is: then either the subject-wide
541
+ * delete removes the old one before this delete does, and this delete
542
+ * takes the new one back out, or this delete removes it first, and the
543
+ * subject-wide delete, finding it gone, lists again (deleteAllUnder).
544
+ *
545
+ * With dataRefresh, the new session's data is due at once: a revocation
546
+ * marked by expireSessionDataAllByKey while the new record was being
547
+ * written may have passed it by, and due, its next read renews the data
548
+ * from the source of truth either way.
549
+ */
422
550
  async regenerateSession(session) {
423
551
  if (!session)
424
552
  throw new Error("Invalid session");
425
- // Delete old session
426
- await this.deleteSession(session);
427
- // Create new session with same sessionKey and data but new tokens.
428
- // The data freshness stamp carries over: rotating tokens must not
429
- // extend how long dataRefresh-managed data may stay unrenewed.
430
- return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, session.dataExpiresAt !== undefined ? { dataExpiresAt: session.dataExpiresAt } : undefined);
553
+ const now = Math.floor(Date.now() / 1000);
554
+ const created = await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, this.dataRefresh ? { dataExpiresAt: now } : undefined);
555
+ const replacement = created.session;
556
+ const removed = await this.store.delete(session.sessionKeyHash, session.secretHash);
557
+ if (!removed || removed.expiresAt <= now) {
558
+ await this.store.delete(replacement.sessionKeyHash, replacement.secretHash);
559
+ return null;
560
+ }
561
+ if (canonicalJson(removed.data) === canonicalJson(session.data))
562
+ return created;
563
+ // Another request wrote the data after this one read it. The write
564
+ // moves the replacement's dataVersion one past the one it was created
565
+ // with, and the record handed back says so: a data write later in
566
+ // this request names the version the store holds rather than answer
567
+ // stale. A mark landing in between still makes it stale, the safe
568
+ // side (see updateSessionData).
569
+ if (await this.store.update(replacement.sessionKeyHash, replacement.secretHash, { data: removed.data }) === "missing")
570
+ return null;
571
+ return { ...created, session: { ...replacement, data: removed.data, dataVersion: replacement.dataVersion + 1 } };
431
572
  }
432
573
  }
433
574
  ;