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