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,14 +1,19 @@
1
1
  import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
2
2
  import { isMintedSessionToken } from "./LambderSessionManager.js";
3
3
  /**
4
- * No session for this request: the cookies named none, or the single session
5
- * they named did not pair with the posted CSRF token.
4
+ * No session for this request: the cookies named none, the single session
5
+ * they named did not pair with the posted CSRF token, they cannot be
6
+ * resolved to one session (LambderSessionAmbiguousError, the one case with a
7
+ * type of its own), or the session was ended while the request held it (a
8
+ * logout or a password change elsewhere, or the dataRefresh callback).
6
9
  *
7
- * Typed rather than a bare Error because fetchSessionIfExists has to tell
8
- * "there is no session here" apart from "something broke". Everything else,
9
- * a TypeError from a custom store, a bug in an app's dataRefresh callback,
10
- * propagates and becomes a crash: answering sessionExpired for a defect makes
11
- * the client clear its cookies and turns somebody's bug into a logout.
10
+ * Typed so fetchSessionIfExists can tell "no session here" from "something
11
+ * broke", and so can every place that answers a request which needed a
12
+ * session and has none (the API pipeline, a route or a hook on the server):
13
+ * each tests for this class, and the ambiguous case with it. Everything else
14
+ * (a TypeError from a custom store, a bug in a dataRefresh callback)
15
+ * propagates as a crash: answering sessionExpired for a defect makes the
16
+ * client clear its cookies and turns a bug into a logout.
12
17
  */
13
18
  export class LambderSessionNotFoundError extends Error {
14
19
  constructor(message = "Session not found") {
@@ -18,39 +23,40 @@ export class LambderSessionNotFoundError extends Error {
18
23
  }
19
24
  /**
20
25
  * The request's session cookies cannot be resolved to one session, so none of
21
- * them is used and every scope this host can write is cleared. Also a "no
22
- * session" answer to the caller, and deliberately a different type: this one
23
- * carries the clearing Set-Cookie headers that heal the state, and it is the
24
- * one worth finding in a log.
26
+ * them is used and every scope this host can write is cleared. A request with
27
+ * no usable session, so it is a LambderSessionNotFoundError and is answered
28
+ * as one wherever that is (a 401 or the session-expired route answer, the
29
+ * sessionExpired envelope on an API call). The subclass keeps it apart for
30
+ * whoever wants to tell: this case carries the clearing Set-Cookie headers
31
+ * that heal the state, and it is the one worth finding in a log.
25
32
  */
26
- export class LambderSessionAmbiguousError extends Error {
33
+ export class LambderSessionAmbiguousError extends LambderSessionNotFoundError {
27
34
  constructor(message = "Session ambiguous") {
28
35
  super(message);
29
36
  this.name = "LambderSessionAmbiguousError";
30
37
  }
31
38
  }
32
- /** The tokens are hex, so the cookie carries them as they are (the format existing browsers hold). */
39
+ /** The tokens are hex, so the cookie carries them unencoded, in the format browsers already hold. */
33
40
  const rawCookieValue = (value) => value;
34
41
  /**
35
42
  * A `__Host-` cookie is the browser's own answer to a sibling subdomain
36
43
  * planting a session cookie at a parent domain: it refuses one that carries a
37
44
  * Domain, so no other host can write it. `__Secure-` is the weaker sibling,
38
- * accepted only on a Secure cookie. Both protections fail silently, though: a
39
- * browser handed a prefixed name with an attribute the prefix forbids simply
40
- * discards the cookie, and the app looks like it has no sessions at all
41
- * rather than like it is misconfigured. So the combinations are rejected at
42
- * creation instead.
45
+ * accepted only on a Secure cookie. Both fail silently: a browser discards a
46
+ * prefixed cookie with an attribute the prefix forbids, and the app looks
47
+ * like it has no sessions rather than like it is misconfigured. So the
48
+ * combinations are rejected at creation.
43
49
  *
44
50
  * Session policy, so it lives beside the controller that writes the cookies
45
- * rather than in the pipeline that happens to call it.
51
+ * rather than in the pipeline that calls it.
46
52
  */
47
53
  export const assertSessionCookiePrefixes = (sessions) => {
48
54
  const keys = [sessions.tokenCookieKey, sessions.csrfCookieKey];
49
55
  const hostPrefixed = keys.filter((key) => key.startsWith("__Host-"));
50
56
  const securePrefixed = keys.filter((key) => key.startsWith("__Secure-"));
51
57
  if (sessions.cookieOptions.secure === false) {
52
- // Both prefixes require Secure, so this one check covers them
53
- // together; the messages stay separate because the fix differs.
58
+ // Both prefixes require Secure, so one check covers them; the
59
+ // messages stay separate so each names its own prefix.
54
60
  if (hostPrefixed.length > 0) {
55
61
  throw new Error(`Lambder: session cookie ${hostPrefixed.join(" and ")} uses the __Host- prefix, which a browser accepts only on a Secure cookie. ` +
56
62
  "Drop session.cookie.secure: false, or drop the prefix; keeping both means the browser discards the cookie silently and no session is ever read.");
@@ -74,21 +80,21 @@ export const assertSessionCookiePrefixes = (sessions) => {
74
80
  /**
75
81
  * How many copies of the session cookie one request may carry before the
76
82
  * request is treated as ambiguous. A name legitimately arrives at a few
77
- * scopes at once (a Domain change mid-migration leaves a host-only twin),
78
- * and no browser has a reason to send more. Beyond this the request is
79
- * refused rather than trimmed: dropping the extras would let anyone who can
80
- * plant cookies at a parent domain push the visitor's own copy out of the
81
- * read and log them out silently, with no eviction emitted, so it would
82
- * never heal. Nothing is read from the store on that path.
83
+ * scopes (a Domain change mid-migration leaves a host-only twin), and no
84
+ * browser has a reason to send more. Beyond this the request is refused
85
+ * rather than trimmed: dropping extras would let anyone who can plant cookies
86
+ * at a parent domain push the visitor's own copy out of the read and log them
87
+ * out silently, with no eviction emitted, so it would never heal. Nothing is
88
+ * read from the store on that path.
83
89
  */
84
90
  const MAX_SESSION_TOKEN_CANDIDATES = 4;
85
91
  /**
86
92
  * Sessions as one request sees them: reads the session the request's
87
- * cookies name onto the context, and writes the cookies a created,
88
- * rotated or ended session needs into the context's response headers.
89
- * Server handlers reach it through lambder.getSessionController(ctx); mock
90
- * handlers through ctx.sessions. It works on the call context and the
91
- * request info alone, so it is one class for both.
93
+ * cookies name onto the context, and writes the cookies a created, rotated
94
+ * or ended session needs into the context's response headers. Server and
95
+ * mock handlers reach it as ctx.sessionController (the server's also through
96
+ * lambder.getSessionController(ctx)); it needs only the call context and
97
+ * request info, so one class serves both.
92
98
  */
93
99
  export default class LambderSessionController {
94
100
  manager;
@@ -113,19 +119,23 @@ export default class LambderSessionController {
113
119
  }
114
120
  ;
115
121
  /**
116
- * Both cookies at one expiry, with the raw secrets. They exist only on the
117
- * LambderCreatedSession result, in these cookies and in the request that
118
- * carried them back; the record stores hashes. `csrfToken` is null where
119
- * the raw CSRF value is not known to this request, in which case only the
120
- * session cookie is written: writing a CSRF cookie whose value does not
121
- * pair with the session would break the very session it is refreshing.
122
+ * Both cookies at one expiry, with the raw secrets, which exist only on
123
+ * the LambderCreatedSession result, in these cookies and in requests that
124
+ * carry them back; the record stores hashes. `csrfToken` is null when this
125
+ * request does not know the raw CSRF value, and then only the session
126
+ * cookie is written: a CSRF cookie that does not pair with the session
127
+ * would break the session it is refreshing.
122
128
  */
123
129
  writeSessionCookies(expiresAt, sessionToken, csrfToken) {
124
130
  const scope = this.cookieScope();
125
131
  const expires = new Date(expiresAt * 1000);
126
- this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.tokenCookieKey, sessionToken, { ...scope, expires, httpOnly: true, encode: rawCookieValue }));
132
+ // Max-Age beside Expires: a browser that knows Max-Age counts from
133
+ // receipt rather than from a date, so a device whose clock runs ahead
134
+ // does not drop a short-lived session early.
135
+ const maxAge = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
136
+ this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.tokenCookieKey, sessionToken, { ...scope, expires, maxAge, httpOnly: true, encode: rawCookieValue }));
127
137
  if (csrfToken !== null)
128
- this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.csrfCookieKey, csrfToken, { ...scope, expires, encode: rawCookieValue }));
138
+ this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.csrfCookieKey, csrfToken, { ...scope, expires, maxAge, encode: rawCookieValue }));
129
139
  }
130
140
  ;
131
141
  setSessionCookies(created) {
@@ -133,10 +143,9 @@ export default class LambderSessionController {
133
143
  }
134
144
  ;
135
145
  /**
136
- * The deleting pair for one scope. Written once because a deletion only
137
- * reaches a cookie carrying the same Domain and Path, so the pair is
138
- * emitted per scope and the two callers differ in nothing but which
139
- * scopes they walk.
146
+ * The deleting pair for one scope. A deletion only reaches a cookie with
147
+ * the same Domain and Path, so the pair is emitted per scope; the two
148
+ * callers differ only in which scopes they walk.
140
149
  */
141
150
  addClearCookiePair(scope) {
142
151
  this.ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(this.tokenCookieKey, { ...scope, httpOnly: true }));
@@ -150,10 +159,10 @@ export default class LambderSessionController {
150
159
  /**
151
160
  * Every Domain this host is allowed to write the session cookies at: the
152
161
  * host-only scope, the configured one, and each parent domain of the
153
- * request host. A deletion matches only a cookie carrying the same
154
- * Domain, so evicting a copy the app itself never set needs all of them.
155
- * A browser ignores a Domain it will not accept, which is why a suffix
156
- * the registry owns can be offered without checking a public-suffix list.
162
+ * request host. A deletion matches only a cookie with the same Domain, so
163
+ * evicting a copy the app never set needs all of them. A browser ignores
164
+ * a Domain it will not accept, so a registry-owned suffix can be offered
165
+ * without checking a public-suffix list.
157
166
  */
158
167
  cookieClearDomains() {
159
168
  const hostname = (this.request.host.split(":")[0] ?? "").toLowerCase();
@@ -174,13 +183,12 @@ export default class LambderSessionController {
174
183
  }
175
184
  ;
176
185
  /**
177
- * Clears the session cookies at every scope this host can reach, rather
178
- * than at the one the app configured. Used when a request carries more
179
- * than one live session: the copy that has to go may sit at a parent
180
- * domain a sibling host planted it at, and clearing the configured scope
181
- * alone would evict this visitor's own cookie and leave the planted one
182
- * as the only survivor, which completes the takeover instead of stopping
183
- * it.
186
+ * Clears the session cookies at every scope this host can reach, not just
187
+ * the configured one. Used when a request carries more than one live
188
+ * session: the copy that has to go may sit at a parent domain where a
189
+ * sibling host planted it, and clearing only the configured scope would
190
+ * evict the visitor's own cookie and leave the planted one as the sole
191
+ * survivor, completing the takeover instead of stopping it.
184
192
  */
185
193
  clearSessionCookiesEverywhere() {
186
194
  const base = this.cookieScope(true);
@@ -191,10 +199,8 @@ export default class LambderSessionController {
191
199
  ;
192
200
  /**
193
201
  * Refuses a request whose session cookies cannot be resolved to one
194
- * session, clearing every scope this host can write. Clearing only the
195
- * configured scope would be worse than picking one: a deletion matches
196
- * only a cookie carrying the same Domain, so it would evict the visitor's
197
- * own copy and leave a planted one as the sole survivor.
202
+ * session, clearing every scope this host can write (see
203
+ * clearSessionCookiesEverywhere for why not just the configured one).
198
204
  *
199
205
  * Path is the one dimension this cannot sweep: the request info carries
200
206
  * no path, and a deletion matches only a cookie at the same Path, so a
@@ -214,12 +220,11 @@ export default class LambderSessionController {
214
220
  * Both session cookie names read in one pass: every well-formed value
215
221
  * under the token name, and every value under the CSRF name.
216
222
  *
217
- * The CSRF cookie is counted here rather than looked at only when a token
218
- * is checked against it, because it is plantable exactly like the session
219
- * cookie and the browser picks between copies without telling anyone: the
220
- * client reads its CSRF token with js-cookie's Cookies.get, which returns
221
- * the FIRST copy in document.cookie, and a browser orders a longer Path
222
- * first. So a sibling host that plants one CSRF cookie at a parent domain
223
+ * The CSRF cookie is counted, not just checked against a token, because
224
+ * it is plantable like the session cookie and the browser picks between
225
+ * copies silently: the client reads it with js-cookie's Cookies.get, which
226
+ * returns the FIRST copy in document.cookie, and browsers order a longer
227
+ * Path first. A sibling host that plants a CSRF cookie at a parent domain
223
228
  * with a deeper Path decides which token every call posts, and the count
224
229
  * is the only thing that shows it.
225
230
  */
@@ -227,9 +232,8 @@ export default class LambderSessionController {
227
232
  const wellFormed = (this.request.cookies[this.tokenCookieKey] ?? []).filter(isMintedSessionToken);
228
233
  // Deduplicated: one value arriving twice (a proxy that appends rather
229
234
  // than merges Cookie, the same value set at two scopes) is one
230
- // session, and counting it twice would read as an ambiguity. Not
231
- // truncated: the count past the cap is the caller's answer, not
232
- // something to trim away (see MAX_SESSION_TOKEN_CANDIDATES).
235
+ // session, not an ambiguity. Not truncated: a count past the cap is
236
+ // itself the answer (see MAX_SESSION_TOKEN_CANDIDATES).
233
237
  return {
234
238
  sessionTokens: [...new Set(wellFormed)],
235
239
  csrfTokens: [...new Set(this.request.cookies[this.csrfCookieKey] ?? [])],
@@ -250,9 +254,9 @@ export default class LambderSessionController {
250
254
  }
251
255
  ;
252
256
  /**
253
- * createSession, handing back the raw tokens beside the session: what a
254
- * test or a mock runtime needs to plant the cookies somewhere else (a
255
- * cookie jar) than this call's response.
257
+ * createSession, handing back the raw tokens beside the session, for a
258
+ * test or mock runtime that plants the cookies somewhere other than this
259
+ * call's response (a cookie jar).
256
260
  */
257
261
  async issueSession(sessionKey, data, ttlInSeconds) {
258
262
  const created = await this.manager.createSession(sessionKey, data, ttlInSeconds);
@@ -266,16 +270,17 @@ export default class LambderSessionController {
266
270
  }
267
271
  ;
268
272
  /**
269
- * regenerateSession, handing back the raw tokens beside the session, the
270
- * way issueSession does for a new one. Rotating the session mints a new
271
- * CSRF token, and a client that holds its token rather than reading
272
- * document.cookie (a native app, an invoke caller) needs the new one to
273
- * keep calling.
273
+ * regenerateSession, handing back the raw tokens beside the session as
274
+ * issueSession does. Rotation mints a new CSRF token, and a client that
275
+ * holds its token rather than reading document.cookie (a native app, an
276
+ * invoke caller) needs the new one to keep calling.
274
277
  */
275
278
  async reissueSession() {
276
279
  if (!this.ctx.session)
277
280
  throw new LambderSessionNotFoundError();
278
281
  const created = await this.manager.regenerateSession(this.ctx.session);
282
+ if (!created)
283
+ this.endWithNoSession();
279
284
  this.setSessionCookies(created);
280
285
  this.ctx.session = created.session;
281
286
  return created;
@@ -289,34 +294,31 @@ export default class LambderSessionController {
289
294
  if (candidates.length > MAX_SESSION_TOKEN_CANDIDATES) {
290
295
  this.refuseAmbiguousSession(`more than ${MAX_SESSION_TOKEN_CANDIDATES} "${this.tokenCookieKey}" cookies arrived, at different scopes`);
291
296
  }
292
- // After the cap check, because this line says the store is about to
293
- // be read once per copy and over the cap nothing is read at all.
297
+ // After the cap check: this says the store is about to be read once
298
+ // per copy, and over the cap nothing is read.
294
299
  if (candidates.length > 1) {
295
300
  console.warn(`Lambder session: ${candidates.length} "${this.tokenCookieKey}" cookies arrived from ${this.request.host}; the browser holds the cookie at several scopes. Reading each.`);
296
301
  }
297
- // How many sessions the browser is holding is a question about the
298
- // cookies alone, so it is asked without the CSRF pairing. Folding the
299
- // pairing in here would answer a different question and always answer
300
- // it "one": a sibling subdomain plants its own CSRF cookie beside the
301
- // session it planted, only one CSRF token is ever posted, and no two
302
- // sessions share a csrfTokenHash, so exactly one candidate would
303
- // survive the pairing and the ambiguity this check exists to catch
304
- // would be invisible. The pairing is asked once, below, of whichever
305
- // single session the cookies resolved to.
302
+ // How many sessions the browser holds is a question about the cookies
303
+ // alone, so it is asked without the CSRF pairing. With the pairing
304
+ // folded in the answer would always be "one": a sibling subdomain
305
+ // plants its own CSRF cookie beside its planted session, only one
306
+ // CSRF token is posted, and no two sessions share a csrfTokenHash, so
307
+ // the ambiguity this check exists to catch would be invisible. The
308
+ // pairing is checked once, below, against the single resolved session.
306
309
  //
307
- // One live session and some stale copies is the ordinary case (a
308
- // cookie whose Domain or Path changed), and the live one wins. Two
309
- // LIVE sessions under one name is not ordinary: any sibling subdomain
310
- // can write a cookie at a parent domain that the browser then sends
311
- // alongside the real one, and taking either would sign this visitor
312
- // into an account that may not be theirs. There is no way to tell
313
- // which copy they meant, so neither is used.
310
+ // One live session plus stale copies is the ordinary case (a cookie
311
+ // whose Domain or Path changed), and the live one wins. Two LIVE
312
+ // sessions under one name is not: a sibling subdomain can write a
313
+ // cookie at a parent domain that the browser sends alongside the real
314
+ // one, and taking either could sign this visitor into an account that
315
+ // is not theirs. There is no telling which they meant, so neither is
316
+ // used.
314
317
  const live = [];
315
318
  for (const sessionToken of candidates) {
316
- // lookupSession finds the record BY the hash of this token's own
317
- // secret and checks the structure and the expiry on the way, so
318
- // possession is already proved here and re-checking the token
319
- // against the record would only hash the same secret twice.
319
+ // lookupSession finds the record BY the hash of this token's
320
+ // secret and checks structure and expiry, so possession is proved
321
+ // here; re-checking the token would hash the same secret twice.
320
322
  const candidate = await this.manager.lookupSession(sessionToken);
321
323
  if (!candidate)
322
324
  continue;
@@ -330,28 +332,24 @@ export default class LambderSessionController {
330
332
  }
331
333
  const found = live[0];
332
334
  if (!found)
333
- throw new LambderSessionNotFoundError();
334
- // The pairing check, once, against the one session the cookies
335
- // resolved to. An API call that did not post the matching CSRF token
336
- // has no session here.
335
+ this.endWithNoSession();
336
+ // The pairing check, once, against the resolved session. An API call
337
+ // that did not post the matching CSRF token has no session here.
337
338
  if (this.request.csrfToken !== null && !(await this.manager.isSessionCsrfTokenValid(found.session, this.request.csrfToken))) {
338
- // A session cookie that resolves while the posted CSRF token
339
- // belongs to nothing is the CSRF half of the planted-cookie
340
- // shape, and it is unhealable on its own: the client reads the
341
- // FIRST CSRF cookie in document.cookie, a longer Path sorts
342
- // first, so the planted copy keeps winning through the logout,
343
- // through the next sign-in, and through every call after it. The
344
- // ordinary answer (no session) emits no Set-Cookie at all, and
345
- // the client can only clear the scopes it knows, which are not
346
- // the ones a sibling host planted at. So the everywhere-clear
347
- // runs instead, and the state heals.
339
+ // A live session cookie with a posted CSRF token that pairs with
340
+ // nothing is the CSRF half of the planted-cookie shape, and it
341
+ // cannot heal on its own: the client posts the FIRST CSRF cookie
342
+ // in document.cookie, a longer Path sorts first, so a planted copy
343
+ // keeps winning through logout, the next sign-in and every call
344
+ // after. A plain no-session answer emits no Set-Cookie, and the
345
+ // client can only clear scopes it knows, not the ones a sibling
346
+ // host planted at. So the everywhere-clear runs instead.
348
347
  //
349
- // Only when the request actually carried CSRF cookies: an invoke
350
- // caller posts the CSRF value in the envelope and sends no CSRF
351
- // cookie at all, and its wrong token is an ordinary no-session.
352
- // And only when the cookies are the suspect: one CSRF cookie that
353
- // is the token posted, not pairing, is a stale pair the visitor
354
- // can clear themselves.
348
+ // Only when the request carried CSRF cookies: an invoke caller
349
+ // posts the value in the envelope with no CSRF cookie, and its
350
+ // wrong token is an ordinary no-session. And only when the cookies
351
+ // are the suspect: a single CSRF cookie equal to the posted token
352
+ // that does not pair is a stale pair the visitor can clear.
355
353
  if (csrfTokens.length > 1) {
356
354
  this.refuseAmbiguousSession(`more than one "${this.csrfCookieKey}" cookie arrived, at different scopes, and the one posted pairs with no session`);
357
355
  }
@@ -360,33 +358,32 @@ export default class LambderSessionController {
360
358
  }
361
359
  throw new LambderSessionNotFoundError();
362
360
  }
363
- // Renewed only now that this session is known to be the caller's:
364
- // a slide or a dataRefresh is a write on their behalf.
361
+ // Renewed only once this session is known to be the caller's: a
362
+ // slide or a dataRefresh is a write on their behalf.
365
363
  const expiresBefore = found.session.expiresAt;
366
364
  const session = await this.manager.renewSession(found.session);
365
+ // Ended while this request read it (a logout, a password change), or
366
+ // by its own dataRefresh: no session either way.
367
367
  if (!session)
368
- throw new LambderSessionNotFoundError();
369
- // The other copies are stale. This response can evict the
370
- // host-only twin of a Domain= cookie; a copy at a parent domain
371
- // this host cannot name is out of reach and expires on its own.
368
+ this.endWithNoSession();
369
+ // The other copies are stale. This response can evict the host-only
370
+ // twin of a Domain= cookie; a copy at a parent domain this host
371
+ // cannot name is out of reach and expires on its own.
372
372
  //
373
- // Which copy is the stale one is an assumption, not a fact: the
374
- // request carries no scope, so the twin being deleted may be the live
375
- // cookie this very read resolved, held by a visitor who signed in
376
- // before the app configured a domain. So the eviction always ships
377
- // with the replacement, at the configured scope, and the visitor
378
- // stays signed in either way. It also lets a domain migration
379
- // converge on the first request rather than on the first slide.
373
+ // Which copy is stale is an assumption: the request carries no scope,
374
+ // so the twin being deleted may be the very cookie this read resolved
375
+ // (a visitor who signed in before the app configured a domain). So the
376
+ // eviction always ships with the replacement at the configured scope,
377
+ // and the visitor stays signed in either way. It also lets a domain
378
+ // migration converge on the first request rather than the first slide.
380
379
  const evictsHostOnlyTwin = candidates.length > 1 && !!this.cookieScope().domain;
381
380
  if (evictsHostOnlyTwin)
382
381
  this.clearSessionCookies(true);
383
- // A sliding write moved the record's expiry, so the cookies have to
384
- // move with it. Without this the browser keeps the Expires it was
385
- // given at creation and drops both cookies at createdAt + ttl, so a
386
- // visitor who never stops using the app is signed out anyway, on the
387
- // one deadline sliding expiration exists to push back. Throttled by
388
- // the same interval as the write, so an active session re-issues its
389
- // cookies at most that often and not on every request.
382
+ // A sliding write moved the record's expiry, so the cookies move with
383
+ // it. Otherwise the browser keeps the creation-time Expires and signs
384
+ // out an active visitor at createdAt + ttl, the very deadline sliding
385
+ // expiration exists to push back. Throttled with the write, so an
386
+ // active session re-issues its cookies at most that often.
390
387
  if (evictsHostOnlyTwin || session.expiresAt !== expiresBefore)
391
388
  await this.slideSessionCookies(session, found.token, csrfTokens);
392
389
  this.ctx.session = session;
@@ -395,17 +392,16 @@ export default class LambderSessionController {
395
392
  ;
396
393
  /**
397
394
  * Re-issues both cookies at this session's expiry: after a sliding write
398
- * moved it, and beside the host-only eviction above, which would
399
- * otherwise delete a cookie without replacing it.
395
+ * moved it, and beside the host-only eviction, which would otherwise
396
+ * delete a cookie without replacing it.
400
397
  *
401
- * The raw CSRF value is the posted one on an API call, which the pairing
402
- * check above has just matched against this session. A route posts none,
403
- * so the single arriving CSRF cookie stands in, and only once it is known
404
- * to pair: re-issuing an unpaired value would overwrite this visitor's
405
- * real CSRF cookie with a planted one, at the app's own scope, which is
406
- * the takeover the scan exists to prevent. Where neither is available the
407
- * session cookie slides alone, which is the half that decides whether the
408
- * session survives.
398
+ * On an API call the raw CSRF value is the posted one, which the pairing
399
+ * check just matched. A route posts none, so the single arriving CSRF
400
+ * cookie stands in, but only once it is known to pair: re-issuing an
401
+ * unpaired value would overwrite the visitor's real CSRF cookie with a
402
+ * planted one at the app's own scope, the takeover the scan exists to
403
+ * prevent. Where neither is available the session cookie slides alone;
404
+ * it is the half that decides whether the session survives.
409
405
  */
410
406
  async slideSessionCookies(session, sessionToken, csrfTokens) {
411
407
  const posted = this.request.csrfToken;
@@ -418,49 +414,72 @@ export default class LambderSessionController {
418
414
  this.writeSessionCookies(session.expiresAt, sessionToken, paired ? only : null);
419
415
  }
420
416
  ;
417
+ /**
418
+ * "No session" for a request whose session cookie names none, or whose
419
+ * session ended while it held it: the context holds none.
420
+ *
421
+ * The cookies are left alone. A deletion matches a cookie by name, not by
422
+ * value, so clearing here would also delete a session another response
423
+ * has just set: a poll sent with the old cookie, answering after a login,
424
+ * a rotation or a password change, would sign the person straight out of
425
+ * the new session. It also keeps the client's own check working, which
426
+ * clears the CSRF cookie only while it still holds the token the call
427
+ * sent. A dead cookie costs a store read per request until it expires.
428
+ */
429
+ endWithNoSession() {
430
+ this.ctx.session = null;
431
+ throw new LambderSessionNotFoundError();
432
+ }
433
+ ;
421
434
  async fetchSessionIfExists() {
422
435
  try {
423
436
  return await this.fetchSession();
424
437
  }
425
438
  catch (err) {
426
- // Only the two "no session" exits become null. A failing
427
- // dataRefresh callback, a store read failure, and anything
428
- // unexpected (a TypeError from a custom store, a bug in this
429
- // layer) propagate: answering sessionExpired for a defect makes
430
- // the client clear its cookies, so a crash would present as a
431
- // logout and the log would say nothing happened.
439
+ // Only a "no session" exit becomes null, the ambiguous one
440
+ // included. A failing dataRefresh, a store read failure and
441
+ // anything unexpected propagate: answering sessionExpired for a
442
+ // defect makes the client clear its cookies, so a crash would
443
+ // present as a logout with nothing in the log.
432
444
  if (err instanceof LambderSessionNotFoundError)
433
445
  return null;
434
- if (err instanceof LambderSessionAmbiguousError)
435
- return null;
436
446
  throw err;
437
447
  }
438
448
  }
439
449
  ;
450
+ /**
451
+ * Writes new data onto the current session. Throws
452
+ * LambderSessionNotFoundError when the session was ended while this
453
+ * request held it: the write does not bring it back, and an API call
454
+ * answers sessionExpired.
455
+ */
440
456
  async updateSessionData(newData) {
441
457
  if (!this.ctx.session)
442
458
  throw new LambderSessionNotFoundError();
443
- this.ctx.session = await this.manager.updateSessionData(this.ctx.session, newData);
444
- return this.ctx.session;
459
+ const updated = await this.manager.updateSessionData(this.ctx.session, newData);
460
+ if (!updated)
461
+ this.endWithNoSession();
462
+ this.ctx.session = updated;
463
+ return updated;
445
464
  }
446
465
  ;
447
466
  /**
448
- * Force-runs the dataRefresh callback now (see the session option of create) and
449
- * persists the result onto the current session. Returns the updated
450
- * session, or null when the callback ended it: the record is deleted and
451
- * the session cookies are cleared.
467
+ * Force-runs the dataRefresh callback now (see the session option of
468
+ * create) and persists the result onto the current session. Throws
469
+ * LambderSessionNotFoundError when the session is over, because the
470
+ * callback ended it (the record is deleted) or because it was ended while
471
+ * this request held it: the same "no session" a read gives for either,
472
+ * with the cookies left alone (see endWithNoSession), and what an API
473
+ * call answers as sessionExpired.
452
474
  */
453
475
  async refreshSessionData() {
454
476
  if (!this.ctx.session)
455
477
  throw new LambderSessionNotFoundError();
456
478
  const refreshed = await this.manager.refreshSessionData(this.ctx.session);
457
- if (!refreshed) {
458
- this.clearSessionCookies();
459
- this.ctx.session = null;
460
- return null;
461
- }
479
+ if (!refreshed)
480
+ this.endWithNoSession();
462
481
  this.ctx.session = refreshed;
463
- return this.ctx.session;
482
+ return refreshed;
464
483
  }
465
484
  ;
466
485
  /**
@@ -2,7 +2,7 @@
2
2
  * The cryptography the session model runs on, behind an interface so the
3
3
  * manager itself has no Node dependency: the bearer secrets are hashed at
4
4
  * rest, compared in constant time, and minted from a cryptographic random
5
- * source.
5
+ * source, and the sessionKey is hashed under the salt as its key.
6
6
  *
7
7
  * LambderWebCrypto is the default and runs on Node 20+, every browser on a
8
8
  * secure context, and edge runtimes. LambderPlainSessionCrypto is the
@@ -23,12 +23,14 @@ export interface LambderSessionCrypto {
23
23
  */
24
24
  readonly isCryptographic: boolean;
25
25
  sha256Hex(value: string): Promise<string>;
26
+ /** HMAC-SHA256 of `value` under `key` (both UTF-8), as hex: the salted partition hash of a sessionKey. */
27
+ hmacSha256Hex(key: string, value: string): Promise<string>;
26
28
  randomHex(bytes: number): Promise<string>;
27
29
  constantTimeEqual(a: string, b: string): boolean;
28
30
  }
29
31
  /** True when this runtime offers WebCrypto's subtle API (secure contexts in browsers; Node 20+). */
30
32
  export declare const isWebCryptoAvailable: () => boolean;
31
- /** sha256 through crypto.subtle and randomness through getRandomValues: the default. */
33
+ /** sha256 and HMAC through crypto.subtle and randomness through getRandomValues: the default. */
32
34
  export declare class LambderWebCrypto implements LambderSessionCrypto {
33
35
  readonly isCryptographic = true;
34
36
  private cryptoPromise;
@@ -42,14 +44,15 @@ export declare class LambderWebCrypto implements LambderSessionCrypto {
42
44
  * The runtime's WebCrypto, through the resolver every layer shares, with
43
45
  * Node's crypto warmed alongside it.
44
46
  *
45
- * The availability question is asked here, before the shared resolver,
46
- * only because of the answer a session has to it: a runtime with neither
47
- * a global crypto nor Node's webcrypto can still run sessions over
48
- * LambderPlainSessionCrypto and a memory store, which is this layer's own
49
- * way out and not something the shared message can know about.
47
+ * Availability is checked here, before the shared resolver, so the error
48
+ * can name this layer's own way out: a runtime with neither a global
49
+ * crypto nor Node's webcrypto can still run sessions over
50
+ * LambderPlainSessionCrypto and a memory store, which the shared
51
+ * resolver's message cannot know about.
50
52
  */
51
53
  private ready;
52
54
  sha256Hex(value: string): Promise<string>;
55
+ hmacSha256Hex(key: string, value: string): Promise<string>;
53
56
  randomHex(bytes: number): Promise<string>;
54
57
  constantTimeEqual(a: string, b: string): boolean;
55
58
  }
@@ -61,6 +64,12 @@ export declare class LambderWebCrypto implements LambderSessionCrypto {
61
64
  export declare class LambderPlainSessionCrypto implements LambderSessionCrypto {
62
65
  readonly isCryptographic = false;
63
66
  sha256Hex(value: string): Promise<string>;
67
+ /**
68
+ * The key and the value hex-encoded as a JSON pair rather than run
69
+ * together, so the pair stays unambiguous the way a keyed hash keeps it:
70
+ * no key and value can pass for another split of the same text.
71
+ */
72
+ hmacSha256Hex(key: string, value: string): Promise<string>;
64
73
  randomHex(bytes: number): Promise<string>;
65
74
  constantTimeEqual(a: string, b: string): boolean;
66
75
  }