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
@@ -1,4 +1,4 @@
1
- import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
1
+ import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
2
  import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
3
3
  import type { LambderSessionChanges, LambderSessionRecord, LambderSessionStore, LambderSessionUpdateResult } from "../shared/contracts/LambderSessionStore.js";
4
4
  export type LambderDdbSessionStoreOptions = {
@@ -16,8 +16,8 @@ export type LambderDdbSessionStoreOptions = {
16
16
  * it can be switched on or off on a live table.
17
17
  */
18
18
  compression?: LambderCompressionOption;
19
- /** A ready document client, e.g. one shared with the rest of the app. */
20
- client?: DynamoDBDocumentClient;
19
+ /** A ready client, e.g. one shared with the rest of the app; the stores' shared one for the region otherwise. */
20
+ client?: DynamoDBClient;
21
21
  };
22
22
  /**
23
23
  * Sessions at rest in DynamoDB: one item per session under the two hashes,
@@ -46,19 +46,34 @@ export declare class LambderDdbSessionStore<SessionData = unknown> implements La
46
46
  private keyOf;
47
47
  /**
48
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.
49
+ * when compressed, a plain `data` attribute otherwise. Either way it goes
50
+ * through its JSON first, so a plain record holds exactly what a
51
+ * compressed one restores to: an `undefined` inside the data is dropped,
52
+ * as JSON drops it, rather than handed to marshallJsonValue, which
53
+ * refuses one and would fail the write (a login answering 500) only when
54
+ * compression is off.
55
+ *
56
+ * Data too large for a record is refused here, before anything is
57
+ * written, with the size and the limit it passed: DynamoDB would refuse
58
+ * the item with a ValidationException that names neither, and data past
59
+ * the restore ceiling would be written only to read back as no session.
60
+ * Create and update measure the same attributes, so data one accepts the
61
+ * other does too.
54
62
  */
55
63
  private dataAttributes;
56
- /** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
64
+ /**
65
+ * The item for a record: the two hashes under the table's key names, the
66
+ * record's other fields under their own names (strings and numbers, an
67
+ * absent dataExpiresAt left out), the data plain or compressed. The
68
+ * attribute names and types are the ones items have always carried, so
69
+ * records written before and after read alike.
70
+ */
57
71
  private toItem;
58
72
  /**
59
73
  * The record for an item. A compressed record decodes back into `data`;
60
- * one whose data cannot be decoded is malformed and reads as no session,
61
- * like a record missing its csrfTokenHash.
74
+ * one whose data cannot be decoded, or declares more of it than the store
75
+ * ever writes, is malformed and reads as no session, like a record
76
+ * missing its csrfTokenHash.
62
77
  *
63
78
  * This is where read failures and malformed records separate. A read
64
79
  * failure is infrastructure and must surface as a 500: signing somebody
@@ -1,5 +1,5 @@
1
- import { createDynamoDocumentClientLoader, isConditionalCheckFailure } from "./LambderDdbSdk.js";
2
- import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
1
+ import { attributeValueBytes, createDynamoClientLoader, isConditionalCheckFailure, marshallJsonValue, MAX_ITEM_BYTES, storedNumber, unmarshallJsonValue, } from "./LambderDdbSdk.js";
2
+ import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
3
3
  import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
4
4
  /** The fields besides data an update may write, as the manager names them. */
5
5
  const UPDATABLE_FIELDS = ["dataExpiresAt", "lastAccessedAt", "expiresAt"];
@@ -7,11 +7,30 @@ const UPDATABLE_FIELDS = ["dataExpiresAt", "lastAccessedAt", "expiresAt"];
7
7
  * Session compression defaults: every record compressed (see
8
8
  * LambderCompressionOption for the option's shape and toggle semantics).
9
9
  * A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
10
- * beside its byte length (`dataBytes`), the scheme LambderDdbCache and
11
- * LambderDdbIdempotencyStore use; below minBytes, or with compression off, the
12
- * record keeps a plain `data` attribute.
10
+ * beside its byte length (`dataBytes`), the scheme every DynamoDB store here
11
+ * keeps text in (see LambderStoredText); below minBytes, or with compression
12
+ * off, the record keeps a plain `data` attribute.
13
13
  */
14
14
  const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
15
+ /**
16
+ * The longest session.data a record holds, in UTF-8 bytes of its JSON: the
17
+ * most the store writes, and so the ceiling it restores a compressed record
18
+ * under. Far past what a session should carry, since it is read on every
19
+ * request, and the same 32 MiB the cache and the idempotency store keep.
20
+ */
21
+ const DATA_CEILING = { user: "LambderDdbSessionStore", maxTextBytes: 32 * 1024 * 1024 };
22
+ /**
23
+ * What session.data may take of a record as stored, sized as DynamoDB sizes
24
+ * an item: its item limit less 8 KB for the record's other attributes (three
25
+ * hashes, the session key and a handful of numbers, a few hundred bytes with
26
+ * any session key of sensible length).
27
+ */
28
+ const MAX_STORED_DATA_BYTES = MAX_ITEM_BYTES - 8 * 1024;
29
+ /** A number attribute's value, or undefined when the attribute is missing or holds no finite number. */
30
+ const numberAttributeOf = (attribute) => {
31
+ const value = attribute?.N === undefined ? NaN : Number(attribute.N);
32
+ return Number.isFinite(value) ? value : undefined;
33
+ };
15
34
  /**
16
35
  * Sessions at rest in DynamoDB: one item per session under the two hashes,
17
36
  * with session.data Brotli-compressed by default. The store maps the
@@ -42,40 +61,66 @@ export class LambderDdbSessionStore {
42
61
  this.partitionKey = options.partitionKey ?? "pk";
43
62
  this.sortKey = options.sortKey ?? "sk";
44
63
  this.compression = resolveCompressionOption(options.compression, SESSION_COMPRESSION_DEFAULTS);
45
- this.ready = createDynamoDocumentClientLoader({
46
- user: "LambderDdbSessionStore",
47
- ...(options.region !== undefined ? { region: options.region } : {}),
48
- ...(options.client ? { client: options.client } : {}),
49
- });
64
+ this.ready = createDynamoClientLoader({ user: "LambderDdbSessionStore", region: options.region, client: options.client });
50
65
  }
51
66
  keyOf(sessionKeyHash, secretHash) {
52
- return { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash };
67
+ return { [this.partitionKey]: { S: sessionKeyHash }, [this.sortKey]: { S: secretHash } };
53
68
  }
54
69
  /**
55
70
  * session.data as the attributes that hold it: `dataBr` and `dataBytes`
56
- * when compressed, a plain `data` otherwise. Either way it goes through
57
- * its JSON first, so a plain record holds exactly what a compressed one
58
- * restores to: an `undefined` inside the data is dropped rather than
59
- * handed to the document client, which refuses one and would fail the
60
- * write (a login answering 500) only when compression is off.
71
+ * when compressed, a plain `data` attribute otherwise. Either way it goes
72
+ * through its JSON first, so a plain record holds exactly what a
73
+ * compressed one restores to: an `undefined` inside the data is dropped,
74
+ * as JSON drops it, rather than handed to marshallJsonValue, which
75
+ * refuses one and would fail the write (a login answering 500) only when
76
+ * compression is off.
77
+ *
78
+ * Data too large for a record is refused here, before anything is
79
+ * written, with the size and the limit it passed: DynamoDB would refuse
80
+ * the item with a ValidationException that names neither, and data past
81
+ * the restore ceiling would be written only to read back as no session.
82
+ * Create and update measure the same attributes, so data one accepts the
83
+ * other does too.
61
84
  */
62
85
  async dataAttributes(data) {
63
86
  const json = JSON.stringify(data);
64
- const raw = Buffer.from(json, "utf8");
65
- if (this.compression && raw.byteLength >= this.compression.minBytes) {
66
- return { dataBr: await compressText(raw, "br", this.compression.quality), dataBytes: raw.byteLength };
87
+ const utf8 = Buffer.from(json, "utf8");
88
+ if (utf8.byteLength > DATA_CEILING.maxTextBytes) {
89
+ throw new Error(`LambderDdbSessionStore: session.data is ${utf8.byteLength} bytes of JSON, over the ${DATA_CEILING.maxTextBytes}-byte limit of a session record. Keep less in the session.`);
67
90
  }
68
- return { data: JSON.parse(json) };
91
+ const text = await storedTextOf(utf8, this.compression);
92
+ const attributes = text.encoding === "br"
93
+ ? { dataBr: { B: text.stored }, dataBytes: { N: String(text.textBytes) } }
94
+ : { data: marshallJsonValue(JSON.parse(json)) };
95
+ const storedBytes = Object.entries(attributes)
96
+ .reduce((total, [name, value]) => total + Buffer.byteLength(name, "utf8") + attributeValueBytes(value), 0);
97
+ if (storedBytes > MAX_STORED_DATA_BYTES) {
98
+ const remedy = text.encoding === "br" ? "Keep less in the session." : "Keep less in the session, or let the store's compression option compress it.";
99
+ throw new Error(`LambderDdbSessionStore: session.data is ${storedBytes} bytes as stored, over the ${MAX_STORED_DATA_BYTES} bytes a session record leaves it inside DynamoDB's ${MAX_ITEM_BYTES}-byte item limit. ${remedy}`);
100
+ }
101
+ return attributes;
69
102
  }
70
- /** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
103
+ /**
104
+ * The item for a record: the two hashes under the table's key names, the
105
+ * record's other fields under their own names (strings and numbers, an
106
+ * absent dataExpiresAt left out), the data plain or compressed. The
107
+ * attribute names and types are the ones items have always carried, so
108
+ * records written before and after read alike.
109
+ */
71
110
  async toItem(record) {
72
- const { sessionKeyHash, secretHash, data, ...rest } = record;
73
- return { ...this.keyOf(sessionKeyHash, secretHash), ...rest, ...await this.dataAttributes(data) };
111
+ const { sessionKeyHash, secretHash, data, ...fields } = record;
112
+ const item = { ...this.keyOf(sessionKeyHash, secretHash), ...await this.dataAttributes(data) };
113
+ for (const [name, value] of Object.entries(fields)) {
114
+ if (value !== undefined)
115
+ item[name] = marshallJsonValue(value);
116
+ }
117
+ return item;
74
118
  }
75
119
  /**
76
120
  * The record for an item. A compressed record decodes back into `data`;
77
- * one whose data cannot be decoded is malformed and reads as no session,
78
- * like a record missing its csrfTokenHash.
121
+ * one whose data cannot be decoded, or declares more of it than the store
122
+ * ever writes, is malformed and reads as no session, like a record
123
+ * missing its csrfTokenHash.
79
124
  *
80
125
  * This is where read failures and malformed records separate. A read
81
126
  * failure is infrastructure and must surface as a 500: signing somebody
@@ -85,44 +130,60 @@ export class LambderDdbSessionStore {
85
130
  * until the TTL retires it. Ending it lets them log in again.
86
131
  */
87
132
  async fromItem(item) {
88
- const { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash, dataBr, dataBytes, data, ...rest } = item;
89
- let restored = data;
90
- if (dataBr) {
91
- try {
92
- restored = JSON.parse(await restoreText(dataBr, "br", { declaredBytes: dataBytes }));
93
- }
94
- catch (err) {
95
- console.warn(`LambderDdbSessionStore: a session record in "${this.tableName}" could not be decoded, so it reads as no session.`, err);
96
- return null;
97
- }
133
+ let data;
134
+ try {
135
+ const compressed = item.dataBr?.B;
136
+ if (compressed)
137
+ data = JSON.parse(await restoreStoredText(compressed, storedNumber(item.dataBytes?.N, 0), DATA_CEILING));
138
+ else if (item.data)
139
+ data = unmarshallJsonValue(item.data);
140
+ }
141
+ catch (err) {
142
+ console.warn(`LambderDdbSessionStore: a session record in "${this.tableName}" could not be decoded, so it reads as no session.`, err);
143
+ return null;
98
144
  }
99
- // The load-bearing fields are checked before the cast, because
100
- // everything past this line trusts them: the manager compares the two
101
- // hashes in constant time (a non-string would throw there rather than
102
- // answer false) and reads expiresAt as a number to decide whether the
103
- // session is over. An item missing them is not this store's record
104
- // (written by an earlier major, by hand, or by another app sharing the
105
- // table), and it reads as no session for the same reason an
106
- // undecodable one does: it will not become valid later. Not logged: a
107
- // visitor whose cookie names such an item sends it on every request
108
- // until the cookie expires, and the item itself goes with its TTL.
109
- // dataVersion is load-bearing the same way: every conditioned write
110
- // names the one read, and a record without it would fail that write
111
- // rather than answer "stale".
112
- if (typeof sessionKeyHash !== "string" || typeof secretHash !== "string"
113
- || typeof rest.csrfTokenHash !== "string" || typeof rest.sessionKey !== "string"
114
- || typeof rest.createdAt !== "number" || typeof rest.expiresAt !== "number"
115
- || typeof rest.ttlInSeconds !== "number" || typeof rest.dataVersion !== "number") {
145
+ const sessionKeyHash = item[this.partitionKey]?.S;
146
+ const secretHash = item[this.sortKey]?.S;
147
+ const csrfTokenHash = item.csrfTokenHash?.S;
148
+ const sessionKey = item.sessionKey?.S;
149
+ const createdAt = numberAttributeOf(item.createdAt);
150
+ const expiresAt = numberAttributeOf(item.expiresAt);
151
+ const lastAccessedAt = numberAttributeOf(item.lastAccessedAt);
152
+ const ttlInSeconds = numberAttributeOf(item.ttlInSeconds);
153
+ const dataVersion = numberAttributeOf(item.dataVersion);
154
+ const dataExpiresAt = numberAttributeOf(item.dataExpiresAt);
155
+ // The load-bearing fields are checked before the record is built,
156
+ // because everything past this line trusts them: the manager compares
157
+ // the two hashes in constant time (a non-string would throw there
158
+ // rather than answer false) and reads expiresAt as a number to decide
159
+ // whether the session is over. An item missing them is not this
160
+ // store's record (written by an earlier major, by hand, or by another
161
+ // app sharing the table), and it reads as no session for the same
162
+ // reason an undecodable one does: it will not become valid later. Not
163
+ // logged: a visitor whose cookie names such an item sends it on every
164
+ // request until the cookie expires, and the item itself goes with its
165
+ // TTL. dataVersion is load-bearing the same way: every conditioned
166
+ // write names the one read, and a record without it would fail that
167
+ // write rather than answer "stale".
168
+ if (sessionKeyHash === undefined || secretHash === undefined || csrfTokenHash === undefined || sessionKey === undefined
169
+ || createdAt === undefined || expiresAt === undefined || ttlInSeconds === undefined || dataVersion === undefined) {
116
170
  return null;
117
171
  }
118
- // The one cast: session.data is whatever the app put there, and JSON
119
- // (or the plain attribute) hands it back as any. Nothing in the store
120
- // can check it, because the shape is the app's, not this layer's.
121
- return { sessionKeyHash, secretHash, data: restored, ...rest };
172
+ // The cast: session.data is whatever the app put there, and JSON (or
173
+ // the plain attribute) hands it back untyped. Nothing in the store can
174
+ // check it, because the shape is the app's, not this layer's. The
175
+ // other unchecked field is lastAccessedAt: written with every record,
176
+ // and read as absent from an item without it rather than refusing it.
177
+ return {
178
+ sessionKeyHash, secretHash, csrfTokenHash, sessionKey, data: data,
179
+ createdAt, expiresAt, ttlInSeconds, dataVersion,
180
+ ...(lastAccessedAt !== undefined ? { lastAccessedAt } : {}),
181
+ ...(dataExpiresAt !== undefined ? { dataExpiresAt } : {}),
182
+ };
122
183
  }
123
184
  async get(sessionKeyHash, secretHash) {
124
185
  const { client, sdk } = await this.ready();
125
- const response = await client.send(new sdk.GetCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ConsistentRead: true }));
186
+ const response = await client.send(new sdk.GetItemCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ConsistentRead: true }));
126
187
  if (!response.Item)
127
188
  return null;
128
189
  return await this.fromItem(response.Item);
@@ -130,7 +191,7 @@ export class LambderDdbSessionStore {
130
191
  async create(record) {
131
192
  const item = await this.toItem(record);
132
193
  const { client, sdk } = await this.ready();
133
- await client.send(new sdk.PutCommand({
194
+ await client.send(new sdk.PutItemCommand({
134
195
  TableName: this.tableName,
135
196
  Item: item,
136
197
  ConditionExpression: "attribute_not_exists(#sk)",
@@ -148,8 +209,9 @@ export class LambderDdbSessionStore {
148
209
  const attributes = await this.dataAttributes(changes.data);
149
210
  for (const attribute of ["data", "dataBr", "dataBytes"]) {
150
211
  names[`#${attribute}`] = attribute;
151
- if (attribute in attributes) {
152
- values[`:${attribute}`] = attributes[attribute];
212
+ const value = attributes[attribute];
213
+ if (value) {
214
+ values[`:${attribute}`] = value;
153
215
  set.push(`#${attribute} = :${attribute}`);
154
216
  }
155
217
  else {
@@ -161,25 +223,25 @@ export class LambderDdbSessionStore {
161
223
  if (changes[field] === undefined)
162
224
  continue;
163
225
  names[`#${field}`] = field;
164
- values[`:${field}`] = changes[field];
226
+ values[`:${field}`] = marshallJsonValue(changes[field]);
165
227
  set.push(`#${field} = :${field}`);
166
228
  }
167
229
  // A write of the data or its deadline moves the version in the same
168
230
  // write, whatever value it writes (see LambderSessionStore.update).
169
231
  if ("data" in changes || changes.dataExpiresAt !== undefined) {
170
232
  names["#dataVersion"] = "dataVersion";
171
- values[":dataVersionStep"] = 1;
233
+ values[":dataVersionStep"] = { N: "1" };
172
234
  add.push("#dataVersion :dataVersionStep");
173
235
  }
174
236
  let conditionExpression = "attribute_exists(#sk)";
175
237
  if (condition) {
176
238
  names["#dataVersion"] = "dataVersion";
177
- values[":readDataVersion"] = condition.dataVersion;
239
+ values[":readDataVersion"] = marshallJsonValue(condition.dataVersion);
178
240
  conditionExpression += " AND #dataVersion = :readDataVersion";
179
241
  }
180
242
  try {
181
243
  const { client, sdk } = await this.ready();
182
- await client.send(new sdk.UpdateCommand({
244
+ await client.send(new sdk.UpdateItemCommand({
183
245
  TableName: this.tableName,
184
246
  Key: this.keyOf(sessionKeyHash, secretHash),
185
247
  UpdateExpression: [
@@ -205,7 +267,7 @@ export class LambderDdbSessionStore {
205
267
  }
206
268
  async delete(sessionKeyHash, secretHash) {
207
269
  const { client, sdk } = await this.ready();
208
- const response = await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ReturnValues: "ALL_OLD" }));
270
+ const response = await client.send(new sdk.DeleteItemCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ReturnValues: "ALL_OLD" }));
209
271
  return response.Attributes ? await this.fromItem(response.Attributes) : null;
210
272
  }
211
273
  async listSecretHashes(sessionKeyHash) {
@@ -214,7 +276,7 @@ export class LambderDdbSessionStore {
214
276
  KeyConditionExpression: "#pk = :pv",
215
277
  ProjectionExpression: "#sk",
216
278
  ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
217
- ExpressionAttributeValues: { ":pv": sessionKeyHash },
279
+ ExpressionAttributeValues: { ":pv": { S: sessionKeyHash } },
218
280
  // Consistent: "log out everywhere" has to find a session created
219
281
  // a moment before it, and an eventually consistent read may not.
220
282
  ConsistentRead: true,
@@ -223,8 +285,11 @@ export class LambderDdbSessionStore {
223
285
  for (;;) {
224
286
  const { client, sdk } = await this.ready();
225
287
  const { Items, LastEvaluatedKey } = await client.send(new sdk.QueryCommand(params));
226
- for (const item of Items ?? [])
227
- hashes.push(item[this.sortKey]);
288
+ for (const item of Items ?? []) {
289
+ const secretHash = item[this.sortKey]?.S;
290
+ if (secretHash !== undefined)
291
+ hashes.push(secretHash);
292
+ }
228
293
  if (LastEvaluatedKey === undefined)
229
294
  return hashes;
230
295
  params.ExclusiveStartKey = LastEvaluatedKey;
@@ -1,4 +1,4 @@
1
- import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
1
+ import type { LambderCache, LambderCacheGetOrSetOptions, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
2
2
  export interface LambderMemoryCacheOptions {
3
3
  /** Default: one year, as LambderDdbCache's. */
4
4
  defaultTtlSeconds?: number;
@@ -41,7 +41,7 @@ export declare class LambderMemoryCache implements LambderCache {
41
41
  delete(key: LambderCacheKey): Promise<boolean>;
42
42
  deletePartition(partition: string): Promise<number>;
43
43
  listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
44
- getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
44
+ getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheGetOrSetOptions): Promise<T>;
45
45
  /** Forgets every entry; a fill in flight meanwhile stores nothing. */
46
46
  reset(): void;
47
47
  /** Stores the value and hands back what was stored, the parse of its JSON. */
@@ -43,8 +43,9 @@ export declare class LambderS3FileSource implements LambderFileSource {
43
43
  private readonly clientConfig;
44
44
  private readonly notFoundErrorNames;
45
45
  private client;
46
- private sdk;
46
+ private clientSdk;
47
47
  constructor({ bucket, prefix, client, clientConfig, notFoundErrorNames }: LambderS3FileSourceOptions);
48
- private loadSdk;
49
48
  read(relativePath: string): Promise<LambderFile | null>;
49
+ /** The SDK, loaded on the first read, and the client: the one supplied, or one made from clientConfig. */
50
+ private s3;
50
51
  }
@@ -1,4 +1,5 @@
1
1
  import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
2
+ import { withInstallHint } from "./LambderSdkInstallHint.js";
2
3
  const DEFAULT_NOT_FOUND_ERROR_NAMES = ["NoSuchKey", "NotFound", "AccessDenied", "KeyTooLongError", "InvalidURI", "InvalidObjectName"];
3
4
  /**
4
5
  * Files from an S3 bucket, or any S3-compatible store such as Cloudflare
@@ -18,7 +19,7 @@ export class LambderS3FileSource {
18
19
  clientConfig;
19
20
  notFoundErrorNames;
20
21
  client;
21
- sdk;
22
+ clientSdk;
22
23
  constructor({ bucket, prefix = "", client, clientConfig, notFoundErrorNames = DEFAULT_NOT_FOUND_ERROR_NAMES }) {
23
24
  if (!bucket.trim())
24
25
  throw new Error("bucket is required");
@@ -28,21 +29,11 @@ export class LambderS3FileSource {
28
29
  this.clientConfig = clientConfig;
29
30
  this.notFoundErrorNames = notFoundErrorNames;
30
31
  }
31
- loadSdk() {
32
- if (!this.sdk) {
33
- this.sdk = import("@aws-sdk/client-s3").catch(() => {
34
- throw new Error("LambderS3FileSource requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3");
35
- });
36
- }
37
- return this.sdk;
38
- }
39
32
  async read(relativePath) {
40
- const { S3Client, GetObjectCommand } = await this.loadSdk();
41
- if (!this.client)
42
- this.client = new S3Client(this.clientConfig ?? {});
33
+ const { sdk, client } = await this.s3();
43
34
  let output;
44
35
  try {
45
- output = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
36
+ output = await client.send(new sdk.GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
46
37
  }
47
38
  catch (err) {
48
39
  const name = err.name;
@@ -54,4 +45,11 @@ export class LambderS3FileSource {
54
45
  return null;
55
46
  return remoteStoreFile(Buffer.from(await output.Body.transformToByteArray()), output.ContentType);
56
47
  }
48
+ /** The SDK, loaded on the first read, and the client: the one supplied, or one made from clientConfig. */
49
+ async s3() {
50
+ this.clientSdk ??= withInstallHint(import("@aws-sdk/client-s3"), "@aws-sdk/client-s3", "LambderS3FileSource", () => { this.clientSdk = undefined; });
51
+ const sdk = await this.clientSdk;
52
+ this.client ??= new sdk.S3Client(this.clientConfig ?? {});
53
+ return { sdk, client: this.client };
54
+ }
57
55
  }
@@ -1,5 +1,7 @@
1
1
  import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
2
2
  import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
3
+ import type { LambderMemoryUploadBucketOptions } from "./LambderMemoryUploadBucket.js";
4
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
3
5
  export type LambderS3UploadBucketOptions = {
4
6
  bucket: string;
5
7
  /** A ready client, e.g. one shared with the rest of the app. */
@@ -50,6 +52,16 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
50
52
  private presignedPostSdk;
51
53
  private requestPresignerSdk;
52
54
  constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds, uploadMethod }: LambderS3UploadBucketOptions);
55
+ /**
56
+ * Puts a memory twin under this bucket in place, for `lambder/testing`:
57
+ * every LambderUploadBucket member answers from the twin from then on.
58
+ * The twin signs with this bucket's own lifetimes and upload method, so a
59
+ * client takes the path against it that it takes against this bucket.
60
+ * Keyed by a symbol no entry point exports; see registerSwappableInstance.
61
+ */
62
+ [LAMBDER_BACKEND_SWAP](twins: {
63
+ uploadBucket(options: LambderMemoryUploadBucketOptions): LambderUploadBucket;
64
+ }): void;
53
65
  issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
54
66
  objectKey: string;
55
67
  fileFacts: LambderUploadFileFacts;
@@ -3,8 +3,15 @@ import { contentDispositionHeader } from "../shared/util/LambderContentDispositi
3
3
  import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
4
4
  import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
5
5
  import { withInstallHint } from "./LambderSdkInstallHint.js";
6
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
7
+ import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
6
8
  /** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
7
9
  const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
10
+ /** Every LambderUploadBucket member, which the swap door hands to a memory twin. */
11
+ const UPLOAD_BUCKET_MEMBERS = {
12
+ issueUploadTicket: true, verifyUploadedObject: true, issueDownloadUrl: true,
13
+ readObject: true, writeObject: true, copyObject: true, deleteObject: true,
14
+ };
8
15
  /**
9
16
  * An S3 bucket browsers upload to directly (see LambderUploadBucket).
10
17
  *
@@ -46,6 +53,22 @@ export class LambderS3UploadBucket {
46
53
  this.ticketLifetimeSeconds = ticketLifetimeSeconds;
47
54
  this.downloadLifetimeSeconds = downloadLifetimeSeconds;
48
55
  this.uploadMethod = uploadMethod;
56
+ registerSwappableInstance(this);
57
+ }
58
+ /**
59
+ * Puts a memory twin under this bucket in place, for `lambder/testing`:
60
+ * every LambderUploadBucket member answers from the twin from then on.
61
+ * The twin signs with this bucket's own lifetimes and upload method, so a
62
+ * client takes the path against it that it takes against this bucket.
63
+ * Keyed by a symbol no entry point exports; see registerSwappableInstance.
64
+ */
65
+ [LAMBDER_BACKEND_SWAP](twins) {
66
+ const twin = twins.uploadBucket({
67
+ ticketLifetimeSeconds: this.ticketLifetimeSeconds,
68
+ downloadLifetimeSeconds: this.downloadLifetimeSeconds,
69
+ uploadMethod: this.uploadMethod,
70
+ });
71
+ delegateToTwin(this, twin, UPLOAD_BUCKET_MEMBERS);
49
72
  }
50
73
  async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
51
74
  assertPinnedObjectKey(objectKey);
@@ -1,4 +1,4 @@
1
- import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
1
+ import type { LambderCache, LambderCacheGetOrSetOptions, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
2
2
  import { type LambderCacheAddress } from "./LambderCacheKeys.js";
3
3
  /** One entry as a storage keeps it: the value's JSON text and the second it stops being live. */
4
4
  export interface LambderCacheStoredEntry {
@@ -85,7 +85,7 @@ export declare class LambderStorageBackedCache implements LambderCache {
85
85
  delete(key: LambderCacheKey): Promise<boolean>;
86
86
  deletePartition(partition: string): Promise<number>;
87
87
  listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
88
- getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
88
+ getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheGetOrSetOptions): Promise<T>;
89
89
  /** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
90
90
  private nowSeconds;
91
91
  /** The entry at the address while it is live; the storage may still hold one past its expiry. */
@@ -0,0 +1,26 @@
1
+ import type { LambderCompressionSettings } from "../shared/wire/LambderCompressionOption.js";
2
+ /** Text as a store keeps it: `stored` is the Brotli bytes ("br") or the UTF-8 bytes themselves ("identity"). */
3
+ export interface LambderStoredText {
4
+ encoding: "br" | "identity";
5
+ stored: Buffer;
6
+ /** The text's UTF-8 byte length: the declared length a compressed text is restored under. */
7
+ textBytes: number;
8
+ }
9
+ /** What a store restores stored text under: the most it ever writes, and its own name for the refusal. */
10
+ export interface LambderStoredTextCeiling {
11
+ user: string;
12
+ maxTextBytes: number;
13
+ }
14
+ /**
15
+ * The text's UTF-8 bytes as the store keeps them, under its compression
16
+ * settings (null: compression off). The plain form is the bytes handed in,
17
+ * not a copy.
18
+ */
19
+ export declare const storedTextOf: (utf8: Buffer, compression: LambderCompressionSettings | null) => Promise<LambderStoredText>;
20
+ /**
21
+ * The text a compressed record holds, restored under the length it declares,
22
+ * or a throw: a plain Error naming the store when the declared length is over
23
+ * its ceiling, and the codec's LambderCompressionError when the length is
24
+ * missing or the bytes do not restore to exactly that length.
25
+ */
26
+ export declare const restoreStoredText: (compressed: Uint8Array, declaredBytes: number, ceiling: LambderStoredTextCeiling) => Promise<string>;
@@ -0,0 +1,25 @@
1
+ import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
2
+ /**
3
+ * The text's UTF-8 bytes as the store keeps them, under its compression
4
+ * settings (null: compression off). The plain form is the bytes handed in,
5
+ * not a copy.
6
+ */
7
+ export const storedTextOf = async (utf8, compression) => {
8
+ const textBytes = utf8.byteLength;
9
+ if (compression && textBytes > 0 && textBytes >= compression.minBytes) {
10
+ return { encoding: "br", stored: await compressText(utf8, "br", compression.quality), textBytes };
11
+ }
12
+ return { encoding: "identity", stored: utf8, textBytes };
13
+ };
14
+ /**
15
+ * The text a compressed record holds, restored under the length it declares,
16
+ * or a throw: a plain Error naming the store when the declared length is over
17
+ * its ceiling, and the codec's LambderCompressionError when the length is
18
+ * missing or the bytes do not restore to exactly that length.
19
+ */
20
+ export const restoreStoredText = async (compressed, declaredBytes, ceiling) => {
21
+ if (declaredBytes > ceiling.maxTextBytes) {
22
+ throw new Error(`${ceiling.user}: a stored record declares ${declaredBytes} bytes of text, over the ${ceiling.maxTextBytes}-byte limit it restores, so the record is unusable.`);
23
+ }
24
+ return await restoreText(compressed, "br", { declaredBytes });
25
+ };
@@ -38,8 +38,15 @@ export type LambderConformanceRunner = {
38
38
  export type LambderConformanceSetup = {
39
39
  now: () => number;
40
40
  };
41
- /** Where every case's clock starts: a fixed moment, so the records a case writes are the same on every run. */
42
- export declare const CONFORMANCE_START_MILLIS = 1700000000000;
41
+ /**
42
+ * Where every case's clock starts: 2100-01-01T00:00:00Z. Fixed, so the
43
+ * records a case writes are the same on every run, and far in the future, so
44
+ * a storage that also hands its records to a native TTL (a database that
45
+ * deletes expired rows itself, Redis's EXPIREAT) holds every one a case
46
+ * writes, and a record counts as expired only when the store says so by the
47
+ * case's clock. In seconds it still fits 32 bits unsigned.
48
+ */
49
+ export declare const CONFORMANCE_START_MILLIS = 4102444800000;
43
50
  /** The clock of one case: `now` for the store, `set` for the case. */
44
51
  export declare const conformanceClock: () => LambderConformanceSetup & {
45
52
  set(millis: number): void;
@@ -9,8 +9,15 @@
9
9
  * under vitest, jest, or any runner with a jest-style `expect`, and the
10
10
  * package depends on none of them.
11
11
  */
12
- /** Where every case's clock starts: a fixed moment, so the records a case writes are the same on every run. */
13
- export const CONFORMANCE_START_MILLIS = 1_700_000_000_000;
12
+ /**
13
+ * Where every case's clock starts: 2100-01-01T00:00:00Z. Fixed, so the
14
+ * records a case writes are the same on every run, and far in the future, so
15
+ * a storage that also hands its records to a native TTL (a database that
16
+ * deletes expired rows itself, Redis's EXPIREAT) holds every one a case
17
+ * writes, and a record counts as expired only when the store says so by the
18
+ * case's clock. In seconds it still fits 32 bits unsigned.
19
+ */
20
+ export const CONFORMANCE_START_MILLIS = 4_102_444_800_000;
14
21
  /** The clock of one case: `now` for the store, `set` for the case. */
15
22
  export const conformanceClock = () => {
16
23
  let millis = CONFORMANCE_START_MILLIS;