lambder 14.1.1 → 15.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 (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -2,12 +2,15 @@ import { createDynamoClientLoader, isConditionalCheckFailure } from "./LambderDd
2
2
  import { getCrypto } from "../shared/util/LambderNodeModules.js";
3
3
  import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
4
4
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
5
- import { compressText, restoreText, LambderCompressionError } from "../shared/wire/LambderCompressionCodec.js";
5
+ import { LambderCompressionError } from "../shared/wire/LambderCompressionCodec.js";
6
6
  import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
7
7
  import { LRUCache } from "lru-cache";
8
8
  import { cacheMemoryKeyOf, decodeCacheSortKey, encodeCacheSortKey, normalizeCacheKey, normalizeCachePartition, } from "./LambderCacheKeys.js";
9
9
  import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
10
10
  import { LambderCacheFiller } from "./LambderCacheFiller.js";
11
+ import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
12
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
13
+ import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
11
14
  const DEFAULT_CHUNK_BYTES = 350 * 1024;
12
15
  const MAX_SAFE_CHUNK_BYTES = 380 * 1024;
13
16
  const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
@@ -155,6 +158,10 @@ class LocalWriteLedger {
155
158
  return Math.floor(this.now() / 1000) + RECENT_WRITE_SECONDS;
156
159
  }
157
160
  }
161
+ /** Every LambderCache member, which the swap door hands to a memory twin. */
162
+ const CACHE_MEMBERS = {
163
+ get: true, has: true, set: true, delete: true, deletePartition: true, listSortKeys: true, getOrSet: true,
164
+ };
158
165
  /**
159
166
  * Persistent JSON cache backed by DynamoDB.
160
167
  *
@@ -235,6 +242,18 @@ export class LambderDdbCache {
235
242
  this.localWrites = new LocalWriteLedger(this.now);
236
243
  this.ready = createDynamoClientLoader({ user: "LambderDdbCache", region: options.region, client: options.client });
237
244
  this.filler = new LambderCacheFiller(`DynamoDB cache failed open in ${this.namespace}`);
245
+ registerSwappableInstance(this);
246
+ }
247
+ /**
248
+ * Puts a memory twin under this cache in place, for `lambder/testing`:
249
+ * every LambderCache member answers from the twin from then on. The twin
250
+ * is built with this cache's own default TTL, size limit and clock, so a
251
+ * test meets the rules this cache was configured with. Keyed by a symbol
252
+ * no entry point exports; see registerSwappableInstance.
253
+ */
254
+ [LAMBDER_BACKEND_SWAP](twins) {
255
+ const twin = twins.cache({ defaultTtlSeconds: this.defaultTtlSeconds, maxValueBytes: this.maxValueBytes, now: this.now });
256
+ delegateToTwin(this, twin, CACHE_MEMBERS);
238
257
  }
239
258
  async get(key) {
240
259
  return await this.getByAddress(normalizeCacheKey(key));
@@ -358,9 +377,7 @@ export class LambderDdbCache {
358
377
  async setByAddress(address, value, ttlSeconds, leaseOwner) {
359
378
  const { json, utf8 } = serializeCacheValue(value, this.maxValueBytes);
360
379
  const input = Buffer.from(utf8.buffer, utf8.byteOffset, utf8.byteLength);
361
- const brotli = this.compression && input.length >= this.compression.minBytes ? this.compression : null;
362
- const encoding = brotli ? "br" : "identity";
363
- const stored = brotli ? await compressText(input, "br", brotli.quality) : input;
380
+ const { encoding, stored } = await storedTextOf(input, this.compression);
364
381
  if (stored.length > this.maxValueBytes) {
365
382
  throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
366
383
  }
@@ -881,9 +898,11 @@ export class LambderDdbCache {
881
898
  }
882
899
  }
883
900
  }
884
- /** The JSON text of a stored payload. */
901
+ /** The JSON text of a stored payload, restored under maxValueBytes, the most this cache writes. */
885
902
  async decode(stored, encoding, uncompressedBytes) {
886
- return encoding === "br" ? await restoreText(stored, "br", { declaredBytes: uncompressedBytes }) : stored.toString("utf8");
903
+ if (encoding === "identity")
904
+ return stored.toString("utf8");
905
+ return await restoreStoredText(stored, uncompressedBytes, { user: "LambderDdbCache", maxTextBytes: this.maxValueBytes });
887
906
  }
888
907
  remember(key, stored, encoding, uncompressedBytes, expiresAt) {
889
908
  if (!this.memory)
@@ -75,8 +75,9 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
75
75
  /**
76
76
  * A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
77
77
  * `bodyBytes`). The stored length is the decompression budget, so it is
78
- * checked here the way the headers are: a record declaring more than this
79
- * store ever writes is unusable, not an invitation to allocate it.
78
+ * checked against the ceiling the way the headers are checked: a record
79
+ * declaring more than this store ever writes is unusable, not an
80
+ * invitation to allocate it.
80
81
  */
81
82
  private static readItemBody;
82
83
  /** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
@@ -113,8 +114,9 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
113
114
  * compression off, stay plain. Returns:
114
115
  *
115
116
  * - "stored": the record is in place and will replay.
116
- * - "too-large": even compressed, the body exceeds the item budget;
117
- * nothing was written and the caller should release the claim.
117
+ * - "too-large": the body is past the store's ceiling, or even
118
+ * compressed it exceeds the item budget; nothing was written and the
119
+ * caller should release the claim.
118
120
  * - "lost": the ownerToken no longer matches, i.e. the claim expired and
119
121
  * a retry took the scope over; nothing was written.
120
122
  */
@@ -1,6 +1,6 @@
1
- import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
1
+ import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, storedNumber, } from "./LambderDdbSdk.js";
2
+ import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
2
3
  import { getCrypto } from "../shared/util/LambderNodeModules.js";
3
- import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
4
4
  import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
5
5
  /** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
6
6
  const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
@@ -11,15 +11,13 @@ const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
11
11
  */
12
12
  const MAX_STORED_BODY_BYTES = 350_000;
13
13
  /**
14
- * Ceiling on a stored body's declared length, the budget the restore
15
- * decompresses under. The store's own writes stay far inside it (a response
16
- * that reaches a client is a few megabytes at most, and the compressed bytes
17
- * must fit MAX_STORED_BODY_BYTES), so a record declaring more is not this
18
- * store's, and trusting it would let a few hundred kilobytes of Brotli expand
19
- * until the function dies. The cache bounds the same number against its
20
- * maxValueBytes.
14
+ * Ceiling on a stored body, in UTF-8 bytes: the most the store writes and the
15
+ * most it restores (see LambderStoredText). A response that reaches a client
16
+ * is a few megabytes at most, so a body past it answers "too-large", and a
17
+ * record declaring more is not this store's. The cache bounds the same
18
+ * number against its maxValueBytes.
21
19
  */
22
- const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
20
+ const BODY_CEILING = { user: "LambderDdbIdempotencyStore", maxTextBytes: 32 * 1024 * 1024 };
23
21
  /**
24
22
  * What an item that keeps no fingerprint reports: one no request matches,
25
23
  * since the engine's fingerprints are never empty. Such an item was not
@@ -29,16 +27,6 @@ const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
29
27
  * free and running the request over it.
30
28
  */
31
29
  const UNKNOWN_REQUEST_FINGERPRINT = "";
32
- /**
33
- * A number attribute as stored, or the fallback when it is missing or not a
34
- * number. `Number(undefined)` and `Number("nope")` are both NaN, which every
35
- * later comparison answers false to: a NaN expiry reads as "not expired" and
36
- * a NaN status code reaches the client as one.
37
- */
38
- const storedNumber = (raw, fallback) => {
39
- const value = Number(raw);
40
- return Number.isFinite(value) ? value : fallback;
41
- };
42
30
  /** 16 random bytes, hex, through the optional-crypto seam so a bundler's browser stub cannot break the import. */
43
31
  const newOwnerToken = async () => {
44
32
  const crypto = await getCrypto();
@@ -138,18 +126,15 @@ export class LambderDdbIdempotencyStore {
138
126
  /**
139
127
  * A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
140
128
  * `bodyBytes`). The stored length is the decompression budget, so it is
141
- * checked here the way the headers are: a record declaring more than this
142
- * store ever writes is unusable, not an invitation to allocate it.
129
+ * checked against the ceiling the way the headers are checked: a record
130
+ * declaring more than this store ever writes is unusable, not an
131
+ * invitation to allocate it.
143
132
  */
144
133
  static async readItemBody(item) {
145
134
  const compressed = item.bodyBr?.B;
146
135
  if (!compressed)
147
136
  return item.body?.S ?? "";
148
- const declaredBytes = storedNumber(item.bodyBytes?.N, 0);
149
- if (declaredBytes > MAX_REPLAY_BODY_BYTES) {
150
- throw new Error(`LambderDdbIdempotencyStore: the stored body declares ${declaredBytes} bytes, over the ${MAX_REPLAY_BODY_BYTES}-byte replay limit, so the record is unusable.`);
151
- }
152
- return await restoreText(compressed, "br", { declaredBytes });
137
+ return await restoreStoredText(compressed, storedNumber(item.bodyBytes?.N, 0), BODY_CEILING);
153
138
  }
154
139
  /** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
155
140
  static async answerOf(item) {
@@ -252,37 +237,32 @@ export class LambderDdbIdempotencyStore {
252
237
  * compression off, stay plain. Returns:
253
238
  *
254
239
  * - "stored": the record is in place and will replay.
255
- * - "too-large": even compressed, the body exceeds the item budget;
256
- * nothing was written and the caller should release the claim.
240
+ * - "too-large": the body is past the store's ceiling, or even
241
+ * compressed it exceeds the item budget; nothing was written and the
242
+ * caller should release the claim.
257
243
  * - "lost": the ownerToken no longer matches, i.e. the claim expired and
258
244
  * a retry took the scope over; nothing was written.
259
245
  */
260
246
  async complete(scopeKey, ownerToken, { statusCode, headers, body, fingerprint, ttlSeconds }) {
261
247
  const nowSeconds = this.nowSeconds();
262
248
  const rawBody = Buffer.from(body, "utf8");
263
- let bodyAttributes;
264
- // A zero-length body is never compressed, whatever minBytes says: it
265
- // would be stored as `bodyBr` with `bodyBytes: 0`, and a declared
266
- // length of zero is one the codec refuses on the way back, so the
267
- // record would be unreadable for its whole TTL and every retry would
268
- // execute again. The plain path stores it as the empty string, which
269
- // reads back as one.
270
- if (this.compression && rawBody.byteLength > 0 && rawBody.byteLength >= this.compression.minBytes) {
271
- const compressed = await compressText(rawBody, "br", this.compression.quality);
272
- if (compressed.byteLength > MAX_STORED_BODY_BYTES)
273
- return "too-large";
274
- // bodyBytes bounds and verifies decompression on read.
275
- bodyAttributes = { bodyBr: { B: compressed }, bodyBytes: { N: String(rawBody.byteLength) } };
276
- }
277
- else {
278
- // The budget is on what actually gets stored, so the plain path is
279
- // measured too. Without this an oversized body reaches DynamoDB and
280
- // comes back as a ValidationException, which is not a
281
- // ConditionalCheckFailedException and so escapes as a store error.
282
- if (rawBody.byteLength > MAX_STORED_BODY_BYTES)
283
- return "too-large";
284
- bodyAttributes = { body: { S: body } };
285
- }
249
+ // Past the ceiling, the record would be one the store refuses to
250
+ // read back, however small its compressed bytes.
251
+ if (rawBody.byteLength > BODY_CEILING.maxTextBytes)
252
+ return "too-large";
253
+ const text = await storedTextOf(rawBody, this.compression);
254
+ // The budget is on what actually gets stored, plain or compressed.
255
+ // Without it an oversized body reaches DynamoDB and comes back as a
256
+ // ValidationException, which is not a ConditionalCheckFailedException
257
+ // and so escapes as a store error.
258
+ if (text.stored.byteLength > MAX_STORED_BODY_BYTES)
259
+ return "too-large";
260
+ // bodyBytes bounds and verifies decompression on read. The plain path
261
+ // stores the body as a string, an empty one included, which reads
262
+ // back as one.
263
+ const bodyAttributes = text.encoding === "br"
264
+ ? { bodyBr: { B: text.stored }, bodyBytes: { N: String(text.textBytes) } }
265
+ : { body: { S: body } };
286
266
  try {
287
267
  const { client, sdk } = await this.ready();
288
268
  await client.send(new sdk.PutItemCommand({
@@ -1,5 +1,6 @@
1
1
  import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
2
  import type { LambderOneShotIssueOutcome, LambderOneShotSecretDraft, LambderOneShotSecretRecord, LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
3
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
3
4
  export interface LambderDdbOneShotSecretStoreOptions {
4
5
  tableName: string;
5
6
  /** Region the client is created for on first use; the SDK's default chain otherwise. */
@@ -44,6 +45,16 @@ export declare class LambderDdbOneShotSecretStore implements LambderOneShotSecre
44
45
  readonly keyPrefix: string;
45
46
  private readonly ready;
46
47
  constructor(options: LambderDdbOneShotSecretStoreOptions);
48
+ /**
49
+ * Puts a memory twin under this store in place, for `lambder/testing`:
50
+ * every LambderOneShotSecretStore member answers from the twin from then
51
+ * on, so the LambderOneShotSecrets built over this store issues and
52
+ * verifies in memory. Keyed by a symbol no entry point exports; see
53
+ * registerSwappableInstance.
54
+ */
55
+ [LAMBDER_BACKEND_SWAP](twins: {
56
+ oneShotSecretStore(): LambderOneShotSecretStore;
57
+ }): void;
47
58
  private scopeKey;
48
59
  private digestKey;
49
60
  /**
@@ -1,5 +1,7 @@
1
- import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
1
+ import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, storedNumber, } from "./LambderDdbSdk.js";
2
2
  import { randomSecret } from "../shared/util/LambderSignedClaims.js";
3
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
4
+ import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
3
5
  /**
4
6
  * Why each item of a cancelled transaction was refused, in the order the
5
7
  * items were sent, or null when the error is not a cancelled transaction.
@@ -46,10 +48,9 @@ const sendRetryingConflicts = async (write) => {
46
48
  }
47
49
  }
48
50
  };
49
- /** A number attribute as stored, or the fallback when it is missing or not a number, since NaN compares false to everything. */
50
- const storedNumber = (raw, fallback) => {
51
- const value = Number(raw);
52
- return Number.isFinite(value) ? value : fallback;
51
+ /** Every LambderOneShotSecretStore member, which the swap door hands to a memory twin. */
52
+ const ONE_SHOT_SECRET_STORE_MEMBERS = {
53
+ issue: true, findByScope: true, findByDigest: true, attempt: true, consume: true, retire: true,
53
54
  };
54
55
  /**
55
56
  * One-shot secrets in DynamoDB, under the store's prefix, so the table can be
@@ -92,6 +93,17 @@ export class LambderDdbOneShotSecretStore {
92
93
  this.tableName = options.tableName;
93
94
  this.keyPrefix = options.keyPrefix ?? "OTS";
94
95
  this.ready = createDynamoClientLoader({ user: "LambderDdbOneShotSecretStore", region: options.region, client: options.client });
96
+ registerSwappableInstance(this);
97
+ }
98
+ /**
99
+ * Puts a memory twin under this store in place, for `lambder/testing`:
100
+ * every LambderOneShotSecretStore member answers from the twin from then
101
+ * on, so the LambderOneShotSecrets built over this store issues and
102
+ * verifies in memory. Keyed by a symbol no entry point exports; see
103
+ * registerSwappableInstance.
104
+ */
105
+ [LAMBDER_BACKEND_SWAP](twins) {
106
+ delegateToTwin(this, twins.oneShotSecretStore(), ONE_SHOT_SECRET_STORE_MEMBERS);
95
107
  }
96
108
  scopeKey(scope) {
97
109
  return {
@@ -1,24 +1,27 @@
1
1
  /**
2
2
  * The DynamoDB SDK, loaded on first use.
3
3
  *
4
- * `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
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
4
+ * `@aws-sdk/client-dynamodb` is an optional peer dependency: an app that uses
5
+ * no DynamoDB store should neither install it nor pay for loading it, and a
6
+ * module-level import would cost every cold start and make every bundled
7
+ * Lambder app reference it. So the stores import its types only and take the
9
8
  * 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.
9
+ * LambderS3FileSource and LambderInvokeCaller do. One loader, memoized for
10
+ * the container's life; a missing package fails that first call with the
11
+ * install hint rather than failing the import of lambder itself.
12
+ *
13
+ * Every store speaks the item-level API, attribute values and all, so they
14
+ * take one client type and need one package. The session store, the one
15
+ * whose items hold an app's JSON, converts it with marshallJsonValue and
16
+ * unmarshallJsonValue below, so no store needs `@aws-sdk/lib-dynamodb`.
13
17
  *
14
18
  * 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
+ * too: a conditional write's refusal is an answer rather than a failure, a
20
+ * partition key and an item each have a limit that caller data can pass, and
21
+ * a number attribute read back may be missing or not a number.
17
22
  */
18
- import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
19
- import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
23
+ import type { AttributeValue, DynamoDBClient } from "@aws-sdk/client-dynamodb";
20
24
  type LambderDynamoClientSdk = typeof import("@aws-sdk/client-dynamodb");
21
- type LambderDynamoDocumentSdk = typeof import("@aws-sdk/lib-dynamodb");
22
25
  /** What every DynamoDB-backed store needs before it can touch its table: the SDK, and a client made with it. */
23
26
  export type LambderDynamoClientReady = {
24
27
  client: DynamoDBClient;
@@ -42,22 +45,6 @@ export declare const createDynamoClientLoader: (options: {
42
45
  region?: string;
43
46
  client?: DynamoDBClient;
44
47
  }) => (() => Promise<LambderDynamoClientReady>);
45
- /** What the session store needs before its first table call: the document SDK, and a document client made with it. */
46
- export type LambderDynamoDocumentClientReady = {
47
- client: DynamoDBDocumentClient;
48
- sdk: LambderDynamoDocumentSdk;
49
- };
50
- /**
51
- * The document-client twin of createDynamoClientLoader, for the one store
52
- * that speaks the document API. A supplied client is taken as is, and then
53
- * only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
54
- * only to construct a client of our own.
55
- */
56
- export declare const createDynamoDocumentClientLoader: (options: {
57
- user: string;
58
- region?: string;
59
- client?: DynamoDBDocumentClient;
60
- }) => (() => Promise<LambderDynamoDocumentClientReady>);
61
48
  /**
62
49
  * Whether DynamoDB refused a write because its condition did not hold, which
63
50
  * is how every conditional write here reports the thing it was testing for: a
@@ -106,4 +93,47 @@ export declare const assertPartitionKeyFits: (options: {
106
93
  partitionKey: string;
107
94
  remedy: string;
108
95
  }) => string;
96
+ /** DynamoDB's own limit on one item, its attribute names and values together: 400 KB. */
97
+ export declare const MAX_ITEM_BYTES: number;
98
+ /**
99
+ * The size DynamoDB counts for one attribute value against its item limit: a
100
+ * string's UTF-8 bytes, a binary's bytes, one byte for a boolean or null, a
101
+ * byte per two significant digits of a number and one more, and for a map
102
+ * or a list three bytes plus, per element, one byte, its name (a map's) and
103
+ * its value. These are the rules DynamoDB states for its item limit, so a
104
+ * store can refuse an item by its size before the table answers it with a
105
+ * ValidationException, which is not a ConditionalCheckFailedException and
106
+ * so escapes as a store error that names nothing the app can act on. The
107
+ * set types are sized nowhere because no store writes one.
108
+ */
109
+ export declare const attributeValueBytes: (value: AttributeValue) => number;
110
+ /**
111
+ * A JSON value as the attribute DynamoDB keeps it: a string as `S`, a number
112
+ * as `N` in its shortest round-trip text, a boolean as `BOOL`, null as
113
+ * `NULL`, an array as `L` and an object as `M`. This is what
114
+ * `@aws-sdk/lib-dynamodb`'s document client writes for the same value, so an
115
+ * item written either way reads back through unmarshallJsonValue.
116
+ *
117
+ * JSON only, as the name says: anything JSON.parse cannot hand back (an
118
+ * undefined, a non-finite number, a function, a Set, a class instance) is
119
+ * refused rather than guessed at. Map entries are built with
120
+ * Object.fromEntries so that a `__proto__` key, which JSON.parse keeps as an
121
+ * own key, stays an entry rather than setting the prototype of the map.
122
+ */
123
+ export declare const marshallJsonValue: (value: unknown) => AttributeValue;
124
+ /**
125
+ * The JSON value an attribute holds: the inverse of marshallJsonValue, and
126
+ * the reading of what the document client wrote for one. A number reads as
127
+ * the JS number its text names, as JSON.parse would read the same text. An
128
+ * attribute JSON has no form for (a binary, a set) throws: it was not
129
+ * written from JSON, so it is not this store's.
130
+ */
131
+ export declare const unmarshallJsonValue: (attribute: AttributeValue) => unknown;
132
+ /**
133
+ * A number attribute as stored, or the fallback when it is missing or not a
134
+ * number. `Number(undefined)` and `Number("nope")` are both NaN, which every
135
+ * later comparison answers false to: a NaN expiry reads as "not expired" and
136
+ * a NaN status code reaches the client as one.
137
+ */
138
+ export declare const storedNumber: (raw: string | undefined, fallback: number) => number;
109
139
  export {};
@@ -1,33 +1,32 @@
1
1
  /**
2
2
  * The DynamoDB SDK, loaded on first use.
3
3
  *
4
- * `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
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
4
+ * `@aws-sdk/client-dynamodb` is an optional peer dependency: an app that uses
5
+ * no DynamoDB store should neither install it nor pay for loading it, and a
6
+ * module-level import would cost every cold start and make every bundled
7
+ * Lambder app reference it. So the stores import its types only and take the
9
8
  * 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.
9
+ * LambderS3FileSource and LambderInvokeCaller do. One loader, memoized for
10
+ * the container's life; a missing package fails that first call with the
11
+ * install hint rather than failing the import of lambder itself.
12
+ *
13
+ * Every store speaks the item-level API, attribute values and all, so they
14
+ * take one client type and need one package. The session store, the one
15
+ * whose items hold an app's JSON, converts it with marshallJsonValue and
16
+ * unmarshallJsonValue below, so no store needs `@aws-sdk/lib-dynamodb`.
13
17
  *
14
18
  * 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
+ * too: a conditional write's refusal is an answer rather than a failure, a
20
+ * partition key and an item each have a limit that caller data can pass, and
21
+ * a number attribute read back may be missing or not a number.
17
22
  */
18
23
  import { withInstallHint } from "./LambderSdkInstallHint.js";
19
24
  let clientSdk;
20
- let documentSdk;
21
- /** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
25
+ /** `@aws-sdk/client-dynamodb`, for the item-level API every store speaks. */
22
26
  const loadDynamoClientSdk = (user) => {
23
27
  clientSdk ??= withInstallHint(import("@aws-sdk/client-dynamodb"), "@aws-sdk/client-dynamodb", user, () => { clientSdk = undefined; });
24
28
  return clientSdk;
25
29
  };
26
- /** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
27
- const loadDynamoDocumentSdk = (user) => {
28
- documentSdk ??= withInstallHint(import("@aws-sdk/lib-dynamodb"), "@aws-sdk/lib-dynamodb", user, () => { documentSdk = undefined; });
29
- return documentSdk;
30
- };
31
30
  /**
32
31
  * The default clients, one per region ("" for the SDK's default chain),
33
32
  * shared by every store not given a client of its own. A client is a
@@ -37,7 +36,6 @@ const loadDynamoDocumentSdk = (user) => {
37
36
  * first request.
38
37
  */
39
38
  const defaultClients = new Map();
40
- const defaultDocumentClients = new Map();
41
39
  const defaultClientFor = (sdk, region) => {
42
40
  let client = defaultClients.get(region ?? "");
43
41
  if (!client) {
@@ -68,32 +66,6 @@ export const createDynamoClientLoader = (options) => {
68
66
  return readyPromise;
69
67
  };
70
68
  };
71
- /**
72
- * The document-client twin of createDynamoClientLoader, for the one store
73
- * that speaks the document API. A supplied client is taken as is, and then
74
- * only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
75
- * only to construct a client of our own.
76
- */
77
- export const createDynamoDocumentClientLoader = (options) => {
78
- let readyPromise;
79
- const build = async () => {
80
- if (options.client)
81
- return { sdk: await loadDynamoDocumentSdk(options.user), client: options.client };
82
- const [clientSdk, sdk] = await Promise.all([loadDynamoClientSdk(options.user), loadDynamoDocumentSdk(options.user)]);
83
- // Over the same shared client the item-level stores use, so the
84
- // session store's calls go through that one connection pool too.
85
- let client = defaultDocumentClients.get(options.region ?? "");
86
- if (!client) {
87
- client = sdk.DynamoDBDocumentClient.from(defaultClientFor(clientSdk, options.region));
88
- defaultDocumentClients.set(options.region ?? "", client);
89
- }
90
- return { sdk, client };
91
- };
92
- return () => {
93
- readyPromise ??= build().catch((error) => { readyPromise = undefined; throw error; });
94
- return readyPromise;
95
- };
96
- };
97
69
  /**
98
70
  * Whether DynamoDB refused a write because its condition did not hold, which
99
71
  * is how every conditional write here reports the thing it was testing for: a
@@ -152,3 +124,103 @@ export const assertPartitionKeyFits = (options) => {
152
124
  }
153
125
  return options.partitionKey;
154
126
  };
127
+ /** DynamoDB's own limit on one item, its attribute names and values together: 400 KB. */
128
+ export const MAX_ITEM_BYTES = 400 * 1024;
129
+ /**
130
+ * The significant digits of a number attribute's text: what DynamoDB stores
131
+ * of it. Leading and trailing zeroes are not stored, nor are the sign, the
132
+ * point and an exponent.
133
+ */
134
+ const significantDigits = (numberText) => Math.max(numberText.replace(/^[-+]/, "").split(/e/i)[0].replace(".", "").replace(/^0+/, "").replace(/0+$/, "").length, 1);
135
+ /**
136
+ * The size DynamoDB counts for one attribute value against its item limit: a
137
+ * string's UTF-8 bytes, a binary's bytes, one byte for a boolean or null, a
138
+ * byte per two significant digits of a number and one more, and for a map
139
+ * or a list three bytes plus, per element, one byte, its name (a map's) and
140
+ * its value. These are the rules DynamoDB states for its item limit, so a
141
+ * store can refuse an item by its size before the table answers it with a
142
+ * ValidationException, which is not a ConditionalCheckFailedException and
143
+ * so escapes as a store error that names nothing the app can act on. The
144
+ * set types are sized nowhere because no store writes one.
145
+ */
146
+ export const attributeValueBytes = (value) => {
147
+ if (value.S !== undefined)
148
+ return Buffer.byteLength(value.S, "utf8");
149
+ if (value.N !== undefined)
150
+ return Math.ceil(significantDigits(value.N) / 2) + 1;
151
+ if (value.B !== undefined)
152
+ return value.B.byteLength;
153
+ if (value.BOOL !== undefined || value.NULL !== undefined)
154
+ return 1;
155
+ if (value.L !== undefined)
156
+ return value.L.reduce((total, element) => total + 1 + attributeValueBytes(element), 3);
157
+ if (value.M !== undefined) {
158
+ return Object.entries(value.M).reduce((total, [name, element]) => total + 1 + Buffer.byteLength(name, "utf8") + attributeValueBytes(element), 3);
159
+ }
160
+ throw new Error(`attributeValueBytes cannot size an attribute of type ${Object.keys(value).join(", ") || "none"}`);
161
+ };
162
+ /**
163
+ * A JSON value as the attribute DynamoDB keeps it: a string as `S`, a number
164
+ * as `N` in its shortest round-trip text, a boolean as `BOOL`, null as
165
+ * `NULL`, an array as `L` and an object as `M`. This is what
166
+ * `@aws-sdk/lib-dynamodb`'s document client writes for the same value, so an
167
+ * item written either way reads back through unmarshallJsonValue.
168
+ *
169
+ * JSON only, as the name says: anything JSON.parse cannot hand back (an
170
+ * undefined, a non-finite number, a function, a Set, a class instance) is
171
+ * refused rather than guessed at. Map entries are built with
172
+ * Object.fromEntries so that a `__proto__` key, which JSON.parse keeps as an
173
+ * own key, stays an entry rather than setting the prototype of the map.
174
+ */
175
+ export const marshallJsonValue = (value) => {
176
+ if (value === null)
177
+ return { NULL: true };
178
+ if (typeof value === "string")
179
+ return { S: value };
180
+ if (typeof value === "boolean")
181
+ return { BOOL: value };
182
+ if (typeof value === "number") {
183
+ if (!Number.isFinite(value))
184
+ throw new Error(`marshallJsonValue cannot store the number ${value}: JSON has no such value.`);
185
+ return { N: String(value) };
186
+ }
187
+ if (Array.isArray(value))
188
+ return { L: value.map(marshallJsonValue) };
189
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
190
+ return { M: Object.fromEntries(Object.entries(value).map(([name, element]) => [name, marshallJsonValue(element)])) };
191
+ }
192
+ throw new Error(`marshallJsonValue cannot store a value of type ${value === undefined ? "undefined" : typeof value}: it is not JSON.`);
193
+ };
194
+ /**
195
+ * The JSON value an attribute holds: the inverse of marshallJsonValue, and
196
+ * the reading of what the document client wrote for one. A number reads as
197
+ * the JS number its text names, as JSON.parse would read the same text. An
198
+ * attribute JSON has no form for (a binary, a set) throws: it was not
199
+ * written from JSON, so it is not this store's.
200
+ */
201
+ export const unmarshallJsonValue = (attribute) => {
202
+ if (attribute.S !== undefined)
203
+ return attribute.S;
204
+ if (attribute.N !== undefined)
205
+ return Number(attribute.N);
206
+ if (attribute.BOOL !== undefined)
207
+ return attribute.BOOL;
208
+ if (attribute.NULL !== undefined)
209
+ return null;
210
+ if (attribute.L !== undefined)
211
+ return attribute.L.map(unmarshallJsonValue);
212
+ if (attribute.M !== undefined) {
213
+ return Object.fromEntries(Object.entries(attribute.M).map(([name, element]) => [name, unmarshallJsonValue(element)]));
214
+ }
215
+ throw new Error(`unmarshallJsonValue cannot read an attribute of type ${Object.keys(attribute).join(", ") || "none"} as JSON.`);
216
+ };
217
+ /**
218
+ * A number attribute as stored, or the fallback when it is missing or not a
219
+ * number. `Number(undefined)` and `Number("nope")` are both NaN, which every
220
+ * later comparison answers false to: a NaN expiry reads as "not expired" and
221
+ * a NaN status code reaches the client as one.
222
+ */
223
+ export const storedNumber = (raw, fallback) => {
224
+ const value = Number(raw);
225
+ return Number.isFinite(value) ? value : fallback;
226
+ };