lambder 6.0.2 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -1,99 +1,421 @@
1
- import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../core/LambderCookie.js";
2
- import { LambderSessionDataRefreshError, LambderSessionReadError } from "./LambderSessionManager.js";
1
+ import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
2
+ import { isMintedSessionToken } from "./LambderSessionManager.js";
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.
6
+ *
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.
12
+ */
13
+ export class LambderSessionNotFoundError extends Error {
14
+ constructor(message = "Session not found") {
15
+ super(message);
16
+ this.name = "LambderSessionNotFoundError";
17
+ }
18
+ }
19
+ /**
20
+ * 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.
25
+ */
26
+ export class LambderSessionAmbiguousError extends Error {
27
+ constructor(message = "Session ambiguous") {
28
+ super(message);
29
+ this.name = "LambderSessionAmbiguousError";
30
+ }
31
+ }
3
32
  /** The tokens are hex, so the cookie carries them as they are (the format existing browsers hold). */
4
- const rawValue = (value) => value;
33
+ const rawCookieValue = (value) => value;
34
+ /**
35
+ * A `__Host-` cookie is the browser's own answer to a sibling subdomain
36
+ * planting a session cookie at a parent domain: it refuses one that carries a
37
+ * 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.
43
+ *
44
+ * Session policy, so it lives beside the controller that writes the cookies
45
+ * rather than in the pipeline that happens to call it.
46
+ */
47
+ export const assertSessionCookiePrefixes = (sessions) => {
48
+ const keys = [sessions.tokenCookieKey, sessions.csrfCookieKey];
49
+ const hostPrefixed = keys.filter((key) => key.startsWith("__Host-"));
50
+ const securePrefixed = keys.filter((key) => key.startsWith("__Secure-"));
51
+ 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.
54
+ if (hostPrefixed.length > 0) {
55
+ throw new Error(`Lambder: session cookie ${hostPrefixed.join(" and ")} uses the __Host- prefix, which a browser accepts only on a Secure cookie. ` +
56
+ "Drop session.cookie.secure: false, or drop the prefix; keeping both means the browser discards the cookie silently and no session is ever read.");
57
+ }
58
+ if (securePrefixed.length > 0) {
59
+ throw new Error(`Lambder: session cookie ${securePrefixed.join(" and ")} uses the __Secure- prefix, which a browser accepts only on a Secure cookie. ` +
60
+ "Drop session.cookie.secure: false, or drop the prefix; keeping both means the browser discards the cookie silently and no session is ever read.");
61
+ }
62
+ }
63
+ if (hostPrefixed.length === 0)
64
+ return;
65
+ if (sessions.cookieOptions.domain !== undefined) {
66
+ throw new Error(`Lambder: session cookie ${hostPrefixed.join(" and ")} uses the __Host- prefix, which a browser accepts only without a Domain. ` +
67
+ "Drop session.cookie.domain, or drop the prefix; keeping both means the browser discards the cookie and no session is ever read.");
68
+ }
69
+ if (sessions.cookieOptions.path !== undefined && sessions.cookieOptions.path !== "/") {
70
+ throw new Error(`Lambder: session cookie ${hostPrefixed.join(" and ")} uses the __Host- prefix, which a browser accepts only at Path=/. ` +
71
+ `Drop session.cookie.path, or drop the prefix.`);
72
+ }
73
+ };
74
+ /**
75
+ * How many copies of the session cookie one request may carry before the
76
+ * 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
+ */
84
+ const MAX_SESSION_TOKEN_CANDIDATES = 4;
85
+ /**
86
+ * 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.
92
+ */
5
93
  export default class LambderSessionController {
6
- lambderSessionManager;
7
- sessionTokenCookieKey;
8
- sessionCsrfCookieKey;
94
+ manager;
95
+ tokenCookieKey;
96
+ csrfCookieKey;
9
97
  cookieOptions;
10
- ctx; // Internal context with mutable session property
11
- constructor({ lambderSessionManager, sessionTokenCookieKey, sessionCsrfCookieKey, cookieOptions, ctx, }) {
12
- this.lambderSessionManager = lambderSessionManager;
13
- this.sessionTokenCookieKey = sessionTokenCookieKey;
14
- this.sessionCsrfCookieKey = sessionCsrfCookieKey;
98
+ ctx;
99
+ request;
100
+ constructor({ manager, tokenCookieKey, csrfCookieKey, cookieOptions, ctx, request }) {
101
+ this.manager = manager;
102
+ this.tokenCookieKey = tokenCookieKey;
103
+ this.csrfCookieKey = csrfCookieKey;
15
104
  this.cookieOptions = cookieOptions ?? {};
16
105
  this.ctx = ctx;
106
+ this.request = request;
17
107
  }
18
108
  ;
19
109
  /** The configured scope with the domain resolved for this request, or the host-only scope. */
20
110
  cookieScope(hostOnly = false) {
21
111
  const { domain, path, sameSite, secure } = this.cookieOptions;
22
- return { domain: hostOnly ? undefined : resolveCookieDomain(domain, this.ctx.host), path, sameSite, secure };
112
+ return { domain: hostOnly ? undefined : resolveCookieDomain(domain, this.request.host), path, sameSite, secure };
23
113
  }
24
114
  ;
25
- /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
26
- setSessionCookies(created) {
115
+ /**
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
+ */
123
+ writeSessionCookies(expiresAt, sessionToken, csrfToken) {
27
124
  const scope = this.cookieScope();
28
- const expires = new Date(created.session.expiresAt * 1000);
29
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionTokenCookieKey, created.sessionToken, { ...scope, expires, httpOnly: true, encode: rawValue }) });
30
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionCsrfCookieKey, created.csrfToken, { ...scope, expires, encode: rawValue }) });
125
+ const expires = new Date(expiresAt * 1000);
126
+ this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.tokenCookieKey, sessionToken, { ...scope, expires, httpOnly: true, encode: rawCookieValue }));
127
+ if (csrfToken !== null)
128
+ this.ctx.responseHeaders.add("Set-Cookie", serializeCookie(this.csrfCookieKey, csrfToken, { ...scope, expires, encode: rawCookieValue }));
31
129
  }
32
130
  ;
33
- clearSessionCookies(hostOnly = false) {
34
- const scope = this.cookieScope(hostOnly);
35
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionTokenCookieKey, { ...scope, httpOnly: true }) });
36
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionCsrfCookieKey, scope) });
131
+ setSessionCookies(created) {
132
+ this.writeSessionCookies(created.session.expiresAt, created.sessionToken, created.csrfToken);
37
133
  }
38
134
  ;
39
135
  /**
40
- * Every well-formed value the request carried under the session cookie
41
- * name. More than one means the browser holds the cookie at several
42
- * scopes, and the order says nothing about which copy is current.
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.
43
140
  */
44
- sessionTokenCandidates() {
45
- return (this.ctx.cookieList?.[this.sessionTokenCookieKey] ?? []).filter((token) => token.split(":").length === 2);
141
+ addClearCookiePair(scope) {
142
+ this.ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(this.tokenCookieKey, { ...scope, httpOnly: true }));
143
+ this.ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(this.csrfCookieKey, scope));
46
144
  }
47
145
  ;
48
- areRequestSessionTokensValid() {
49
- const isSessionTokenValid = this.sessionTokenCandidates().length > 0;
50
- if (this.ctx._otherInternal.isApiCall) {
51
- const csrfToken = this.ctx.post?.token;
52
- const isCsrfTokenValid = typeof csrfToken === "string" && csrfToken.length > 0;
53
- return isSessionTokenValid && isCsrfTokenValid;
146
+ clearSessionCookies(hostOnly = false) {
147
+ this.addClearCookiePair(this.cookieScope(hostOnly));
148
+ }
149
+ ;
150
+ /**
151
+ * Every Domain this host is allowed to write the session cookies at: the
152
+ * 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.
157
+ */
158
+ cookieClearDomains() {
159
+ const hostname = (this.request.host.split(":")[0] ?? "").toLowerCase();
160
+ const labels = hostname.split(".").filter(Boolean);
161
+ const scopes = [];
162
+ // Two labels is the shortest a browser will take; one is a registry
163
+ // suffix and is rejected wherever it is offered.
164
+ for (let i = 0; i + 2 <= labels.length; i++) {
165
+ scopes.push(labels.slice(i).join("."));
54
166
  }
55
- else {
56
- return isSessionTokenValid;
167
+ const configured = resolveCookieDomain(this.cookieOptions.domain, this.request.host);
168
+ if (configured)
169
+ scopes.push(configured);
170
+ // A leading dot is ignored when matching, so ".example.com" and
171
+ // "example.com" name one cookie and only one deletion is needed.
172
+ const named = new Set(scopes.map((domain) => domain.replace(/^\./, "")));
173
+ return [undefined, ...named];
174
+ }
175
+ ;
176
+ /**
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.
184
+ */
185
+ clearSessionCookiesEverywhere() {
186
+ const base = this.cookieScope(true);
187
+ for (const domain of this.cookieClearDomains()) {
188
+ this.addClearCookiePair({ ...base, domain });
57
189
  }
58
190
  }
59
191
  ;
192
+ /**
193
+ * 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.
198
+ *
199
+ * Path is the one dimension this cannot sweep: the request info carries
200
+ * no path, and a deletion matches only a cookie at the same Path, so a
201
+ * copy planted at a deeper path stays out of reach. A `__Host-` cookie
202
+ * name is the structural answer, since the prefix forbids Domain and
203
+ * pins Path to "/"; see docs/sessions.md.
204
+ */
205
+ refuseAmbiguousSession(what) {
206
+ console.warn(`Lambder session: ${what}, from ${this.request.host}. `
207
+ + "Refusing all of them and clearing every scope this host can write: only one of them can be this visitor's, and picking one would sign them into the other. "
208
+ + "A cookie set at a parent domain is the usual cause, whether from a sibling site or an old deployment scope.");
209
+ this.clearSessionCookiesEverywhere();
210
+ throw new LambderSessionAmbiguousError();
211
+ }
212
+ ;
213
+ /**
214
+ * Both session cookie names read in one pass: every well-formed value
215
+ * under the token name, and every value under the CSRF name.
216
+ *
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
+ * with a deeper Path decides which token every call posts, and the count
224
+ * is the only thing that shows it.
225
+ */
226
+ scanSessionCookies() {
227
+ const wellFormed = (this.request.cookies[this.tokenCookieKey] ?? []).filter(isMintedSessionToken);
228
+ // Deduplicated: one value arriving twice (a proxy that appends rather
229
+ // 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).
233
+ return {
234
+ sessionTokens: [...new Set(wellFormed)],
235
+ csrfTokens: [...new Set(this.request.cookies[this.csrfCookieKey] ?? [])],
236
+ };
237
+ }
238
+ ;
239
+ /** An API call must also carry a CSRF token; a route only needs the cookie. */
240
+ areRequestSessionTokensValid(sessionTokens) {
241
+ if (sessionTokens.length === 0)
242
+ return false;
243
+ if (this.request.csrfToken !== null)
244
+ return this.request.csrfToken.length > 0;
245
+ return true;
246
+ }
247
+ ;
60
248
  async createSession(sessionKey, data, ttlInSeconds) {
61
- const created = await this.lambderSessionManager.createSession(sessionKey, data, ttlInSeconds);
249
+ return (await this.issueSession(sessionKey, data, ttlInSeconds)).session;
250
+ }
251
+ ;
252
+ /**
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.
256
+ */
257
+ async issueSession(sessionKey, data, ttlInSeconds) {
258
+ const created = await this.manager.createSession(sessionKey, data, ttlInSeconds);
62
259
  this.setSessionCookies(created);
63
260
  this.ctx.session = created.session;
64
- return this.ctx.session;
261
+ return created;
65
262
  }
66
263
  ;
67
264
  async regenerateSession() {
265
+ return (await this.reissueSession()).session;
266
+ }
267
+ ;
268
+ /**
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.
274
+ */
275
+ async reissueSession() {
68
276
  if (!this.ctx.session)
69
- throw new Error("Session not found.");
70
- const created = await this.lambderSessionManager.regenerateSession(this.ctx.session);
277
+ throw new LambderSessionNotFoundError();
278
+ const created = await this.manager.regenerateSession(this.ctx.session);
71
279
  this.setSessionCookies(created);
72
280
  this.ctx.session = created.session;
73
- return this.ctx.session;
281
+ return created;
74
282
  }
75
283
  ;
76
284
  async fetchSession() {
77
- if (!this.areRequestSessionTokensValid()) {
78
- throw new Error("Session tokens are invalid");
285
+ const { sessionTokens: candidates, csrfTokens } = this.scanSessionCookies();
286
+ if (!this.areRequestSessionTokensValid(candidates)) {
287
+ throw new LambderSessionNotFoundError("Session tokens are invalid");
288
+ }
289
+ if (candidates.length > MAX_SESSION_TOKEN_CANDIDATES) {
290
+ this.refuseAmbiguousSession(`more than ${MAX_SESSION_TOKEN_CANDIDATES} "${this.tokenCookieKey}" cookies arrived, at different scopes`);
79
291
  }
80
- const candidates = this.sessionTokenCandidates();
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.
81
294
  if (candidates.length > 1) {
82
- console.warn(`Lambder session: ${candidates.length} "${this.sessionTokenCookieKey}" cookies arrived from ${this.ctx.host}; the browser holds the cookie at several scopes and a stale copy may shadow the live one. Trying each.`);
295
+ console.warn(`Lambder session: ${candidates.length} "${this.tokenCookieKey}" cookies arrived from ${this.request.host}; the browser holds the cookie at several scopes. Reading each.`);
83
296
  }
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.
306
+ //
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.
314
+ const live = [];
84
315
  for (const sessionToken of candidates) {
85
- const session = await this.lambderSessionManager.getSession(sessionToken);
86
- if (!session || !this.isSessionValid(session, sessionToken))
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.
320
+ const candidate = await this.manager.lookupSession(sessionToken);
321
+ if (!candidate)
87
322
  continue;
88
- // The other copies are stale. This response can evict the
89
- // host-only twin of a Domain= cookie; a copy at a parent domain
90
- // this host cannot name is out of reach and expires on its own.
91
- if (candidates.length > 1 && this.cookieScope().domain)
92
- this.clearSessionCookies(true);
93
- this.ctx.session = session;
94
- return session;
323
+ live.push({ token: sessionToken, session: candidate });
324
+ // A second live copy is already the whole answer.
325
+ if (live.length > 1)
326
+ break;
327
+ }
328
+ if (live.length > 1) {
329
+ this.refuseAmbiguousSession(`more than one live "${this.tokenCookieKey}" cookie arrived, at different scopes`);
330
+ }
331
+ const found = live[0];
332
+ 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.
337
+ 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.
348
+ //
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.
355
+ if (csrfTokens.length > 1) {
356
+ this.refuseAmbiguousSession(`more than one "${this.csrfCookieKey}" cookie arrived, at different scopes, and the one posted pairs with no session`);
357
+ }
358
+ if (csrfTokens.length === 1 && !csrfTokens.includes(this.request.csrfToken)) {
359
+ this.refuseAmbiguousSession(`the posted CSRF token matches no "${this.csrfCookieKey}" cookie this request carried, while the session cookie named a live session`);
360
+ }
361
+ throw new LambderSessionNotFoundError();
362
+ }
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.
365
+ const expiresBefore = found.session.expiresAt;
366
+ const session = await this.manager.renewSession(found.session);
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.
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.
380
+ const evictsHostOnlyTwin = candidates.length > 1 && !!this.cookieScope().domain;
381
+ if (evictsHostOnlyTwin)
382
+ 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.
390
+ if (evictsHostOnlyTwin || session.expiresAt !== expiresBefore)
391
+ await this.slideSessionCookies(session, found.token, csrfTokens);
392
+ this.ctx.session = session;
393
+ return session;
394
+ }
395
+ ;
396
+ /**
397
+ * 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.
400
+ *
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.
409
+ */
410
+ async slideSessionCookies(session, sessionToken, csrfTokens) {
411
+ const posted = this.request.csrfToken;
412
+ if (posted !== null) {
413
+ this.writeSessionCookies(session.expiresAt, sessionToken, posted);
414
+ return;
95
415
  }
96
- throw new Error("Session not found");
416
+ const only = csrfTokens.length === 1 ? csrfTokens[0] : null;
417
+ const paired = only !== null && await this.manager.isSessionCsrfTokenValid(session, only);
418
+ this.writeSessionCookies(session.expiresAt, sessionToken, paired ? only : null);
97
419
  }
98
420
  ;
99
421
  async fetchSessionIfExists() {
@@ -101,32 +423,24 @@ export default class LambderSessionController {
101
423
  return await this.fetchSession();
102
424
  }
103
425
  catch (err) {
104
- // Missing or invalid sessions become null, but a failing
105
- // dataRefresh callback or a DynamoDB read failure must not
106
- // masquerade as a logout.
107
- if (err instanceof LambderSessionDataRefreshError)
108
- throw err;
109
- if (err instanceof LambderSessionReadError)
110
- throw err;
111
- return null;
112
- }
113
- }
114
- ;
115
- /** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
116
- isSessionValid(session, sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey]) {
117
- if (this.ctx._otherInternal.isApiCall) {
118
- const csrfToken = this.ctx.post?.token;
119
- return this.lambderSessionManager.isSessionValid(session, sessionToken, csrfToken);
120
- }
121
- else {
122
- return this.lambderSessionManager.isSessionValid(session, sessionToken, null, true);
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.
432
+ if (err instanceof LambderSessionNotFoundError)
433
+ return null;
434
+ if (err instanceof LambderSessionAmbiguousError)
435
+ return null;
436
+ throw err;
123
437
  }
124
438
  }
125
439
  ;
126
440
  async updateSessionData(newData) {
127
441
  if (!this.ctx.session)
128
- throw new Error("Session not found.");
129
- this.ctx.session = await this.lambderSessionManager.updateSessionData(this.ctx.session, newData);
442
+ throw new LambderSessionNotFoundError();
443
+ this.ctx.session = await this.manager.updateSessionData(this.ctx.session, newData);
130
444
  return this.ctx.session;
131
445
  }
132
446
  ;
@@ -138,8 +452,8 @@ export default class LambderSessionController {
138
452
  */
139
453
  async refreshSessionData() {
140
454
  if (!this.ctx.session)
141
- throw new Error("Session not found.");
142
- const refreshed = await this.lambderSessionManager.refreshSessionData(this.ctx.session);
455
+ throw new LambderSessionNotFoundError();
456
+ const refreshed = await this.manager.refreshSessionData(this.ctx.session);
143
457
  if (!refreshed) {
144
458
  this.clearSessionCookies();
145
459
  this.ctx.session = null;
@@ -155,7 +469,7 @@ export default class LambderSessionController {
155
469
  * session and touches no cookies, so it works on any subject.
156
470
  */
157
471
  async deleteSessionAllByKey(sessionKey) {
158
- await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
472
+ await this.manager.deleteSessionAllByKey(sessionKey);
159
473
  }
160
474
  ;
161
475
  /**
@@ -165,21 +479,21 @@ export default class LambderSessionController {
165
479
  * out. Needs no fetched session; requires dataRefresh.
166
480
  */
167
481
  async expireSessionDataAllByKey(sessionKey) {
168
- await this.lambderSessionManager.expireSessionDataAllByKey(sessionKey);
482
+ await this.manager.expireSessionDataAllByKey(sessionKey);
169
483
  }
170
484
  ;
171
485
  async endSession() {
172
486
  if (!this.ctx.session)
173
- throw new Error("Session not found.");
174
- await this.lambderSessionManager.deleteSession(this.ctx.session);
487
+ throw new LambderSessionNotFoundError();
488
+ await this.manager.deleteSession(this.ctx.session);
175
489
  this.clearSessionCookies();
176
490
  this.ctx.session = null;
177
491
  }
178
492
  ;
179
493
  async endSessionAll() {
180
494
  if (!this.ctx.session)
181
- throw new Error("Session not found.");
182
- await this.lambderSessionManager.deleteSessionAll(this.ctx.session);
495
+ throw new LambderSessionNotFoundError();
496
+ await this.manager.deleteSessionAll(this.ctx.session);
183
497
  this.clearSessionCookies();
184
498
  this.ctx.session = null;
185
499
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The cryptography the session model runs on, behind an interface so the
3
+ * manager itself has no Node dependency: the bearer secrets are hashed at
4
+ * rest, compared in constant time, and minted from a cryptographic random
5
+ * source.
6
+ *
7
+ * LambderWebCrypto is the default and runs on Node 20+, every browser on a
8
+ * secure context, and edge runtimes. LambderPlainSessionCrypto is the
9
+ * stand-in the mock runtime picks on its own where crypto.subtle is missing
10
+ * (a plain-http page during device testing on a LAN): its memory store is
11
+ * not a table anybody can leak, so hashing there protects nothing.
12
+ */
13
+ /**
14
+ * Hashing, randomness and constant-time comparison, as the session manager
15
+ * asks for them. Implement it to run sessions on a runtime's own primitives.
16
+ */
17
+ export interface LambderSessionCrypto {
18
+ /**
19
+ * Whether this really hashes and really draws random bytes. The session
20
+ * manager refuses a non-cryptographic implementation over a store that
21
+ * outlives the process, because such a store would then hold usable
22
+ * credentials rather than hashes of them.
23
+ */
24
+ readonly isCryptographic: boolean;
25
+ sha256Hex(value: string): Promise<string>;
26
+ randomHex(bytes: number): Promise<string>;
27
+ constantTimeEqual(a: string, b: string): boolean;
28
+ }
29
+ /** True when this runtime offers WebCrypto's subtle API (secure contexts in browsers; Node 20+). */
30
+ export declare const isWebCryptoAvailable: () => boolean;
31
+ /** sha256 through crypto.subtle and randomness through getRandomValues: the default. */
32
+ export declare class LambderWebCrypto implements LambderSessionCrypto {
33
+ readonly isCryptographic = true;
34
+ private cryptoPromise;
35
+ /**
36
+ * Node's crypto where the runtime has it, kept for timingSafeEqual.
37
+ * Warmed by ready(), because the comparison itself is synchronous and a
38
+ * session is always hashed before anything is compared against it.
39
+ */
40
+ private nodeCrypto;
41
+ /**
42
+ * The runtime's WebCrypto, through the resolver every layer shares, with
43
+ * Node's crypto warmed alongside it.
44
+ *
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.
50
+ */
51
+ private ready;
52
+ sha256Hex(value: string): Promise<string>;
53
+ randomHex(bytes: number): Promise<string>;
54
+ constantTimeEqual(a: string, b: string): boolean;
55
+ }
56
+ /**
57
+ * No hashing and no cryptographic randomness: values are hex-encoded as
58
+ * they are and secrets come from Math.random. Only for an in-memory store
59
+ * in a runtime without WebCrypto; never for anything at rest.
60
+ */
61
+ export declare class LambderPlainSessionCrypto implements LambderSessionCrypto {
62
+ readonly isCryptographic = false;
63
+ sha256Hex(value: string): Promise<string>;
64
+ randomHex(bytes: number): Promise<string>;
65
+ constantTimeEqual(a: string, b: string): boolean;
66
+ }