lambder 6.0.1 → 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.
- package/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* with their length. The policy type derives from this table, so the two can
|
|
5
|
-
* never drift.
|
|
6
|
-
*/
|
|
7
|
-
export const RATE_LIMIT_WINDOWS = [
|
|
8
|
-
{ key: "perMin", seconds: 60 },
|
|
9
|
-
{ key: "per10Min", seconds: 10 * 60 },
|
|
10
|
-
{ key: "perHour", seconds: 60 * 60 },
|
|
11
|
-
{ key: "perDay", seconds: 24 * 60 * 60 },
|
|
12
|
-
{ key: "perWeek", seconds: 7 * 24 * 60 * 60 },
|
|
13
|
-
{ key: "perMonth", seconds: 30 * 24 * 60 * 60 },
|
|
14
|
-
];
|
|
1
|
+
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
|
|
2
|
+
import { assertNumberAtLeast } from "../shared/util/LambderOptionChecks.js";
|
|
3
|
+
import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
15
4
|
/**
|
|
16
5
|
* Fixed-window rate limiter backed by DynamoDB.
|
|
17
6
|
*
|
|
@@ -24,39 +13,41 @@ export const RATE_LIMIT_WINDOWS = [
|
|
|
24
13
|
* would give up the conditional-ADD atomicity). Items carry an `expiresAt`
|
|
25
14
|
* attribute for DynamoDB TTL.
|
|
26
15
|
*
|
|
16
|
+
* The tracker key is caller data (an address, a session key, whatever a
|
|
17
|
+
* policy handler returned), so a key whose partition key would pass
|
|
18
|
+
* DynamoDB's 2048-byte limit is refused here, before any window is counted,
|
|
19
|
+
* rather than reaching the table and coming back as a ValidationException:
|
|
20
|
+
* that is not a conditional-check failure, so it escapes as a store error and
|
|
21
|
+
* a caller failing open on it counts nothing at all, which is the limit
|
|
22
|
+
* silently off. Lambder's own engine folds an over-long key into a digest
|
|
23
|
+
* long before this, so a key that gets here came from a direct caller.
|
|
24
|
+
*
|
|
25
|
+
* A DynamoDB error propagates: a limiter says whether the caller is over its
|
|
26
|
+
* limit, and it cannot answer that question when it cannot reach the table.
|
|
27
|
+
* Whether an unanswerable limit lets the request through is the application's
|
|
28
|
+
* call, not the storage's, so it is made once for every limiter at
|
|
29
|
+
* `rateLimits.failOpen` and the engine there handles the throw.
|
|
30
|
+
*
|
|
27
31
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
28
32
|
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
29
|
-
* LambderDdbCache (`CACHE#`) and
|
|
33
|
+
* LambderDdbCache (`CACHE#`) and LambderDdbIdempotencyStore (`IDEM#`) without key
|
|
30
34
|
* collisions.
|
|
31
35
|
*/
|
|
32
36
|
export class LambderDdbRateLimiter {
|
|
33
37
|
tableName;
|
|
34
38
|
keyPrefix;
|
|
35
|
-
/** The
|
|
36
|
-
|
|
37
|
-
region;
|
|
38
|
-
readyPromise;
|
|
39
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
40
|
+
ready;
|
|
39
41
|
ttlWindowMultiplier;
|
|
40
|
-
|
|
42
|
+
now;
|
|
41
43
|
constructor(options) {
|
|
42
44
|
if (!options.tableName.trim())
|
|
43
45
|
throw new Error("tableName is required");
|
|
44
46
|
this.tableName = options.tableName;
|
|
45
47
|
this.keyPrefix = options.keyPrefix ?? "RL";
|
|
46
|
-
this.ttlWindowMultiplier = options.ttlWindowMultiplier ?? 2;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
this.failOpen = options.failOpen ?? false;
|
|
51
|
-
this.providedClient = options.client;
|
|
52
|
-
this.region = options.region;
|
|
53
|
-
}
|
|
54
|
-
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
55
|
-
ready() {
|
|
56
|
-
this.readyPromise ??= loadDynamoClientSdk("LambderDdbRateLimiter")
|
|
57
|
-
.then((sdk) => ({ sdk, client: this.providedClient ?? new sdk.DynamoDBClient(this.region ? { region: this.region } : {}) }))
|
|
58
|
-
.catch((error) => { this.readyPromise = undefined; throw error; });
|
|
59
|
-
return this.readyPromise;
|
|
48
|
+
this.ttlWindowMultiplier = assertNumberAtLeast(options.ttlWindowMultiplier ?? 2, 1, "ttlWindowMultiplier");
|
|
49
|
+
this.now = options.now ?? (() => Date.now());
|
|
50
|
+
this.ready = createDynamoClientLoader({ user: "LambderDdbRateLimiter", region: options.region, client: options.client });
|
|
60
51
|
}
|
|
61
52
|
/**
|
|
62
53
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
@@ -64,25 +55,39 @@ export class LambderDdbRateLimiter {
|
|
|
64
55
|
* reset time when so.
|
|
65
56
|
*/
|
|
66
57
|
async isRateLimited(trackerKey, policy) {
|
|
67
|
-
const nowSeconds = Math.floor(
|
|
58
|
+
const nowSeconds = Math.floor(this.now() / 1000);
|
|
59
|
+
// Once for the whole call, and before the first window is counted: a
|
|
60
|
+
// key the table will not take fails every window the same way, so
|
|
61
|
+
// refusing it here is the difference between one clear error and a
|
|
62
|
+
// policy that counts nothing while reporting nothing.
|
|
63
|
+
const partitionKey = this.partitionKeyFor(trackerKey);
|
|
68
64
|
for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
|
|
69
65
|
const limit = policy[key];
|
|
70
66
|
if (!limit)
|
|
71
67
|
continue;
|
|
72
68
|
const windowStart = Math.floor(nowSeconds / seconds) * seconds;
|
|
73
|
-
const exceeded = await this.incrementWindow(
|
|
69
|
+
const exceeded = await this.incrementWindow(partitionKey, key, windowStart, seconds, limit, nowSeconds);
|
|
74
70
|
if (exceeded)
|
|
75
71
|
return { window: key, limit, resetAt: windowStart + seconds };
|
|
76
72
|
}
|
|
77
73
|
return false;
|
|
78
74
|
}
|
|
75
|
+
/** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
|
|
76
|
+
partitionKeyFor(trackerKey) {
|
|
77
|
+
return assertPartitionKeyFits({
|
|
78
|
+
user: "LambderDdbRateLimiter",
|
|
79
|
+
what: "tracker key",
|
|
80
|
+
partitionKey: `${this.keyPrefix}#${trackerKey}`,
|
|
81
|
+
remedy: "Shorten the key the policy hands the limiter.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
79
84
|
/** Increments one window counter. Returns true when the limit was already reached. */
|
|
80
|
-
async incrementWindow(
|
|
85
|
+
async incrementWindow(partitionKey, sortKeyPrefix, windowStart, windowSeconds, limit, nowSeconds) {
|
|
81
86
|
const expiresAt = nowSeconds + Math.ceil(windowSeconds * this.ttlWindowMultiplier);
|
|
82
87
|
const input = {
|
|
83
88
|
TableName: this.tableName,
|
|
84
89
|
Key: {
|
|
85
|
-
pk: { S:
|
|
90
|
+
pk: { S: partitionKey },
|
|
86
91
|
sk: { S: `${sortKeyPrefix}#${windowStart}` },
|
|
87
92
|
},
|
|
88
93
|
UpdateExpression: "ADD #count :one SET #expiresAt = if_not_exists(#expiresAt, :expiresAt)",
|
|
@@ -100,14 +105,11 @@ export class LambderDdbRateLimiter {
|
|
|
100
105
|
return false;
|
|
101
106
|
}
|
|
102
107
|
catch (error) {
|
|
103
|
-
|
|
108
|
+
// The refused condition is the limiter's own answer; anything else
|
|
109
|
+
// is the table being unreachable, which only the caller can decide
|
|
110
|
+
// what to do about.
|
|
111
|
+
if (isConditionalCheckFailure(error))
|
|
104
112
|
return true;
|
|
105
|
-
if (this.failOpen) {
|
|
106
|
-
// Failing open swallows the error from the caller's view, so
|
|
107
|
-
// keep the infra failure visible in the logs.
|
|
108
|
-
console.error(`LambderDdbRateLimiter: DynamoDB error while counting "${trackerKey}", allowing the request (failOpen).`, error);
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
113
|
throw error;
|
|
112
114
|
}
|
|
113
115
|
}
|
|
@@ -11,10 +11,87 @@
|
|
|
11
11
|
* LambderInvokeCaller load theirs. One loader per package, memoized for the
|
|
12
12
|
* container's life; a missing package fails that first call with the
|
|
13
13
|
* install hint rather than failing the import of lambder itself.
|
|
14
|
+
*
|
|
15
|
+
* The two facts about DynamoDB itself that every one of those stores has to
|
|
16
|
+
* agree on live here as well, for the same reason: a conditional write's
|
|
17
|
+
* refusal is an answer rather than a failure, and a partition key has a
|
|
18
|
+
* limit that the caller data these stores build keys from can pass.
|
|
19
|
+
*/
|
|
20
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
21
|
+
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
22
|
+
type LambderDynamoClientSdk = typeof import("@aws-sdk/client-dynamodb");
|
|
23
|
+
type LambderDynamoDocumentSdk = typeof import("@aws-sdk/lib-dynamodb");
|
|
24
|
+
/** What every DynamoDB-backed store needs before it can touch its table: the SDK, and a client made with it. */
|
|
25
|
+
export type LambderDynamoClientReady = {
|
|
26
|
+
client: DynamoDBClient;
|
|
27
|
+
sdk: LambderDynamoClientSdk;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The "ready" step each DynamoDB-backed store runs before its first table
|
|
31
|
+
* call: load the SDK, then take the client the app supplied or build one.
|
|
32
|
+
* Memoized for the store's life, and a FAILED load is deliberately not, so a
|
|
33
|
+
* store that ran before the package was installed asks again rather than
|
|
34
|
+
* caching the install hint for ever.
|
|
35
|
+
*
|
|
36
|
+
* Written once because four stores had written it for themselves and had
|
|
37
|
+
* already drifted apart on the region: three passed the SDK's default chain
|
|
38
|
+
* through and the cache pinned `us-east-1`, so one store out of four ignored
|
|
39
|
+
* the deployment's own region.
|
|
40
|
+
*
|
|
41
|
+
* `region` absent means the SDK's default chain (`AWS_REGION`, the Lambda
|
|
42
|
+
* environment, the shared config file), which is what a store should leave
|
|
43
|
+
* alone unless the caller names one.
|
|
44
|
+
*/
|
|
45
|
+
export declare const createDynamoClientLoader: (options: {
|
|
46
|
+
user: string;
|
|
47
|
+
region?: string;
|
|
48
|
+
client?: DynamoDBClient;
|
|
49
|
+
}) => (() => Promise<LambderDynamoClientReady>);
|
|
50
|
+
/** What the session store needs before its first table call: the document SDK, and a document client made with it. */
|
|
51
|
+
export type LambderDynamoDocumentClientReady = {
|
|
52
|
+
client: DynamoDBDocumentClient;
|
|
53
|
+
sdk: LambderDynamoDocumentSdk;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The document-client twin of createDynamoClientLoader, for the one store
|
|
57
|
+
* that speaks the document API. A supplied client is taken as is, and then
|
|
58
|
+
* only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
|
|
59
|
+
* only to construct a client of our own.
|
|
60
|
+
*/
|
|
61
|
+
export declare const createDynamoDocumentClientLoader: (options: {
|
|
62
|
+
user: string;
|
|
63
|
+
region?: string;
|
|
64
|
+
client?: DynamoDBDocumentClient;
|
|
65
|
+
}) => (() => Promise<LambderDynamoDocumentClientReady>);
|
|
66
|
+
/**
|
|
67
|
+
* Whether DynamoDB refused a write because its condition did not hold, which
|
|
68
|
+
* is how every conditional write here reports the thing it was testing for: a
|
|
69
|
+
* claim already taken, a counter at its limit, a lease somebody else holds.
|
|
70
|
+
* An answer, not a failure, so it is the one error class these stores catch
|
|
71
|
+
* and the reason they must not catch any other.
|
|
72
|
+
*/
|
|
73
|
+
export declare const isConditionalCheckFailure: (error: unknown) => boolean;
|
|
74
|
+
/**
|
|
75
|
+
* DynamoDB's own limit on a partition key, which every store's
|
|
76
|
+
* `<keyPrefix>#<caller data>` key has to fit inside.
|
|
77
|
+
*/
|
|
78
|
+
export declare const MAX_PARTITION_KEY_BYTES = 2048;
|
|
79
|
+
/**
|
|
80
|
+
* The partition key, or a refusal naming the byte count. Every store here
|
|
81
|
+
* builds its key out of caller data (an idempotency key and the identity it
|
|
82
|
+
* is scoped by, a rate-limit key a policy handler returned), so the length is
|
|
83
|
+
* the caller's to reach and this is the one place that measures it.
|
|
84
|
+
*
|
|
85
|
+
* Checked rather than left to DynamoDB, which answers an over-long key with a
|
|
86
|
+
* ValidationException: that is not a ConditionalCheckFailedException, so it
|
|
87
|
+
* escapes as a store error that reads as "the table is broken" and, under a
|
|
88
|
+
* fail-open setting, is swallowed into no protection at all. The message
|
|
89
|
+
* carries the count and never the key, because it reaches a log.
|
|
14
90
|
*/
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
91
|
+
export declare const assertPartitionKeyFits: (options: {
|
|
92
|
+
user: string;
|
|
93
|
+
what: string;
|
|
94
|
+
partitionKey: string;
|
|
95
|
+
remedy: string;
|
|
96
|
+
}) => string;
|
|
97
|
+
export {};
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* LambderInvokeCaller load theirs. One loader per package, memoized for the
|
|
12
12
|
* container's life; a missing package fails that first call with the
|
|
13
13
|
* install hint rather than failing the import of lambder itself.
|
|
14
|
+
*
|
|
15
|
+
* The two facts about DynamoDB itself that every one of those stores has to
|
|
16
|
+
* agree on live here as well, for the same reason: a conditional write's
|
|
17
|
+
* refusal is an answer rather than a failure, and a partition key has a
|
|
18
|
+
* limit that the caller data these stores build keys from can pass.
|
|
14
19
|
*/
|
|
15
20
|
let clientSdk;
|
|
16
21
|
let documentSdk;
|
|
@@ -20,12 +25,88 @@ const withInstallHint = (loading, packageName, user, reset) => loading.catch((ca
|
|
|
20
25
|
throw new Error(`${user} requires ${packageName}: npm install ${packageName}`, { cause });
|
|
21
26
|
});
|
|
22
27
|
/** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
|
|
23
|
-
|
|
28
|
+
const loadDynamoClientSdk = (user) => {
|
|
24
29
|
clientSdk ??= withInstallHint(import("@aws-sdk/client-dynamodb"), "@aws-sdk/client-dynamodb", user, () => { clientSdk = undefined; });
|
|
25
30
|
return clientSdk;
|
|
26
31
|
};
|
|
27
32
|
/** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
|
|
28
|
-
|
|
33
|
+
const loadDynamoDocumentSdk = (user) => {
|
|
29
34
|
documentSdk ??= withInstallHint(import("@aws-sdk/lib-dynamodb"), "@aws-sdk/lib-dynamodb", user, () => { documentSdk = undefined; });
|
|
30
35
|
return documentSdk;
|
|
31
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* The "ready" step each DynamoDB-backed store runs before its first table
|
|
39
|
+
* call: load the SDK, then take the client the app supplied or build one.
|
|
40
|
+
* Memoized for the store's life, and a FAILED load is deliberately not, so a
|
|
41
|
+
* store that ran before the package was installed asks again rather than
|
|
42
|
+
* caching the install hint for ever.
|
|
43
|
+
*
|
|
44
|
+
* Written once because four stores had written it for themselves and had
|
|
45
|
+
* already drifted apart on the region: three passed the SDK's default chain
|
|
46
|
+
* through and the cache pinned `us-east-1`, so one store out of four ignored
|
|
47
|
+
* the deployment's own region.
|
|
48
|
+
*
|
|
49
|
+
* `region` absent means the SDK's default chain (`AWS_REGION`, the Lambda
|
|
50
|
+
* environment, the shared config file), which is what a store should leave
|
|
51
|
+
* alone unless the caller names one.
|
|
52
|
+
*/
|
|
53
|
+
export const createDynamoClientLoader = (options) => {
|
|
54
|
+
let readyPromise;
|
|
55
|
+
return () => {
|
|
56
|
+
readyPromise ??= loadDynamoClientSdk(options.user)
|
|
57
|
+
.then((sdk) => ({ sdk, client: options.client ?? new sdk.DynamoDBClient(options.region ? { region: options.region } : {}) }))
|
|
58
|
+
.catch((error) => { readyPromise = undefined; throw error; });
|
|
59
|
+
return readyPromise;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The document-client twin of createDynamoClientLoader, for the one store
|
|
64
|
+
* that speaks the document API. A supplied client is taken as is, and then
|
|
65
|
+
* only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
|
|
66
|
+
* only to construct a client of our own.
|
|
67
|
+
*/
|
|
68
|
+
export const createDynamoDocumentClientLoader = (options) => {
|
|
69
|
+
let readyPromise;
|
|
70
|
+
const build = async () => {
|
|
71
|
+
if (options.client)
|
|
72
|
+
return { sdk: await loadDynamoDocumentSdk(options.user), client: options.client };
|
|
73
|
+
const [clientSdk, sdk] = await Promise.all([loadDynamoClientSdk(options.user), loadDynamoDocumentSdk(options.user)]);
|
|
74
|
+
return { sdk, client: sdk.DynamoDBDocumentClient.from(new clientSdk.DynamoDBClient(options.region ? { region: options.region } : {})) };
|
|
75
|
+
};
|
|
76
|
+
return () => {
|
|
77
|
+
readyPromise ??= build().catch((error) => { readyPromise = undefined; throw error; });
|
|
78
|
+
return readyPromise;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Whether DynamoDB refused a write because its condition did not hold, which
|
|
83
|
+
* is how every conditional write here reports the thing it was testing for: a
|
|
84
|
+
* claim already taken, a counter at its limit, a lease somebody else holds.
|
|
85
|
+
* An answer, not a failure, so it is the one error class these stores catch
|
|
86
|
+
* and the reason they must not catch any other.
|
|
87
|
+
*/
|
|
88
|
+
export const isConditionalCheckFailure = (error) => !!error && typeof error === "object" && "name" in error && error.name === "ConditionalCheckFailedException";
|
|
89
|
+
/**
|
|
90
|
+
* DynamoDB's own limit on a partition key, which every store's
|
|
91
|
+
* `<keyPrefix>#<caller data>` key has to fit inside.
|
|
92
|
+
*/
|
|
93
|
+
export const MAX_PARTITION_KEY_BYTES = 2048;
|
|
94
|
+
/**
|
|
95
|
+
* The partition key, or a refusal naming the byte count. Every store here
|
|
96
|
+
* builds its key out of caller data (an idempotency key and the identity it
|
|
97
|
+
* is scoped by, a rate-limit key a policy handler returned), so the length is
|
|
98
|
+
* the caller's to reach and this is the one place that measures it.
|
|
99
|
+
*
|
|
100
|
+
* Checked rather than left to DynamoDB, which answers an over-long key with a
|
|
101
|
+
* ValidationException: that is not a ConditionalCheckFailedException, so it
|
|
102
|
+
* escapes as a store error that reads as "the table is broken" and, under a
|
|
103
|
+
* fail-open setting, is swallowed into no protection at all. The message
|
|
104
|
+
* carries the count and never the key, because it reaches a log.
|
|
105
|
+
*/
|
|
106
|
+
export const assertPartitionKeyFits = (options) => {
|
|
107
|
+
const partitionKeyBytes = Buffer.byteLength(options.partitionKey, "utf8");
|
|
108
|
+
if (partitionKeyBytes > MAX_PARTITION_KEY_BYTES) {
|
|
109
|
+
throw new Error(`${options.user}: the ${options.what} is ${partitionKeyBytes} bytes with its prefix, over DynamoDB's ${MAX_PARTITION_KEY_BYTES}-byte partition key limit. ${options.remedy}`);
|
|
110
|
+
}
|
|
111
|
+
return options.partitionKey;
|
|
112
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
2
|
+
import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
|
|
3
|
+
import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
4
|
+
export type LambderDdbSessionStoreOptions = {
|
|
5
|
+
tableName: string;
|
|
6
|
+
/** Region the client is created for on first use; the SDK's default chain otherwise. */
|
|
7
|
+
region?: string;
|
|
8
|
+
/** Attribute names of the table's hash and range keys. Defaults: "pk" and "sk". */
|
|
9
|
+
partitionKey?: string;
|
|
10
|
+
sortKey?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Brotli compression of session.data at rest. `true` (the default)
|
|
13
|
+
* compresses every record, the same as `{ minBytes: 0 }`; `false` turns
|
|
14
|
+
* it off; `{ minBytes }` compresses only records whose JSON is at least
|
|
15
|
+
* that many bytes. Records written under either setting read back, so
|
|
16
|
+
* it can be switched on or off on a live table.
|
|
17
|
+
*/
|
|
18
|
+
compression?: LambderCompressionOption;
|
|
19
|
+
/** A ready document client, e.g. one shared with the rest of the app. */
|
|
20
|
+
client?: DynamoDBDocumentClient;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Sessions at rest in DynamoDB: one item per session under the two hashes,
|
|
24
|
+
* with session.data Brotli-compressed by default. The store maps the
|
|
25
|
+
* manager's record onto the table's own key attribute names and back, and
|
|
26
|
+
* owns nothing of the session model itself.
|
|
27
|
+
*
|
|
28
|
+
* Table shape: a string hash key (the salted sessionKey hash, every session
|
|
29
|
+
* of one subject shares it) and a string range key (the bearer secret's
|
|
30
|
+
* hash), plus a TTL on `expiresAt` to let DynamoDB sweep expired sessions.
|
|
31
|
+
*/
|
|
32
|
+
export declare class LambderDdbSessionStore<SessionData = unknown> implements LambderSessionStore<SessionData> {
|
|
33
|
+
/** DynamoDB keeps records after this process is gone. */
|
|
34
|
+
readonly isMemoryOnly = false;
|
|
35
|
+
readonly tableName: string;
|
|
36
|
+
private readonly partitionKey;
|
|
37
|
+
private readonly sortKey;
|
|
38
|
+
private readonly compression;
|
|
39
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
40
|
+
private readonly ready;
|
|
41
|
+
constructor(options: LambderDdbSessionStoreOptions);
|
|
42
|
+
private keyOf;
|
|
43
|
+
/** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
|
|
44
|
+
private toItem;
|
|
45
|
+
/**
|
|
46
|
+
* The record for an item. A compressed record decodes back into `data`;
|
|
47
|
+
* one whose data cannot be decoded is a malformed record and reads as no
|
|
48
|
+
* session, the same as a record missing its csrfTokenHash.
|
|
49
|
+
*
|
|
50
|
+
* Read failures and malformed records have to stay apart, and this is the
|
|
51
|
+
* seam where they separate. A read failure is infrastructure and must
|
|
52
|
+
* surface as a 500, because signing somebody out over a transient
|
|
53
|
+
* DynamoDB error is a worse answer than an error page. A record that will
|
|
54
|
+
* not decode is not transient: it will not decode on the next request
|
|
55
|
+
* either, so a 500 there is a session the visitor can neither use nor
|
|
56
|
+
* clear, on every request, until the TTL retires it. Ending it lets them
|
|
57
|
+
* log in again.
|
|
58
|
+
*/
|
|
59
|
+
private fromItem;
|
|
60
|
+
get(sessionKeyHash: string, secretHash: string): Promise<LambderSessionRecord<SessionData> | null>;
|
|
61
|
+
put(record: LambderSessionRecord<SessionData>): Promise<void>;
|
|
62
|
+
delete(sessionKeyHash: string, secretHash: string): Promise<void>;
|
|
63
|
+
listSecretHashes(sessionKeyHash: string): Promise<string[]>;
|
|
64
|
+
markDataExpired(sessionKeyHash: string, secretHash: string, at: number): Promise<void>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createDynamoDocumentClientLoader, isConditionalCheckFailure } from "./LambderDdbSdk.js";
|
|
2
|
+
import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
|
|
3
|
+
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
4
|
+
/**
|
|
5
|
+
* Session compression defaults: every record compressed (see
|
|
6
|
+
* LambderCompressionOption for the option's shape and toggle semantics).
|
|
7
|
+
* A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
8
|
+
* beside its byte length (`dataBytes`), the scheme LambderDdbCache and
|
|
9
|
+
* LambderDdbIdempotencyStore use; below minBytes, or with compression off, the
|
|
10
|
+
* record keeps a plain `data` attribute.
|
|
11
|
+
*/
|
|
12
|
+
const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
13
|
+
/**
|
|
14
|
+
* Sessions at rest in DynamoDB: one item per session under the two hashes,
|
|
15
|
+
* with session.data Brotli-compressed by default. The store maps the
|
|
16
|
+
* manager's record onto the table's own key attribute names and back, and
|
|
17
|
+
* owns nothing of the session model itself.
|
|
18
|
+
*
|
|
19
|
+
* Table shape: a string hash key (the salted sessionKey hash, every session
|
|
20
|
+
* of one subject shares it) and a string range key (the bearer secret's
|
|
21
|
+
* hash), plus a TTL on `expiresAt` to let DynamoDB sweep expired sessions.
|
|
22
|
+
*/
|
|
23
|
+
export class LambderDdbSessionStore {
|
|
24
|
+
/** DynamoDB keeps records after this process is gone. */
|
|
25
|
+
isMemoryOnly = false;
|
|
26
|
+
tableName;
|
|
27
|
+
partitionKey;
|
|
28
|
+
sortKey;
|
|
29
|
+
compression;
|
|
30
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
31
|
+
ready;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
if (!options.tableName.trim())
|
|
34
|
+
throw new Error("tableName is required");
|
|
35
|
+
this.tableName = options.tableName;
|
|
36
|
+
this.partitionKey = options.partitionKey ?? "pk";
|
|
37
|
+
this.sortKey = options.sortKey ?? "sk";
|
|
38
|
+
this.compression = resolveCompressionOption(options.compression, SESSION_COMPRESSION_DEFAULTS);
|
|
39
|
+
this.ready = createDynamoDocumentClientLoader({
|
|
40
|
+
user: "LambderDdbSessionStore",
|
|
41
|
+
...(options.region !== undefined ? { region: options.region } : {}),
|
|
42
|
+
...(options.client ? { client: options.client } : {}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
keyOf(sessionKeyHash, secretHash) {
|
|
46
|
+
return { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash };
|
|
47
|
+
}
|
|
48
|
+
/** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
|
|
49
|
+
async toItem(record) {
|
|
50
|
+
const { sessionKeyHash, secretHash, data, ...rest } = record;
|
|
51
|
+
const item = { ...this.keyOf(sessionKeyHash, secretHash), ...rest };
|
|
52
|
+
const raw = this.compression && Buffer.from(JSON.stringify(data), "utf8");
|
|
53
|
+
if (this.compression && raw && raw.byteLength >= this.compression.minBytes) {
|
|
54
|
+
item.dataBr = await compressText(raw, "br", this.compression.quality);
|
|
55
|
+
item.dataBytes = raw.byteLength;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
item.data = data;
|
|
59
|
+
}
|
|
60
|
+
return item;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The record for an item. A compressed record decodes back into `data`;
|
|
64
|
+
* one whose data cannot be decoded is a malformed record and reads as no
|
|
65
|
+
* session, the same as a record missing its csrfTokenHash.
|
|
66
|
+
*
|
|
67
|
+
* Read failures and malformed records have to stay apart, and this is the
|
|
68
|
+
* seam where they separate. A read failure is infrastructure and must
|
|
69
|
+
* surface as a 500, because signing somebody out over a transient
|
|
70
|
+
* DynamoDB error is a worse answer than an error page. A record that will
|
|
71
|
+
* not decode is not transient: it will not decode on the next request
|
|
72
|
+
* either, so a 500 there is a session the visitor can neither use nor
|
|
73
|
+
* clear, on every request, until the TTL retires it. Ending it lets them
|
|
74
|
+
* log in again.
|
|
75
|
+
*/
|
|
76
|
+
async fromItem(item) {
|
|
77
|
+
const { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash, dataBr, dataBytes, data, ...rest } = item;
|
|
78
|
+
let restored = data;
|
|
79
|
+
if (dataBr) {
|
|
80
|
+
try {
|
|
81
|
+
restored = JSON.parse(await restoreText(dataBr, "br", { declaredBytes: dataBytes }));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.warn(`LambderDdbSessionStore: a session record in "${this.tableName}" could not be decoded, so it reads as no session.`, err);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// The load-bearing fields are checked before the cast, because
|
|
89
|
+
// everything past this line trusts them: the manager compares the two
|
|
90
|
+
// hashes in constant time (a non-string would throw there rather than
|
|
91
|
+
// answer false) and reads expiresAt as a number to decide whether the
|
|
92
|
+
// session is over. An item missing them is not this store's record,
|
|
93
|
+
// whether it was written by hand, by an older schema, or by another
|
|
94
|
+
// app sharing the table, and it reads as no session for the same
|
|
95
|
+
// reason an undecodable one does: it will not become valid later.
|
|
96
|
+
if (typeof sessionKeyHash !== "string" || typeof secretHash !== "string"
|
|
97
|
+
|| typeof rest.csrfTokenHash !== "string" || typeof rest.sessionKey !== "string"
|
|
98
|
+
|| typeof rest.createdAt !== "number" || typeof rest.expiresAt !== "number"
|
|
99
|
+
|| typeof rest.ttlInSeconds !== "number") {
|
|
100
|
+
console.warn(`LambderDdbSessionStore: an item in "${this.tableName}" is missing the fields a session record has, so it reads as no session.`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
// The one cast: session.data is whatever the app put there, and JSON
|
|
104
|
+
// (or the plain attribute) hands it back as any. Nothing in the store
|
|
105
|
+
// can check it, because the shape is the app's, not this layer's.
|
|
106
|
+
return { sessionKeyHash, secretHash, data: restored, ...rest };
|
|
107
|
+
}
|
|
108
|
+
async get(sessionKeyHash, secretHash) {
|
|
109
|
+
const { client, sdk } = await this.ready();
|
|
110
|
+
const response = await client.send(new sdk.GetCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ConsistentRead: true }));
|
|
111
|
+
if (!response.Item)
|
|
112
|
+
return null;
|
|
113
|
+
return await this.fromItem(response.Item);
|
|
114
|
+
}
|
|
115
|
+
async put(record) {
|
|
116
|
+
const item = await this.toItem(record);
|
|
117
|
+
const { client, sdk } = await this.ready();
|
|
118
|
+
await client.send(new sdk.PutCommand({ TableName: this.tableName, Item: item }));
|
|
119
|
+
}
|
|
120
|
+
async delete(sessionKeyHash, secretHash) {
|
|
121
|
+
const { client, sdk } = await this.ready();
|
|
122
|
+
await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash) }));
|
|
123
|
+
}
|
|
124
|
+
async listSecretHashes(sessionKeyHash) {
|
|
125
|
+
const params = {
|
|
126
|
+
TableName: this.tableName,
|
|
127
|
+
KeyConditionExpression: "#pk = :pv",
|
|
128
|
+
ProjectionExpression: "#sk",
|
|
129
|
+
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
130
|
+
ExpressionAttributeValues: { ":pv": sessionKeyHash },
|
|
131
|
+
};
|
|
132
|
+
const hashes = [];
|
|
133
|
+
for (;;) {
|
|
134
|
+
const { client, sdk } = await this.ready();
|
|
135
|
+
const { Items, LastEvaluatedKey } = await client.send(new sdk.QueryCommand(params));
|
|
136
|
+
for (const item of Items ?? [])
|
|
137
|
+
hashes.push(item[this.sortKey]);
|
|
138
|
+
if (LastEvaluatedKey === undefined)
|
|
139
|
+
return hashes;
|
|
140
|
+
params.ExclusiveStartKey = LastEvaluatedKey;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async markDataExpired(sessionKeyHash, secretHash, at) {
|
|
144
|
+
try {
|
|
145
|
+
const { client, sdk } = await this.ready();
|
|
146
|
+
await client.send(new sdk.UpdateCommand({
|
|
147
|
+
TableName: this.tableName,
|
|
148
|
+
Key: this.keyOf(sessionKeyHash, secretHash),
|
|
149
|
+
UpdateExpression: "SET #dataExpiresAt = :at",
|
|
150
|
+
ConditionExpression: "attribute_exists(#sk)",
|
|
151
|
+
ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
|
|
152
|
+
ExpressionAttributeValues: { ":at": at },
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Deleted between the query and the update: nothing left to expire.
|
|
157
|
+
if (!isConditionalCheckFailure(err))
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LambderFile, type LambderFileSource } from "../
|
|
1
|
+
import { type LambderFile, type LambderFileSource } from "../shared/contracts/LambderFileSource.js";
|
|
2
2
|
export type LambderHttpFileSourceOptions = {
|
|
3
3
|
/**
|
|
4
4
|
* The folder URL relative paths resolve under: "https://assets.example.com/v42/".
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteStoreFile } from "../
|
|
1
|
+
import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
|
|
2
2
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
3
3
|
/**
|
|
4
4
|
* Files over HTTP(S) from any origin that serves them by path: a CDN, a
|
|
@@ -34,6 +34,15 @@ export class LambderHttpFileSource {
|
|
|
34
34
|
}
|
|
35
35
|
async read(relativePath) {
|
|
36
36
|
const url = new URL(relativePath.split("/").map(encodeURIComponent).join("/"), this.baseUrl);
|
|
37
|
+
// The reader's path rule already refuses everything that could make
|
|
38
|
+
// this reference leave the configured folder (a leading slash makes it
|
|
39
|
+
// root-relative, two make it protocol-relative and pick the host).
|
|
40
|
+
// Checked again here rather than trusted, because the value being
|
|
41
|
+
// resolved is the request path and what leaving costs is a
|
|
42
|
+
// credentialed fetch of an attacker-named origin, served back from
|
|
43
|
+
// this app's own domain.
|
|
44
|
+
if (!url.href.startsWith(this.baseUrl.href))
|
|
45
|
+
return null;
|
|
37
46
|
const response = await fetch(url, { headers: this.headers, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
38
47
|
if (!response.ok) {
|
|
39
48
|
await response.body?.cancel();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LambderFile, LambderFileSource } from "../shared/contracts/LambderFileSource.js";
|
|
2
|
+
/**
|
|
3
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
4
|
+
* bundled into the deployment package. Reads stay under root: the reader's
|
|
5
|
+
* path rule already refuses anything that could leave it, and the resolved
|
|
6
|
+
* path is checked against the root again here, because a contract is not a
|
|
7
|
+
* boundary.
|
|
8
|
+
*/
|
|
9
|
+
export declare class LambderLocalFileSource implements LambderFileSource {
|
|
10
|
+
private root;
|
|
11
|
+
constructor({ root }: {
|
|
12
|
+
root: string;
|
|
13
|
+
});
|
|
14
|
+
read(relativePath: string): Promise<LambderFile | null>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getFS, getPath } from "../shared/util/LambderNodeModules.js";
|
|
2
|
+
/**
|
|
3
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
4
|
+
* bundled into the deployment package. Reads stay under root: the reader's
|
|
5
|
+
* path rule already refuses anything that could leave it, and the resolved
|
|
6
|
+
* path is checked against the root again here, because a contract is not a
|
|
7
|
+
* boundary.
|
|
8
|
+
*/
|
|
9
|
+
export class LambderLocalFileSource {
|
|
10
|
+
root;
|
|
11
|
+
constructor({ root }) {
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
async read(relativePath) {
|
|
15
|
+
const fs = await getFS();
|
|
16
|
+
const path = await getPath();
|
|
17
|
+
if (!fs || !path)
|
|
18
|
+
throw new Error("Lambder: LambderLocalFileSource requires a Node.js environment.");
|
|
19
|
+
const base = path.resolve(this.root);
|
|
20
|
+
const absolute = path.resolve(base, relativePath);
|
|
21
|
+
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
22
|
+
return null;
|
|
23
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
24
|
+
if (!stat?.isFile())
|
|
25
|
+
return null;
|
|
26
|
+
return { body: await fs.promises.readFile(absolute) };
|
|
27
|
+
}
|
|
28
|
+
}
|