lambder 7.3.1 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +933 -3
  2. package/README.md +41 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +140 -75
  41. package/dist/core/Lambder.js +347 -227
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +21 -7
  51. package/dist/core/LambderFiles.js +62 -34
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  73. package/dist/invoke/LambderLambdaEvent.js +40 -22
  74. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  75. package/dist/invoke/lambderHandlerTransport.js +15 -18
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +1 -1
  93. package/dist/mock.js +2 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +124 -46
  99. package/dist/session/LambderSessionManager.js +262 -137
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  134. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  135. package/dist/shared/util/boundKeyField.d.ts +20 -0
  136. package/dist/shared/util/boundKeyField.js +34 -0
  137. package/dist/shared/util/canonicalJson.d.ts +11 -0
  138. package/dist/shared/util/canonicalJson.js +28 -0
  139. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  140. package/dist/shared/util/joinKeyFields.js +22 -0
  141. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  142. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  143. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  144. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  145. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  146. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  147. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  148. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  149. package/dist/shared/wire/LambderApiSignature.js +16 -19
  150. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  151. package/dist/shared/wire/LambderCallOptions.js +9 -11
  152. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  153. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  154. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  155. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  156. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  157. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  158. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  159. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  160. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  161. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  162. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  163. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  164. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  165. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  166. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  167. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  168. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  169. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  170. package/dist/stores/LambderCacheFiller.js +119 -0
  171. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  172. package/dist/stores/LambderCacheKeys.js +54 -0
  173. package/dist/stores/LambderCacheValues.d.ts +45 -0
  174. package/dist/stores/LambderCacheValues.js +74 -0
  175. package/dist/stores/LambderDdbCache.d.ts +121 -56
  176. package/dist/stores/LambderDdbCache.js +528 -225
  177. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  178. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  179. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  180. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  181. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  182. package/dist/stores/LambderDdbSdk.js +79 -33
  183. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  184. package/dist/stores/LambderDdbSessionStore.js +119 -47
  185. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  186. package/dist/stores/LambderHttpFileSource.js +15 -13
  187. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  188. package/dist/stores/LambderMemoryCache.js +113 -0
  189. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  190. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  191. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  192. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  193. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  194. package/dist/stores/LambderMemorySessionStore.js +38 -19
  195. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  196. package/dist/stores/LambderS3FileSource.js +12 -7
  197. package/dist/testing/LambderTestApp.d.ts +21 -23
  198. package/dist/testing/LambderTestApp.js +22 -24
  199. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  200. package/dist/testing/LambderTestVisitor.js +15 -15
  201. package/dist/testing.d.ts +1 -0
  202. package/dist/testing.js +1 -0
  203. package/package.json +12 -3
  204. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  205. package/dist/api/LambderApiPolicyEngine.js +0 -85
  206. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  207. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,32 +1,82 @@
1
- import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
1
+ import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, isKeyRangeThrottle, } from "./LambderDdbSdk.js";
2
2
  import { assertNumberAtLeast } from "../shared/util/LambderOptionChecks.js";
3
+ import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
4
+ import { joinKeyFields } from "../shared/util/joinKeyFields.js";
3
5
  import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
6
+ /** How long a key refused during a throttle is told to wait: long enough to let the partition recover, short enough for a legitimate caller. */
7
+ const THROTTLED_RETRY_SECONDS = 5;
8
+ /**
9
+ * Most (tracker key, window) pairs the limiter remembers from throttles at
10
+ * once. Only a key seen during a key-range throttle is remembered, for
11
+ * THROTTLED_RETRY_SECONDS, so this is reached only by a flood spread over
12
+ * that many keys at once, where the ones closest to expiring go first.
13
+ */
14
+ const THROTTLED_WINDOW_MEMORY_ENTRIES = 10_000;
15
+ /** The table key of one window's counter. */
16
+ const counterKeyOf = (partitionKey, window) => ({
17
+ pk: { S: partitionKey },
18
+ sk: { S: `${window.key}#${window.start}` },
19
+ });
20
+ /** The same counter's key in the limiter's memory of throttled windows, its fields kept apart as the table's two attributes keep them. */
21
+ const rememberedWindowKeyOf = (partitionKey, window) => joinKeyFields(partitionKey, window.key, String(window.start));
4
22
  /**
5
23
  * Fixed-window rate limiter backed by DynamoDB.
6
24
  *
7
- * Each window is a single item counted with a conditional `ADD`, so the
8
- * increment and the limit check happen atomically in one request. Windows are
9
- * evaluated from smallest to largest and evaluation stops at the first
10
- * exceeded window, which keeps blocked requests cheap and spares the larger
11
- * counters. Attempts count, not successes: a counter checked before the
12
- * refusing one keeps its increment (there is no compensating decrement, which
13
- * would give up the conditional-ADD atomicity). Items carry an `expiresAt`
14
- * attribute for DynamoDB TTL.
25
+ * Each window is one item counted with a conditional `ADD`, so the increment
26
+ * and the limit check are one atomic request. Windows are evaluated smallest
27
+ * first and evaluation stops at the first exceeded one, which keeps blocked
28
+ * requests cheap and spares the larger counters. Attempts count, not
29
+ * successes: a counter checked before the refusing one keeps its increment,
30
+ * since a compensating decrement would give up the conditional-ADD
31
+ * atomicity. Items carry an `expiresAt` attribute for DynamoDB TTL.
15
32
  *
16
33
  * The tracker key is caller data (an address, a session key, whatever a
17
34
  * 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.
35
+ * DynamoDB's 2048-byte limit is refused here, before any window is counted.
36
+ * At the table it would come back as a ValidationException, which is not a
37
+ * conditional-check failure: it would escape as a store error, and a caller
38
+ * failing open on it would count nothing, leaving the limit silently off.
39
+ * Lambder's own engine folds an over-long key into a digest first, so a key
40
+ * that gets here came from a direct caller.
41
+ *
42
+ * A DynamoDB error propagates: a limiter cannot say whether the caller is
43
+ * over its limit when it cannot reach the table, and whether an unanswerable
44
+ * limit lets the request through is the application's call, made once for
45
+ * every limiter at `rateLimits.failOpen`.
46
+ *
47
+ * A throttle on the key's range can be an answer instead. DynamoDB
48
+ * throttles a partition's writes at roughly a thousand a second, and the SDK
49
+ * retries first, so a throttle whose reason is KeyRangeThroughputExceeded
50
+ * means the partition holding this counter is flooded. A partition holds a
51
+ * range of keys, though, not one: a flood on one address, or a session or
52
+ * cache spike on a shared table, throttles every counter on the same
53
+ * partition. The key's own counts tell the flood from its neighbours: the
54
+ * throttled window's and every capped window's after it, the ones this
55
+ * attempt has not been counted against yet, each read with a consistent
56
+ * GetItem, in parallel (reads have their own throughput, which the throttled
57
+ * writes leave alone). A key at or over the limit of any of them is the
58
+ * flood: it is refused, since passed on as a failure `failOpen` would wave
59
+ * the flood through unmetered, and the Retry-After is a few seconds, the
60
+ * time the partition takes to recover, rather than the window's reset. A key
61
+ * over its daily cap whose per-minute counter has just started again is the
62
+ * flood as much as one over its per-minute cap. Under every limit, or when a
63
+ * read fails too, the key is a neighbour: the throttle propagates like any
64
+ * other store failure and `failOpen` decides, as it does for a throttle of
65
+ * the table or the account (its provisioned capacity, an on-demand maximum,
66
+ * the account's quota), so no caller under its limit is refused because the
67
+ * table is busy with somebody else.
24
68
  *
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.
69
+ * A flood repeats, and each repeat would cost the partition another
70
+ * throttled write and another consistent read, until the reads throttle too
71
+ * and the flood fails open. So the process remembers, for
72
+ * THROTTLED_RETRY_SECONDS, each window it read at its limit, and refuses the
73
+ * key's next attempts from memory without touching the table, which also
74
+ * lets the partition recover for the neighbours. A count only rises within
75
+ * its window, so the table would answer the same. A window whose read was
76
+ * throttled as well is remembered too, with the throttle it was answered
77
+ * with: a next attempt whose write is throttled there again skips the read
78
+ * and throws that same throttle, which `rateLimits.failOpen` logs once
79
+ * rather than once per request.
30
80
  *
31
81
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
32
82
  * Items are prefixed `RL#` by default, so the table can be shared with
@@ -40,6 +90,8 @@ export class LambderDdbRateLimiter {
40
90
  ready;
41
91
  ttlWindowMultiplier;
42
92
  now;
93
+ /** The windows seen during key-range throttles, by (partition key, window, window start); see the class doc. */
94
+ throttledWindows;
43
95
  constructor(options) {
44
96
  if (!options.tableName.trim())
45
97
  throw new Error("tableName is required");
@@ -47,8 +99,13 @@ export class LambderDdbRateLimiter {
47
99
  this.keyPrefix = options.keyPrefix ?? "RL";
48
100
  this.ttlWindowMultiplier = assertNumberAtLeast(options.ttlWindowMultiplier ?? 2, 1, "ttlWindowMultiplier");
49
101
  this.now = options.now ?? (() => Date.now());
102
+ this.throttledWindows = new LambderExpiringMap({ now: this.now, maxEntries: THROTTLED_WINDOW_MEMORY_ENTRIES });
50
103
  this.ready = createDynamoClientLoader({ user: "LambderDdbRateLimiter", region: options.region, client: options.client });
51
104
  }
105
+ /** The clock the windows are computed against, for the engine's Retry-After. */
106
+ clockMilliseconds() {
107
+ return this.now();
108
+ }
52
109
  /**
53
110
  * Increment every configured window for `trackerKey` (IP, session, user id, ...)
54
111
  * and report whether any of them is over its limit, with the window's
@@ -61,14 +118,30 @@ export class LambderDdbRateLimiter {
61
118
  // refusing it here is the difference between one clear error and a
62
119
  // policy that counts nothing while reporting nothing.
63
120
  const partitionKey = this.partitionKeyFor(trackerKey);
64
- for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
65
- const limit = policy[key];
66
- if (!limit)
67
- continue;
68
- const windowStart = Math.floor(nowSeconds / seconds) * seconds;
69
- const exceeded = await this.incrementWindow(partitionKey, key, windowStart, seconds, limit, nowSeconds);
70
- if (exceeded)
71
- return { window: key, limit, resetAt: windowStart + seconds };
121
+ const windows = RATE_LIMIT_WINDOWS
122
+ .filter(({ key }) => policy[key])
123
+ .map(({ key, seconds }) => ({ key, seconds, limit: policy[key], start: Math.floor(nowSeconds / seconds) * seconds }));
124
+ // A window this process read at its limit during a throttle refuses
125
+ // without touching the table (see the class doc).
126
+ const rememberedAtLimit = windows.find((window) => {
127
+ const remembered = this.throttledWindows.get(rememberedWindowKeyOf(partitionKey, window));
128
+ return remembered !== undefined && "count" in remembered && remembered.count >= window.limit;
129
+ });
130
+ if (rememberedAtLimit)
131
+ return { window: rememberedAtLimit.key, limit: rememberedAtLimit.limit, resetAt: nowSeconds + THROTTLED_RETRY_SECONDS };
132
+ for (const [index, window] of windows.entries()) {
133
+ try {
134
+ if (await this.incrementWindow(partitionKey, window, nowSeconds))
135
+ return { window: window.key, limit: window.limit, resetAt: window.start + window.seconds };
136
+ }
137
+ catch (error) {
138
+ // A key-range throttle may be the limiter's own answer (see
139
+ // the class doc); anything else is the table failing, which
140
+ // only the caller can decide what to do about.
141
+ if (!isKeyRangeThrottle(error))
142
+ throw error;
143
+ return await this.answerKeyRangeThrottle(partitionKey, windows.slice(index), nowSeconds, error);
144
+ }
72
145
  }
73
146
  return false;
74
147
  }
@@ -81,22 +154,23 @@ export class LambderDdbRateLimiter {
81
154
  remedy: "Shorten the key the policy hands the limiter.",
82
155
  });
83
156
  }
84
- /** Increments one window counter. Returns true when the limit was already reached. */
85
- async incrementWindow(partitionKey, sortKeyPrefix, windowStart, windowSeconds, limit, nowSeconds) {
86
- const expiresAt = nowSeconds + Math.ceil(windowSeconds * this.ttlWindowMultiplier);
157
+ /**
158
+ * Increments one window counter. True when the limit was already reached
159
+ * (the refused condition is the limiter's own answer); any other failure
160
+ * throws.
161
+ */
162
+ async incrementWindow(partitionKey, window, nowSeconds) {
163
+ const expiresAt = nowSeconds + Math.ceil(window.seconds * this.ttlWindowMultiplier);
87
164
  const input = {
88
165
  TableName: this.tableName,
89
- Key: {
90
- pk: { S: partitionKey },
91
- sk: { S: `${sortKeyPrefix}#${windowStart}` },
92
- },
166
+ Key: counterKeyOf(partitionKey, window),
93
167
  UpdateExpression: "ADD #count :one SET #expiresAt = if_not_exists(#expiresAt, :expiresAt)",
94
168
  ConditionExpression: "attribute_not_exists(#count) OR #count < :limit",
95
169
  ExpressionAttributeNames: { "#count": "count", "#expiresAt": "expiresAt" },
96
170
  ExpressionAttributeValues: {
97
171
  ":one": { N: "1" },
98
172
  ":expiresAt": { N: String(expiresAt) },
99
- ":limit": { N: String(limit) },
173
+ ":limit": { N: String(window.limit) },
100
174
  },
101
175
  };
102
176
  try {
@@ -105,13 +179,51 @@ export class LambderDdbRateLimiter {
105
179
  return false;
106
180
  }
107
181
  catch (error) {
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
182
  if (isConditionalCheckFailure(error))
112
183
  return true;
113
184
  throw error;
114
185
  }
115
186
  }
187
+ /**
188
+ * The answer to a key-range throttle on the first of `uncounted`: that
189
+ * window and every capped one after it, the windows this attempt has not
190
+ * been counted against. The windows before it counted this attempt and
191
+ * allowed it, so reading them could only turn the attempt that filled
192
+ * one into a refusal.
193
+ *
194
+ * Each count is read strongly consistent: the count that decides is the
195
+ * one the throttled writes were racing to raise, and a replica lagging
196
+ * behind them could read a key that has just reached its limit as under
197
+ * it. Any window at or over its limit refuses, and is remembered (see the
198
+ * class doc). Otherwise the throttle is thrown on, and when a read was
199
+ * throttled too, the throttled window is remembered with it, so the
200
+ * key's next attempts skip the read and throw that same throttle.
201
+ */
202
+ async answerKeyRangeThrottle(partitionKey, uncounted, nowSeconds, throttle) {
203
+ const throttledWindowKey = rememberedWindowKeyOf(partitionKey, uncounted[0]);
204
+ const remembered = this.throttledWindows.get(throttledWindowKey);
205
+ if (remembered !== undefined && "unreadable" in remembered)
206
+ throw remembered.unreadable;
207
+ const { client, sdk } = await this.ready();
208
+ const reads = await Promise.allSettled(uncounted.map(async (window) => {
209
+ const { Item } = await client.send(new sdk.GetItemCommand({ TableName: this.tableName, Key: counterKeyOf(partitionKey, window), ConsistentRead: true }));
210
+ return Number(Item?.count?.N ?? 0);
211
+ }));
212
+ const rememberUntil = nowSeconds + THROTTLED_RETRY_SECONDS;
213
+ let refusing;
214
+ for (const [index, read] of reads.entries()) {
215
+ const window = uncounted[index];
216
+ if (read.status === "fulfilled" && read.value >= window.limit) {
217
+ this.throttledWindows.set(rememberedWindowKeyOf(partitionKey, window), { count: read.value }, rememberUntil);
218
+ refusing ??= window;
219
+ }
220
+ }
221
+ if (refusing)
222
+ return { window: refusing.key, limit: refusing.limit, resetAt: rememberUntil };
223
+ if (reads.some((read) => read.status === "rejected" && isKeyRangeThrottle(read.reason))) {
224
+ this.throttledWindows.set(throttledWindowKey, { unreadable: throttle }, rememberUntil);
225
+ }
226
+ throw throttle;
227
+ }
116
228
  }
117
229
  export default LambderDdbRateLimiter;
@@ -2,20 +2,18 @@
2
2
  * The DynamoDB SDK, loaded on first use.
3
3
  *
4
4
  * `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
5
- * dependencies, and an app that keeps no sessions and uses none of the
6
- * DynamoDB stores should neither install them nor pay for loading them: a
7
- * module-level import costs every cold start something and, bundled, makes
8
- * every Lambder app reference both packages. So the session manager and the
9
- * three stores import their types only and take the classes from here the
10
- * first time they touch the table, the way LambderS3FileSource and
11
- * LambderInvokeCaller load theirs. One loader per package, memoized for the
12
- * container's life; a missing package fails that first call with the
13
- * install hint rather than failing the import of lambder itself.
5
+ * dependencies: an app that uses no DynamoDB store should neither install
6
+ * them nor pay for loading them, and a module-level import would cost every
7
+ * cold start and make every bundled Lambder app reference both packages. So
8
+ * the session manager and the stores import their types only and take the
9
+ * classes from here the first time they touch the table, as
10
+ * LambderS3FileSource and LambderInvokeCaller do. One loader per package,
11
+ * memoized for the container's life; a missing package fails that first call
12
+ * with the install hint rather than failing the import of lambder itself.
14
13
  *
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
+ * The facts about DynamoDB itself that every store must agree on live here
15
+ * too: a conditional write's refusal is an answer rather than a failure, and
16
+ * a partition key has a limit that keys built from caller data can pass.
19
17
  */
20
18
  import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
21
19
  import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
@@ -28,19 +26,16 @@ export type LambderDynamoClientReady = {
28
26
  };
29
27
  /**
30
28
  * 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.
29
+ * call: load the SDK, then take the client the app supplied or the shared
30
+ * default one for the store's region. Memoized for the store's life, except
31
+ * that a FAILED load is not, so a store that ran before the package was
32
+ * installed asks again rather than caching the install hint forever.
35
33
  *
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.
34
+ * One implementation for every store, so they cannot drift apart on the
35
+ * region. `region` absent means the SDK's default chain (`AWS_REGION`, the
36
+ * Lambda environment, the shared config file); a store leaves it alone
37
+ * unless the caller names one, since pinning one (say `us-east-1`) would
38
+ * ignore the deployment's own region.
44
39
  */
45
40
  export declare const createDynamoClientLoader: (options: {
46
41
  user: string;
@@ -71,6 +66,23 @@ export declare const createDynamoDocumentClientLoader: (options: {
71
66
  * and the reason they must not catch any other.
72
67
  */
73
68
  export declare const isConditionalCheckFailure: (error: unknown) => boolean;
69
+ /**
70
+ * Whether DynamoDB refused a request, after the SDK's own retries, because
71
+ * of the rate at one key range, rather than the table's or the account's.
72
+ * A key range is a partition, which holds many keys, so the throttle falls
73
+ * on the key asked about and on its neighbours alike: whether this key is
74
+ * the one flooding it is the caller's to find out.
75
+ *
76
+ * Every throttle names why in its ThrottlingReasons (throttlingReasons on a
77
+ * ThrottlingException), as `<Table|Index><Read|Write><LimitType>`; only
78
+ * KeyRangeThroughputExceeded is about one partition. The other limit types
79
+ * (ProvisionedThroughputExceeded, AccountLimitExceeded,
80
+ * MaxOnDemandThroughputExceeded) are the table or the account running out,
81
+ * which says nothing about any one partition. An SDK older than the reasons
82
+ * (`@aws-sdk/client-dynamodb` before 3.868.0, the peer dependency's
83
+ * minimum), or a local DynamoDB, names none, and reads as not a key range's.
84
+ */
85
+ export declare const isKeyRangeThrottle: (error: unknown) => boolean;
74
86
  /**
75
87
  * DynamoDB's own limit on a partition key, which every store's
76
88
  * `<keyPrefix>#<caller data>` key has to fit inside.
@@ -78,14 +90,14 @@ export declare const isConditionalCheckFailure: (error: unknown) => boolean;
78
90
  export declare const MAX_PARTITION_KEY_BYTES = 2048;
79
91
  /**
80
92
  * 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
93
+ * builds its key from caller data (an idempotency key and the identity it is
94
+ * scoped by, a rate-limit key a policy handler returned), so the length is
83
95
  * the caller's to reach and this is the one place that measures it.
84
96
  *
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
97
+ * Checked here because DynamoDB answers an over-long key with a
98
+ * ValidationException, not a ConditionalCheckFailedException: it would
99
+ * escape as a store error that reads as "the table is broken" and, under a
100
+ * fail-open setting, be swallowed into no protection at all. The message
89
101
  * carries the count and never the key, because it reaches a log.
90
102
  */
91
103
  export declare const assertPartitionKeyFits: (options: {
@@ -2,20 +2,18 @@
2
2
  * The DynamoDB SDK, loaded on first use.
3
3
  *
4
4
  * `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
5
- * dependencies, and an app that keeps no sessions and uses none of the
6
- * DynamoDB stores should neither install them nor pay for loading them: a
7
- * module-level import costs every cold start something and, bundled, makes
8
- * every Lambder app reference both packages. So the session manager and the
9
- * three stores import their types only and take the classes from here the
10
- * first time they touch the table, the way LambderS3FileSource and
11
- * LambderInvokeCaller load theirs. One loader per package, memoized for the
12
- * container's life; a missing package fails that first call with the
13
- * install hint rather than failing the import of lambder itself.
5
+ * dependencies: an app that uses no DynamoDB store should neither install
6
+ * them nor pay for loading them, and a module-level import would cost every
7
+ * cold start and make every bundled Lambder app reference both packages. So
8
+ * the session manager and the stores import their types only and take the
9
+ * classes from here the first time they touch the table, as
10
+ * LambderS3FileSource and LambderInvokeCaller do. One loader per package,
11
+ * memoized for the container's life; a missing package fails that first call
12
+ * with the install hint rather than failing the import of lambder itself.
14
13
  *
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
+ * The facts about DynamoDB itself that every store must agree on live here
15
+ * too: a conditional write's refusal is an answer rather than a failure, and
16
+ * a partition key has a limit that keys built from caller data can pass.
19
17
  */
20
18
  let clientSdk;
21
19
  let documentSdk;
@@ -34,27 +32,42 @@ const loadDynamoDocumentSdk = (user) => {
34
32
  documentSdk ??= withInstallHint(import("@aws-sdk/lib-dynamodb"), "@aws-sdk/lib-dynamodb", user, () => { documentSdk = undefined; });
35
33
  return documentSdk;
36
34
  };
35
+ /**
36
+ * The default clients, one per region ("" for the SDK's default chain),
37
+ * shared by every store not given a client of its own. A client is a
38
+ * connection pool and a credential chain: sessions, rate limits,
39
+ * idempotency and the cache building one each would mean four pools and, on
40
+ * a cold container, four TLS handshakes and credential lookups inside the
41
+ * first request.
42
+ */
43
+ const defaultClients = new Map();
44
+ const defaultDocumentClients = new Map();
45
+ const defaultClientFor = (sdk, region) => {
46
+ let client = defaultClients.get(region ?? "");
47
+ if (!client) {
48
+ client = new sdk.DynamoDBClient(region ? { region } : {});
49
+ defaultClients.set(region ?? "", client);
50
+ }
51
+ return client;
52
+ };
37
53
  /**
38
54
  * 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.
55
+ * call: load the SDK, then take the client the app supplied or the shared
56
+ * default one for the store's region. Memoized for the store's life, except
57
+ * that a FAILED load is not, so a store that ran before the package was
58
+ * installed asks again rather than caching the install hint forever.
48
59
  *
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.
60
+ * One implementation for every store, so they cannot drift apart on the
61
+ * region. `region` absent means the SDK's default chain (`AWS_REGION`, the
62
+ * Lambda environment, the shared config file); a store leaves it alone
63
+ * unless the caller names one, since pinning one (say `us-east-1`) would
64
+ * ignore the deployment's own region.
52
65
  */
53
66
  export const createDynamoClientLoader = (options) => {
54
67
  let readyPromise;
55
68
  return () => {
56
69
  readyPromise ??= loadDynamoClientSdk(options.user)
57
- .then((sdk) => ({ sdk, client: options.client ?? new sdk.DynamoDBClient(options.region ? { region: options.region } : {}) }))
70
+ .then((sdk) => ({ sdk, client: options.client ?? defaultClientFor(sdk, options.region) }))
58
71
  .catch((error) => { readyPromise = undefined; throw error; });
59
72
  return readyPromise;
60
73
  };
@@ -71,7 +84,14 @@ export const createDynamoDocumentClientLoader = (options) => {
71
84
  if (options.client)
72
85
  return { sdk: await loadDynamoDocumentSdk(options.user), client: options.client };
73
86
  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 } : {})) };
87
+ // Over the same shared client the item-level stores use, so the
88
+ // session store's calls go through that one connection pool too.
89
+ let client = defaultDocumentClients.get(options.region ?? "");
90
+ if (!client) {
91
+ client = sdk.DynamoDBDocumentClient.from(defaultClientFor(clientSdk, options.region));
92
+ defaultDocumentClients.set(options.region ?? "", client);
93
+ }
94
+ return { sdk, client };
75
95
  };
76
96
  return () => {
77
97
  readyPromise ??= build().catch((error) => { readyPromise = undefined; throw error; });
@@ -86,6 +106,32 @@ export const createDynamoDocumentClientLoader = (options) => {
86
106
  * and the reason they must not catch any other.
87
107
  */
88
108
  export const isConditionalCheckFailure = (error) => !!error && typeof error === "object" && "name" in error && error.name === "ConditionalCheckFailedException";
109
+ /** DynamoDB's names for "too many requests": a hot partition, the table's or account's throughput, the request rate. */
110
+ const THROTTLE_ERROR_NAMES = ["ProvisionedThroughputExceededException", "ThrottlingException", "RequestLimitExceeded"];
111
+ /**
112
+ * Whether DynamoDB refused a request, after the SDK's own retries, because
113
+ * of the rate at one key range, rather than the table's or the account's.
114
+ * A key range is a partition, which holds many keys, so the throttle falls
115
+ * on the key asked about and on its neighbours alike: whether this key is
116
+ * the one flooding it is the caller's to find out.
117
+ *
118
+ * Every throttle names why in its ThrottlingReasons (throttlingReasons on a
119
+ * ThrottlingException), as `<Table|Index><Read|Write><LimitType>`; only
120
+ * KeyRangeThroughputExceeded is about one partition. The other limit types
121
+ * (ProvisionedThroughputExceeded, AccountLimitExceeded,
122
+ * MaxOnDemandThroughputExceeded) are the table or the account running out,
123
+ * which says nothing about any one partition. An SDK older than the reasons
124
+ * (`@aws-sdk/client-dynamodb` before 3.868.0, the peer dependency's
125
+ * minimum), or a local DynamoDB, names none, and reads as not a key range's.
126
+ */
127
+ export const isKeyRangeThrottle = (error) => {
128
+ if (!error || typeof error !== "object" || !("name" in error) || !THROTTLE_ERROR_NAMES.includes(String(error.name)))
129
+ return false;
130
+ const { ThrottlingReasons, throttlingReasons } = error;
131
+ const reasons = ThrottlingReasons ?? throttlingReasons;
132
+ return Array.isArray(reasons)
133
+ && reasons.some((reason) => typeof reason?.reason === "string" && reason.reason.endsWith("KeyRangeThroughputExceeded"));
134
+ };
89
135
  /**
90
136
  * DynamoDB's own limit on a partition key, which every store's
91
137
  * `<keyPrefix>#<caller data>` key has to fit inside.
@@ -93,14 +139,14 @@ export const isConditionalCheckFailure = (error) => !!error && typeof error ===
93
139
  export const MAX_PARTITION_KEY_BYTES = 2048;
94
140
  /**
95
141
  * 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
142
+ * builds its key from caller data (an idempotency key and the identity it is
143
+ * scoped by, a rate-limit key a policy handler returned), so the length is
98
144
  * the caller's to reach and this is the one place that measures it.
99
145
  *
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
146
+ * Checked here because DynamoDB answers an over-long key with a
147
+ * ValidationException, not a ConditionalCheckFailedException: it would
148
+ * escape as a store error that reads as "the table is broken" and, under a
149
+ * fail-open setting, be swallowed into no protection at all. The message
104
150
  * carries the count and never the key, because it reaches a log.
105
151
  */
106
152
  export const assertPartitionKeyFits = (options) => {
@@ -1,6 +1,6 @@
1
1
  import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
2
2
  import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
3
- import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
3
+ import type { LambderSessionChanges, LambderSessionRecord, LambderSessionStore, LambderSessionUpdateResult } from "../shared/contracts/LambderSessionStore.js";
4
4
  export type LambderDdbSessionStoreOptions = {
5
5
  tableName: string;
6
6
  /** Region the client is created for on first use; the SDK's default chain otherwise. */
@@ -28,6 +28,10 @@ export type LambderDdbSessionStoreOptions = {
28
28
  * Table shape: a string hash key (the salted sessionKey hash, every session
29
29
  * of one subject shares it) and a string range key (the bearer secret's
30
30
  * hash), plus a TTL on `expiresAt` to let DynamoDB sweep expired sessions.
31
+ *
32
+ * Every write is conditional: a create on the item not existing, an update
33
+ * on it existing, so no write already in flight can bring back a session a
34
+ * logout deleted.
31
35
  */
32
36
  export declare class LambderDdbSessionStore<SessionData = unknown> implements LambderSessionStore<SessionData> {
33
37
  /** DynamoDB keeps records after this process is gone. */
@@ -40,26 +44,35 @@ export declare class LambderDdbSessionStore<SessionData = unknown> implements La
40
44
  private readonly ready;
41
45
  constructor(options: LambderDdbSessionStoreOptions);
42
46
  private keyOf;
47
+ /**
48
+ * session.data as the attributes that hold it: `dataBr` and `dataBytes`
49
+ * when compressed, a plain `data` otherwise. Either way it goes through
50
+ * its JSON first, so a plain record holds exactly what a compressed one
51
+ * restores to: an `undefined` inside the data is dropped rather than
52
+ * handed to the document client, which refuses one and would fail the
53
+ * write (a login answering 500) only when compression is off.
54
+ */
55
+ private dataAttributes;
43
56
  /** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
44
57
  private toItem;
45
58
  /**
46
59
  * 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.
60
+ * one whose data cannot be decoded is malformed and reads as no session,
61
+ * like a record missing its csrfTokenHash.
49
62
  *
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.
63
+ * This is where read failures and malformed records separate. A read
64
+ * failure is infrastructure and must surface as a 500: signing somebody
65
+ * out over a transient DynamoDB error is worse than an error page. A
66
+ * record that will not decode will not decode on the next request either,
67
+ * so a 500 there would be a session the visitor can neither use nor clear
68
+ * until the TTL retires it. Ending it lets them log in again.
58
69
  */
59
70
  private fromItem;
60
71
  get(sessionKeyHash: string, secretHash: string): Promise<LambderSessionRecord<SessionData> | null>;
61
- put(record: LambderSessionRecord<SessionData>): Promise<void>;
62
- delete(sessionKeyHash: string, secretHash: string): Promise<void>;
72
+ create(record: LambderSessionRecord<SessionData>): Promise<void>;
73
+ update(sessionKeyHash: string, secretHash: string, changes: LambderSessionChanges<SessionData>, condition?: {
74
+ dataVersion: number;
75
+ }): Promise<LambderSessionUpdateResult>;
76
+ delete(sessionKeyHash: string, secretHash: string): Promise<LambderSessionRecord<SessionData> | null>;
63
77
  listSecretHashes(sessionKeyHash: string): Promise<string[]>;
64
- markDataExpired(sessionKeyHash: string, secretHash: string, at: number): Promise<void>;
65
78
  }