lambder 7.3.1 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +933 -3
  2. package/README.md +41 -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 +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -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 +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -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 +140 -75
  41. package/dist/core/Lambder.js +347 -227
  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 +21 -7
  51. package/dist/core/LambderFiles.js +62 -34
  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 +29 -9
  73. package/dist/invoke/LambderLambdaEvent.js +40 -22
  74. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  75. package/dist/invoke/lambderHandlerTransport.js +15 -18
  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 +1 -1
  93. package/dist/mock.js +2 -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 +124 -46
  99. package/dist/session/LambderSessionManager.js +262 -137
  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/LambderTypeUtilities.d.ts +7 -8
  134. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  135. package/dist/shared/util/boundKeyField.d.ts +20 -0
  136. package/dist/shared/util/boundKeyField.js +34 -0
  137. package/dist/shared/util/canonicalJson.d.ts +11 -0
  138. package/dist/shared/util/canonicalJson.js +28 -0
  139. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  140. package/dist/shared/util/joinKeyFields.js +22 -0
  141. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  142. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  143. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  144. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  145. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  146. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  147. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  148. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  149. package/dist/shared/wire/LambderApiSignature.js +16 -19
  150. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  151. package/dist/shared/wire/LambderCallOptions.js +9 -11
  152. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  153. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  154. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  155. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  156. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  157. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  158. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  159. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  160. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  161. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  162. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  163. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  164. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  165. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  166. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  167. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  168. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  169. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  170. package/dist/stores/LambderCacheFiller.js +119 -0
  171. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  172. package/dist/stores/LambderCacheKeys.js +54 -0
  173. package/dist/stores/LambderCacheValues.d.ts +45 -0
  174. package/dist/stores/LambderCacheValues.js +74 -0
  175. package/dist/stores/LambderDdbCache.d.ts +121 -56
  176. package/dist/stores/LambderDdbCache.js +528 -225
  177. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  178. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  179. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  180. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  181. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  182. package/dist/stores/LambderDdbSdk.js +79 -33
  183. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  184. package/dist/stores/LambderDdbSessionStore.js +119 -47
  185. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  186. package/dist/stores/LambderHttpFileSource.js +15 -13
  187. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  188. package/dist/stores/LambderMemoryCache.js +113 -0
  189. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  190. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  191. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  192. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  193. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  194. package/dist/stores/LambderMemorySessionStore.js +38 -19
  195. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  196. package/dist/stores/LambderS3FileSource.js +12 -7
  197. package/dist/testing/LambderTestApp.d.ts +21 -23
  198. package/dist/testing/LambderTestApp.js +22 -24
  199. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  200. package/dist/testing/LambderTestVisitor.js +15 -15
  201. package/dist/testing.d.ts +1 -0
  202. package/dist/testing.js +1 -0
  203. package/package.json +12 -3
  204. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  205. package/dist/api/LambderApiPolicyEngine.js +0 -85
  206. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  207. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,6 +1,7 @@
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";
4
5
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
5
6
  /**
6
7
  * Wraps errors thrown by the dataRefresh callback so they stay
@@ -29,35 +30,43 @@ export class LambderSessionReadError extends Error {
29
30
  }
30
31
  /**
31
32
  * The longest either half of a session token may be. A minted token is two
32
- * 64-character hex halves, so this is sixteen times the room the default
33
- * crypto needs, which leaves a custom LambderSessionCrypto free to mint
34
- * longer hashes or secrets without this file knowing its lengths, and leaves
35
- * LambderPlainSessionCrypto, which hex-encodes its input rather than hashing
36
- * it, room for a session key and salt of several hundred characters.
37
- * Deriving the exact length from the crypto instead would tie the check to
38
- * whichever crypto is configured today and reject every session minted by
39
- * 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.
40
40
  *
41
- * The number that matters is the one this is comfortably under: DynamoDB
42
- * refuses a partition key over 2048 bytes, and a cookie may carry 4000, so
43
- * without a bound a planted oversized cookie reaches the store as a key it
44
- * cannot take, the read throws, and a live session beside it answers 500 on
45
- * 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.
46
45
  */
47
46
  const MAX_SESSION_TOKEN_HALF_CHARS = 1024;
48
- /** 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. */
49
59
  const SESSION_TOKEN_HALF_PATTERN = /^[0-9a-fA-F]+$/;
50
60
  /**
51
61
  * Whether a string has the shape a minted session token has:
52
62
  * `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
53
63
  *
54
- * It lives beside the code that mints and splits that format rather than
55
- * beside the cookie reader, so the ceiling is a property of the model and
56
- * anyone handing lookupSession a token they did not mint can ask the same
57
- * question. The session controller asks it of every candidate cookie before
58
- * any store read, so a malformed candidate is "no session" and never a read
59
- * error. Nothing a browser legitimately holds fails it, because the only
60
- * 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.
61
70
  */
62
71
  export const isMintedSessionToken = (token) => {
63
72
  const halves = token.split(":");
@@ -100,7 +109,7 @@ export default class LambderSessionManager {
100
109
  this.crypto = crypto ?? new LambderWebCrypto();
101
110
  this.assertCryptoFitsStore(store);
102
111
  if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
103
- 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.");
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.");
104
113
  }
105
114
  }
106
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. */
@@ -122,24 +131,19 @@ export default class LambderSessionManager {
122
131
  this.store = store;
123
132
  }
124
133
  /**
125
- * The salted partition hash of a sessionKey: sha256 of the key followed
126
- * by the salt, with NO separator between them.
134
+ * The salted partition hash of a sessionKey: HMAC-SHA256 with the salt
135
+ * as the key and the sessionKey as the message.
127
136
  *
128
- * The missing separator is frozen by the wire guarantee, not chosen
129
- * again here: every live session in every deployed table was partitioned
130
- * under this exact string, so inserting a separator would relocate every
131
- * partition key at once and read to everyone signed in as being logged
132
- * out. What it costs is worth naming so nobody reintroduces it by
133
- * accident: without a separator the split between key and salt is not
134
- * recoverable from the string, so two deployments SHARING one table
135
- * collide when the difference between their salts can be absorbed into a
136
- * sessionKey ("ab" + "cd" and "a" + "bcd" hash alike). Two deployments
137
- * over one table must therefore not stand in a prefix relationship over
138
- * their salts. Separate tables, or salts that are independent random
139
- * 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.
140
144
  */
141
145
  sessionKeyHashOf(sessionKey) {
142
- return this.crypto.sha256Hex(`${sessionKey}${this.sessionSalt}`);
146
+ return this.crypto.hmacSha256Hex(this.sessionSalt, sessionKey);
143
147
  }
144
148
  /**
145
149
  * At-rest hash for the bearer secrets (session sort-key secret, CSRF
@@ -156,11 +160,11 @@ export default class LambderSessionManager {
156
160
  // unparsed environment variable would write a record nothing ever
157
161
  // retires and no read ever accepts.
158
162
  assertPositiveInteger(ttlInSeconds, "createSession ttlInSeconds");
159
- // Refused for the same reason as a NaN TTL: an empty sessionKey
160
- // writes a record that lookupSession rejects on every read (a session
161
- // has to name its subject), so the caller would be handed a
162
- // valid-looking cookie pair for a session nobody can ever sign in
163
- // 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.
164
168
  if (!sessionKey)
165
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.");
166
170
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
@@ -179,33 +183,67 @@ export default class LambderSessionManager {
179
183
  sessionKey, data,
180
184
  createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
181
185
  ...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
186
+ dataVersion: 0,
182
187
  };
183
- await this.store.put(session);
188
+ await this.store.create(session);
184
189
  return { session, sessionToken, csrfToken };
185
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
+ */
186
217
  async updateSessionData(session, newData) {
187
218
  if (!session)
188
219
  throw new Error("Invalid session");
189
- session.data = newData;
190
- session.lastAccessedAt = Math.floor(Date.now() / 1000);
191
- // Explicitly written data is fresh by definition.
192
- if (this.dataRefresh) {
193
- session.dataExpiresAt = session.lastAccessedAt + this.dataRefresh.ttlSeconds;
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 };
194
224
  }
195
- // Update expiration if sliding expiration is enabled
196
- if (this.enableSlidingExpiration) {
197
- session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
198
- }
199
- await this.store.put(session);
200
- 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 };
201
239
  }
202
240
  /**
203
241
  * The record a token names, read and structurally checked, with nothing
204
- * renewed. Split out of getSession so a caller weighing several candidate
205
- * 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
206
244
  * written on their behalf: renewing slides an expiry and may run the
207
245
  * app's dataRefresh callback, and a cookie a sibling host planted must
208
- * not get either from the victim's traffic.
246
+ * get neither from the victim's traffic.
209
247
  */
210
248
  async lookupSession(sessionToken) {
211
249
  const [sessionKeyHash, secret] = sessionToken.split(":");
@@ -213,9 +251,9 @@ export default class LambderSessionManager {
213
251
  return null;
214
252
  const secretHash = await this.hashToken(secret);
215
253
  // A store read failure propagates typed: null means "no such
216
- // session", which callers translate to sessionExpired, and the caller
217
- // then clears the client's session cookies. A transient infra error
218
- // 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.
219
257
  let session;
220
258
  try {
221
259
  // The lookup itself proves possession of the raw secret: the
@@ -227,12 +265,11 @@ export default class LambderSessionManager {
227
265
  }
228
266
  if (!session)
229
267
  return null;
230
- // The record has to be the one that was asked for. A correct store
231
- // answers with the item under the two keys it was given, and this is
232
- // what a store that does not (a cache keyed loosely, a query that
233
- // forgot its partition) runs into instead of handing back somebody
234
- // else's session. The hashes are already in hand, so it costs a
235
- // 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.
236
273
  if (!this.crypto.constantTimeEqual(session.sessionKeyHash ?? "", sessionKeyHash))
237
274
  return null;
238
275
  if (!this.crypto.constantTimeEqual(session.secretHash ?? "", secretHash))
@@ -243,6 +280,10 @@ export default class LambderSessionManager {
243
280
  return null;
244
281
  if (!session.createdAt)
245
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;
246
287
  // Expiry is enforced here, on every read, because the store contract
247
288
  // allows a record past its expiresAt: a DynamoDB TTL deletes within
248
289
  // days rather than at the second, and a store over a plain table
@@ -255,15 +296,17 @@ export default class LambderSessionManager {
255
296
  /**
256
297
  * The renewal half of a session read: the dataRefresh callback once its
257
298
  * shelf life has passed, and the sliding-expiration write. Returns null
258
- * when a refresh says the session is over (a deleted or disabled login),
259
- * 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.
260
302
  */
261
303
  async renewSession(session) {
262
304
  const now = Math.floor(Date.now() / 1000);
263
- let needsWrite = false;
305
+ const refreshed = {};
306
+ const slid = {};
264
307
  // Renew session.data once its shelf life has passed (opt-in
265
- // dataRefresh). Records from before the feature was enabled have no
266
- // 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.
267
310
  if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
268
311
  let newData;
269
312
  try {
@@ -278,45 +321,58 @@ export default class LambderSessionManager {
278
321
  await this.deleteSession(session);
279
322
  return null;
280
323
  }
281
- session.data = newData;
282
- session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
283
- needsWrite = true;
324
+ refreshed.data = newData;
325
+ refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
284
326
  }
285
- // Update last accessed time if sliding expiration is enabled.
286
- // Throttled: skip the store write when the session was refreshed
287
- // recently, to avoid a write on every request. A due data renewal
288
- // 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.
289
330
  if (this.enableSlidingExpiration) {
290
331
  const minInterval = this.slidingWriteIntervalSeconds
291
332
  ?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
292
- if (needsWrite || now - (session.lastAccessedAt || 0) >= minInterval) {
293
- session.lastAccessedAt = now;
294
- session.expiresAt = now + session.ttlInSeconds;
295
- needsWrite = true;
333
+ if (refreshed.data !== undefined || now - (session.lastAccessedAt || 0) >= minInterval) {
334
+ slid.lastAccessedAt = now;
335
+ slid.expiresAt = now + session.ttlInSeconds;
296
336
  }
297
337
  }
298
- if (needsWrite) {
299
- // Wait for the update to ensure it persists before Lambda freezes.
300
- // A failed put is not fatal: the data served is fresh, and an
301
- // unpersisted renewal simply runs again on the next read. It is
302
- // still logged, because a store that fails every renewal write
303
- // means sliding expiration has quietly stopped working and every
304
- // session now ends at its creation TTL, which otherwise shows up
305
- // only as users being signed out sooner than the app promises.
306
- // The reason only, never the record or the token: a log line is
307
- // not the place for anything that identifies a session.
308
- await this.store.put(session).catch((err) => {
309
- console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
310
- });
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;
311
361
  }
312
- 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;
313
367
  }
314
368
  ;
315
369
  /**
316
- * Runs the dataRefresh callback now, regardless of dataExpiresAt, and
317
- * persists the result onto the same record. Returns the updated session,
318
- * or null when the callback ended it (the record is deleted). Requires
319
- * 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.
320
376
  */
321
377
  async refreshSessionData(session) {
322
378
  if (!this.dataRefresh)
@@ -334,23 +390,21 @@ export default class LambderSessionManager {
334
390
  await this.deleteSession(session);
335
391
  return null;
336
392
  }
337
- const now = Math.floor(Date.now() / 1000);
338
- session.data = newData;
339
- session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
340
- session.lastAccessedAt = now;
341
- if (this.enableSlidingExpiration) {
342
- session.expiresAt = now + session.ttlInSeconds;
343
- }
344
- await this.store.put(session);
345
- 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 } : {}) };
346
400
  }
347
401
  ;
348
402
  /**
349
403
  * Checks a record against the session token presented with it: the
350
404
  * partition hash and the bearer secret the cookie carries, plus the
351
- * structural checks and the expiry. This is the half a route needs, and
352
- * the half lookupSession has already proved for a record it just found by
353
- * 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.
354
408
  */
355
409
  async isSessionTokenValid(session, sessionToken) {
356
410
  if (!session)
@@ -378,9 +432,8 @@ export default class LambderSessionManager {
378
432
  /**
379
433
  * Checks a record against the CSRF token the request posted: the other
380
434
  * half, asked of an API call and not of a route. Separate methods rather
381
- * than one with a skip flag, because a boolean at the call site says
382
- * nothing about which half it turns off, and the two are asked in
383
- * 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.
384
437
  */
385
438
  async isSessionCsrfTokenValid(session, csrfToken) {
386
439
  if (!session?.csrfTokenHash)
@@ -389,61 +442,133 @@ export default class LambderSessionManager {
389
442
  return false;
390
443
  return this.crypto.constantTimeEqual(session.csrfTokenHash, await this.hashToken(csrfToken));
391
444
  }
445
+ /** Deletes the session; false when there was none left to delete. */
392
446
  async deleteSession(session) {
393
- await this.store.delete(session.sessionKeyHash, session.secretHash);
394
- return true;
447
+ return (await this.store.delete(session.sessionKeyHash, session.secretHash)) !== null;
395
448
  }
396
449
  ;
397
- /** 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
+ */
398
455
  async deleteSessionAll(session) {
399
- await this.deleteAllUnder(session.sessionKeyHash);
400
- return true;
456
+ return await this.deleteAllUnder(session.sessionKeyHash);
401
457
  }
402
458
  ;
403
459
  /**
404
460
  * Deletes every session created for the given sessionKey (e.g. a user
405
461
  * id): "log this subject out everywhere", without needing a fetched
406
- * session record.
462
+ * session record. False when a rotation racing it may have left a
463
+ * session behind (see deleteAllUnder).
407
464
  */
408
465
  async deleteSessionAllByKey(sessionKey) {
409
- await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
410
- return true;
466
+ return await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
411
467
  }
412
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
+ */
413
480
  async deleteAllUnder(sessionKeyHash) {
414
- for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
415
- 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));
416
503
  }
417
504
  }
418
505
  /**
419
506
  * Marks the data of every session of the given sessionKey stale, so each
420
507
  * renews via dataRefresh on its next read: "this subject's roles or
421
- * permissions changed, apply it now", without logging the subject out
422
- * (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
423
510
  * dataExpiresAt only, on records that still exist, so it neither
424
511
  * resurrects a session deleted in between nor overwrites a concurrent
425
- * 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.
426
517
  */
427
518
  async expireSessionDataAllByKey(sessionKey) {
428
519
  if (!this.dataRefresh)
429
520
  throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
430
521
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
431
522
  const now = Math.floor(Date.now() / 1000);
432
- for (const secretHash of await this.store.listSecretHashes(sessionKeyHash)) {
433
- await this.store.markDataExpired(sessionKeyHash, secretHash, now);
434
- }
523
+ // An update, so a session deleted in between stays deleted.
524
+ await this.forEachSessionOf(sessionKeyHash, (secretHash) => this.store.update(sessionKeyHash, secretHash, { dataExpiresAt: now }));
435
525
  return true;
436
526
  }
437
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
+ */
438
550
  async regenerateSession(session) {
439
551
  if (!session)
440
552
  throw new Error("Invalid session");
441
- // Delete old session
442
- await this.deleteSession(session);
443
- // Create new session with same sessionKey and data but new tokens.
444
- // The data freshness stamp carries over: rotating tokens must not
445
- // extend how long dataRefresh-managed data may stay unrenewed.
446
- 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 } };
447
572
  }
448
573
  }
449
574
  ;