lambder 6.0.2 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,17 +1,6 @@
1
- import { loadDynamoClientSdk } from "./LambderDdbSdk.js";
2
- /**
3
- * The fixed windows a policy may cap, smallest first (the evaluation order),
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 LambderDdbIdempotency (`IDEM#`) without key
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 client given at creation, or one created from `region` on first use; the SDK arrives with it. */
36
- providedClient;
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
- failOpen;
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
- if (!Number.isFinite(this.ttlWindowMultiplier) || this.ttlWindowMultiplier < 1) {
48
- throw new Error("ttlWindowMultiplier must be a number greater than or equal to 1");
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(Date.now() / 1000);
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(trackerKey, key, windowStart, seconds, limit, nowSeconds);
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(trackerKey, sortKeyPrefix, windowStart, windowSeconds, limit, nowSeconds) {
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: `${this.keyPrefix}#${trackerKey}` },
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
- if (error.name === "ConditionalCheckFailedException")
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 type LambderDynamoClientSdk = typeof import("@aws-sdk/client-dynamodb");
16
- export type LambderDynamoDocumentSdk = typeof import("@aws-sdk/lib-dynamodb");
17
- /** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
18
- export declare const loadDynamoClientSdk: (user: string) => Promise<LambderDynamoClientSdk>;
19
- /** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
20
- export declare const loadDynamoDocumentSdk: (user: string) => Promise<LambderDynamoDocumentSdk>;
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
- export const loadDynamoClientSdk = (user) => {
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
- export const loadDynamoDocumentSdk = (user) => {
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 "../core/LambderFiles.js";
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 "../core/LambderFiles.js";
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
+ }