lambder 7.2.5 → 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 (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -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 +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -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 +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -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 +161 -69
  41. package/dist/core/Lambder.js +370 -226
  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 +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  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 +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  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 +3 -1
  93. package/dist/mock.js +5 -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 +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  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/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,70 +1,132 @@
1
1
  import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
2
2
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
3
- import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
3
+ import { joinKeyFields } from "../shared/util/joinKeyFields.js";
4
+ import { boundKeyField } from "../shared/util/boundKeyField.js";
4
5
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
6
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
7
+ import { canonicalJson } from "../shared/util/canonicalJson.js";
8
+ import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
9
+ import { crashAnswer } from "./LambderApiEnvelope.js";
10
+ import { LambderApiOutputValidationError } from "./LambderApiOutputValidationError.js";
5
11
  /**
6
- * A crashed original must not block retries forever, so a pending claim
7
- * expires on its own. The default is five minutes, which covers the great
8
- * majority of handlers.
9
- *
10
- * It has to outlive the handler, though, and that is the app's business to
11
- * know: a Lambda may run for fifteen minutes, and a claim that expires while
12
- * its own handler is still working hands the next retry a free scope, so the
13
- * operation runs a second time, which is the one thing idempotency exists to
14
- * prevent. An app whose handlers can run long should raise it to just past
15
- * its own timeout through `idempotency: { pendingTtlSeconds }`, or per API.
12
+ * A pending claim expires on its own so a crashed original cannot block
13
+ * retries forever. Five minutes covers most handlers, but the claim must
14
+ * outlive its handler: a Lambda may run for fifteen minutes, and a claim that
15
+ * expires mid-run hands the next retry a free scope, so the operation runs
16
+ * twice. Apps with long handlers raise it past their timeout through
17
+ * `idempotency: { pendingTtlSeconds }`, or per API.
16
18
  */
17
19
  const DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
18
20
  /**
19
21
  * Keys must be unguessable: without a session, the replay scope is the key
20
22
  * itself, so a guessable key would let one client read another's stored
21
- * response. LambderCaller.createIdempotencyKey() returns 36 chars.
23
+ * response. createIdempotencyKey() returns 36 chars.
22
24
  */
23
25
  const IDEMPOTENCY_MIN_KEY_LENGTH = 16;
24
26
  const IDEMPOTENCY_MAX_KEY_LENGTH = 200;
25
- /** A stored record as an answer: the three fields, nothing else. */
27
+ /**
28
+ * An answer's headers with the map and every value list copied, on the way
29
+ * into a store and on the way out of one. The pipeline applies the CALL's
30
+ * headers into the answer's own map as the call ends, so a custom store that
31
+ * kept the object it was given, or handed back the one it keeps (the
32
+ * interface asks for copies both ways, but nothing enforces it), would store
33
+ * one call's Set-Cookie and replay it to everyone.
34
+ */
35
+ const copyAnswerHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, values]) => [name, [...values]]));
36
+ /**
37
+ * A stored record as an answer: the three fields, nothing else, and the
38
+ * headers copied (see copyAnswerHeaders). The pipeline applies the replaying
39
+ * call's own headers into the answer's map, so a store that handed back the
40
+ * object it keeps would have this call's Set-Cookie written into its record
41
+ * and replayed to the next caller.
42
+ */
26
43
  const answerFromRecord = (record) => ({
27
44
  statusCode: record.statusCode,
28
- headers: record.headers,
45
+ headers: copyAnswerHeaders(record.headers),
29
46
  body: record.body,
30
47
  });
31
48
  /**
32
- * A replay window a store can act on, checked where the rate limiter checks
33
- * its own windows. NaN was the one that mattered: it survives every
49
+ * A replay window a store can act on. NaN matters most: it survives every
34
50
  * comparison an expiry test makes, so an in-memory record with a NaN expiry
35
- * outlives every sweep, while DynamoDB rejects the same number outright. Zero
36
- * is rejected too, because a record that expires the instant it is written
37
- * silently turns replay off for the API that asked for it.
51
+ * outlives every sweep, while DynamoDB rejects the number outright. Zero is
52
+ * refused too, since a record that expires as it is written silently turns
53
+ * replay off for the API that asked for it.
38
54
  */
39
55
  const assertReplayTtl = (subject, ttlSeconds) => {
40
56
  if (ttlSeconds === undefined)
41
57
  return;
42
- // The shared positive-integer check, with the subject naming the option;
43
- // the wording "a replay window is a positive whole number of seconds"
44
- // lived here alone and said the same thing in different words.
45
58
  assertPositiveInteger(ttlSeconds, `${subject} (a replay window in whole seconds)`);
46
59
  };
47
- /** A per-API replay window, falling back to the configured default. Written once: the two windows resolve the same way. */
60
+ /** A per-API replay window, falling back to the configured default. */
48
61
  const resolveWindowSeconds = (config, field, fallback) => (typeof config === "object" ? config[field] : undefined) ?? fallback;
49
62
  /**
50
- * Headers the store may keep: the map and every value list copied, so what
51
- * the engine hands complete() cannot be rewritten afterwards.
52
- *
53
- * The pipeline applies the CALL's headers onto the answer on the way out,
54
- * into the very object handed here. A store that keeps the reference it was
55
- * given (the interface asks for a copy, and the shipped stores make one, but
56
- * a custom store is under no compiler's supervision) would have this call's
57
- * Set-Cookie become part of the stored record and replay to everyone.
63
+ * Whether a handler's answer is stored for replays. A 5xx is not: the
64
+ * operation may not have happened, and a retry should try it. Nor is a
65
+ * Set-Cookie (replaying another request's session cookies would be wrong),
66
+ * or a binary body.
58
67
  */
59
- const copyAnswerHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, values]) => [name, [...values]]));
68
+ const isReplayableAnswer = (answer) => answer.statusCode < 500
69
+ && !answer.isBodyBase64
70
+ && getAnswerHeader(answer.headers, "Set-Cookie") === undefined;
71
+ /**
72
+ * The refusal for a key that already belongs to a different request. Without
73
+ * it the second request would get the first one's stored answer: a corrected
74
+ * order (qty 2 after a refused qty 10) would get the stale refusal for a day,
75
+ * and an edited retry after a timeout (qty 4 after a processed qty 3) would
76
+ * get the first order's success though only the first was placed.
77
+ */
78
+ const keyReusedRefusal = (apiName) => new LambderApiRefusal(`Idempotency key reused for a different request to "${apiName}".`, {
79
+ statusCode: 409,
80
+ errorMessage: {
81
+ type: "error",
82
+ code: LAMBDER_REFUSAL_CODES.idempotencyKeyReused,
83
+ content: "This request key was already used for a different request. Start the operation again.",
84
+ },
85
+ });
86
+ /**
87
+ * A request's fingerprint: what makes two requests under one key the same
88
+ * request. The payload as it was posted, as canonical JSON so key order does
89
+ * not matter, digested so a record keeps 64 characters rather than the
90
+ * payload. Guard inputs stay out: a captcha or proof token is single use, so
91
+ * a genuine retry carries a new one, and a fingerprint over it would refuse
92
+ * that retry as another request. A runtime without
93
+ * WebCrypto (the mock on a page served over plain http, a phone on the LAN)
94
+ * keeps the canonical JSON itself: a fingerprint is only compared with ones
95
+ * the same runtime took, so its form does not matter, only that one payload
96
+ * always gives the same one. Either form is non-empty (canonical JSON is at
97
+ * least `null`), which is what lets a store report the empty string for a
98
+ * record it cannot tie to any request.
99
+ */
100
+ const requestFingerprintOf = async (payload) => {
101
+ let canonical;
102
+ try {
103
+ canonical = canonicalJson(payload ?? null);
104
+ }
105
+ catch {
106
+ // Sorting the keys recurses once per nesting level, so a payload
107
+ // nested deep enough to exhaust the stack is refused as the caller's
108
+ // error rather than answered as a crash: nothing an app posts nests
109
+ // that deep, and the schema would refuse it a step later anyway.
110
+ const content = "Invalid request payload: nested too deep to fingerprint.";
111
+ throw new LambderApiRefusal(content, {
112
+ statusCode: 400,
113
+ errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content },
114
+ });
115
+ }
116
+ try {
117
+ return await sha256HexOf(canonical);
118
+ }
119
+ catch {
120
+ return canonical;
121
+ }
122
+ };
60
123
  /**
61
124
  * A store failure the engine decided to ignore, said out loud. Failing open
62
- * is the right default (a store outage should not take the app down with it),
63
- * but it is also indistinguishable from working: the failure class includes
64
- * permanent ones (a missing table, a missing IAM action, an SDK that would
65
- * not install), and an app can run for months executing every retry twice
66
- * with nothing in its logs. The scope key never appears, since it carries the
67
- * caller's identity and their posted key.
125
+ * is the right default, but silently it looks exactly like working: permanent
126
+ * failures (a missing table, a missing IAM action, an SDK that would not
127
+ * install) could have an app execute every retry twice for months with
128
+ * nothing in its logs. The scope key is left out, since it carries the
129
+ * caller's identity and posted key.
68
130
  */
69
131
  const reportFailOpen = (apiName, attempted, err) => {
70
132
  console.error(`Lambder idempotency: "${apiName}" could not ${attempted}; the request is being executed as if it carried no idempotency key. ` +
@@ -73,24 +135,20 @@ const reportFailOpen = (apiName, attempted, err) => {
73
135
  /**
74
136
  * Runtime side of the idempotency subsystem: claims a per-operation scope
75
137
  * around handler execution, replays stored answers, and settles claims.
76
- * Composed into LambderApiPolicyEngine. Works on plain answers, so it runs
138
+ * Held by LambderApiPipeline. Works on plain answers, so it runs
77
139
  * unchanged under the server and the mock runtime.
78
140
  */
79
141
  export class LambderApiIdempotencyEngine {
142
+ /** Stamped on the crash answer the engine records for an answer that broke its output schema. */
143
+ apiVersion;
80
144
  store = null;
81
145
  defaultTtlSeconds = 24 * 3600;
82
146
  defaultPendingTtlSeconds = DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
83
147
  failOpen = true;
84
148
  callerIdentity = undefined;
85
- /**
86
- * The scope this call resolved to, keyed by its context, which is the one
87
- * object per call the engine is handed. A keyed request asks for it
88
- * twice, at the replay lookup and at the claim, and callerIdentity is app
89
- * code that may verify a token or read a store: running it twice per
90
- * request is a cost the app never asked for, and one it cannot see.
91
- * Entries go when the call's context does.
92
- */
93
- scopeByCall = new WeakMap();
149
+ constructor(apiVersion) {
150
+ this.apiVersion = apiVersion;
151
+ }
94
152
  configure(config) {
95
153
  if (this.store)
96
154
  throw new Error("Lambder: idempotency was already configured.");
@@ -102,6 +160,17 @@ export class LambderApiIdempotencyEngine {
102
160
  this.failOpen = config.failOpen ?? true;
103
161
  this.callerIdentity = config.callerIdentity;
104
162
  }
163
+ /**
164
+ * Puts the engine over another store, for `lambder/testing`; the replay
165
+ * TTLs, failOpen and callerIdentity stay as configured. False when
166
+ * idempotency was never configured.
167
+ */
168
+ [LAMBDER_BACKEND_SWAP](store) {
169
+ if (!this.store)
170
+ return false;
171
+ this.store = store;
172
+ return true;
173
+ }
105
174
  /** Startup validation of one API registration's idempotency option. */
106
175
  assertRegistration(apiName, config) {
107
176
  if (typeof config !== "object")
@@ -130,61 +199,69 @@ export class LambderApiIdempotencyEngine {
130
199
  return rawKey;
131
200
  }
132
201
  /**
133
- * The record's scope. Session APIs scope per session, so even a leaked
134
- * key cannot cross users. Public APIs scope by the key alone unless the
135
- * app supplies callerIdentity, because the key is required to be long
136
- * (and documented to be random), and identity proxies like the client IP
137
- * are deliberately NOT part of the scope: the retry idempotency exists
138
- * for (a timeout followed by a network change) frequently arrives from a
139
- * different IP. An app whose public APIs are authorized by a guard should
140
- * give callerIdentity, since the replay is served before guards run.
202
+ * The record's scope. Session APIs scope per user (the sessionKey, which
203
+ * every session of one user shares), so even a leaked key cannot cross
204
+ * users. Public APIs scope by the key alone (it must be
205
+ * long and is documented to be random) unless the app supplies
206
+ * callerIdentity. The client IP is deliberately NOT part of the scope:
207
+ * the retry idempotency exists for (a timeout, then a network change)
208
+ * often arrives from a different IP. Apps whose public APIs are
209
+ * authorized by a guard should give callerIdentity, since the replay is
210
+ * served before guards run.
141
211
  *
142
- * Fields are escaped and joined through joinKeyFields, so no two distinct
143
- * scopes can produce one string.
212
+ * joinKeyFields escapes the fields, so no two distinct scopes collide.
213
+ * The identity field is the caller's (a device token in the docs' own
214
+ * example), so it is bounded first (boundKeyField): an over-long one
215
+ * would push the scope past a store's key limit, and the store's refusal
216
+ * is a throw that failOpen turns into no idempotency for that caller.
144
217
  */
145
218
  async scopeOf(apiName, ctx, request, key) {
146
- const cached = this.scopeByCall.get(ctx);
147
- if (cached)
148
- return await cached;
149
- const computed = this.computeScope(apiName, ctx, request, key);
150
- this.scopeByCall.set(ctx, computed);
151
- return await computed;
152
- }
153
- async computeScope(apiName, ctx, request, key) {
154
219
  const sessionKey = ctx.session?.sessionKey;
155
220
  if (sessionKey)
156
- return joinKeyFields(`s:${sessionKey}`, apiName, key);
221
+ return joinKeyFields(await boundKeyField("s", sessionKey), apiName, key);
157
222
  // No session to scope by. The app may still say who this is, through
158
223
  // callerIdentity; without one the key alone is the scope, which is
159
224
  // what makes it a bearer token for its own answer.
160
225
  const identity = this.callerIdentity ? await this.callerIdentity(ctx, request) : null;
161
226
  return identity
162
- ? joinKeyFields(`i:${identity}`, apiName, key)
227
+ ? joinKeyFields(await boundKeyField("i", identity), apiName, key)
163
228
  : joinKeyFields("k", apiName, key);
164
229
  }
165
230
  /**
166
- * Replay fast path, run before the remaining rate limits and before
167
- * guards: a completed record answers with its stored answer, so a
168
- * legitimate retry neither burns rate-limit quota nor re-runs guards (the
169
- * original already passed them, and no handler executes). The `per: "ip"`
170
- * limits are the exception and are checked ahead of this, since the store
171
- * read a replay costs is one of the things they exist to bound. Misses
172
- * fall through to the normal pipeline; store errors follow the failOpen
173
- * setting.
231
+ * The call's key worked out, once per call: its scope and its request's
232
+ * fingerprint, for findReplay and withIdempotency. Null for a call that
233
+ * sends no key, or when idempotency is not configured; a malformed key
234
+ * refuses with a 400. Taken at the replay lookup, before input validation
235
+ * replaces the payload with its parsed form, so the fingerprint is of the
236
+ * request as it was posted, and callerIdentity (app code that may verify
237
+ * a token or read a store) runs once.
174
238
  */
175
- async findReplay(apiName, request, ctx, trace) {
176
- const store = this.store;
177
- if (!store)
239
+ async resolveKeyedCall(apiName, request, ctx) {
240
+ if (!this.store)
178
241
  return null;
179
242
  const key = this.readKey(request);
180
243
  if (key === null)
181
244
  return null;
245
+ return {
246
+ scopeKey: await this.scopeOf(apiName, ctx, request, key),
247
+ fingerprint: await requestFingerprintOf(request.payload),
248
+ };
249
+ }
250
+ /**
251
+ * Replay fast path, run before guards and all rate limits except
252
+ * `per: "ip"`: a completed record returns its stored answer, so a
253
+ * legitimate retry neither burns quota nor re-runs guards (the original
254
+ * passed them, and no handler executes). The IP limits run first because
255
+ * the store read a replay costs is one of the things they bound. Misses
256
+ * fall through to the normal pipeline; store errors follow failOpen.
257
+ */
258
+ async findReplay(apiName, call, trace) {
259
+ const store = this.store;
260
+ if (!store)
261
+ return null;
262
+ let done;
182
263
  try {
183
- const done = await store.peek(await this.scopeOf(apiName, ctx, request, key));
184
- if (!done)
185
- return null;
186
- trace.replayed = true;
187
- return answerFromRecord(done);
264
+ done = await store.peek(call.scopeKey);
188
265
  }
189
266
  catch (err) {
190
267
  if (!this.failOpen)
@@ -192,36 +269,36 @@ export class LambderApiIdempotencyEngine {
192
269
  reportFailOpen(apiName, "look its replay record up", err);
193
270
  return null;
194
271
  }
272
+ if (!done)
273
+ return null;
274
+ if (done.fingerprint !== call.fingerprint)
275
+ throw keyReusedRefusal(apiName);
276
+ trace.replayed = true;
277
+ return answerFromRecord(done);
195
278
  }
196
279
  /**
197
- * Idempotency wrapper around validation-passed handler execution. Without
198
- * a client idempotencyKey the handler just runs; with one, the scope
199
- * (identity + api + key) is claimed atomically: duplicates of an
200
- * in-flight original refuse with 409, replays of a completed one return
201
- * the stored answer verbatim, and a crashed original releases its claim
202
- * so a retry actually retries.
280
+ * Idempotency wrapper around validation-passed handler execution, for a
281
+ * call resolveKeyedCall found a key on (a keyless call runs its handler
282
+ * without one): the scope (identity + api + key) is claimed atomically: duplicates of an in-flight original
283
+ * refuse with 409, replays of a completed one return the stored answer
284
+ * verbatim, and a crashed original releases its claim so a retry retries.
203
285
  *
204
- * `exec` must hand back the handler's own answer, the headers the handler
205
- * itself wrote included: the pipeline applies those before returning here,
206
- * so a Set-Cookie the handler set is visible to the caching rule below.
286
+ * `exec` returns the handler's own answer with the headers the handler
287
+ * wrote, so a Set-Cookie it set is visible to the caching rule below.
207
288
  * Headers written EARLIER in the call (a session read evicting a stale
208
- * cookie) are deliberately not on it: they are the call's, they reach the
209
- * client either way, and charging them to this answer would make an
210
- * idempotent operation silently stop being idempotent.
289
+ * cookie) are deliberately left off: they reach the client either way,
290
+ * and charging them to this answer would make it uncacheable, silently
291
+ * ending the operation's idempotency.
211
292
  */
212
- async withIdempotency(apiName, request, ctx, config, trace, exec) {
293
+ async withIdempotency(apiName, { scopeKey, fingerprint }, config, trace, exec) {
213
294
  const store = this.store;
214
295
  if (!store)
215
296
  return await exec();
216
- const rawKey = this.readKey(request);
217
- if (rawKey === null)
218
- return await exec();
219
297
  const ttlSeconds = resolveWindowSeconds(config, "ttlSeconds", this.defaultTtlSeconds);
220
298
  const pendingTtlSeconds = resolveWindowSeconds(config, "pendingTtlSeconds", this.defaultPendingTtlSeconds);
221
- const scopeKey = await this.scopeOf(apiName, ctx, request, rawKey);
222
299
  let begun;
223
300
  try {
224
- begun = await store.begin(scopeKey, { pendingTtlSeconds });
301
+ begun = await store.begin(scopeKey, { pendingTtlSeconds, fingerprint });
225
302
  }
226
303
  catch (err) {
227
304
  if (!this.failOpen)
@@ -229,6 +306,8 @@ export class LambderApiIdempotencyEngine {
229
306
  reportFailOpen(apiName, "claim its scope", err);
230
307
  return await exec();
231
308
  }
309
+ if (begun.state !== "new" && begun.fingerprint !== fingerprint)
310
+ throw keyReusedRefusal(apiName);
232
311
  if (begun.state === "pending") {
233
312
  throw new LambderApiRefusal(`Duplicate request for "${apiName}": the original is still processing.`, {
234
313
  statusCode: 409,
@@ -236,43 +315,34 @@ export class LambderApiIdempotencyEngine {
236
315
  });
237
316
  }
238
317
  // The other replay path: the original settled between this request's
239
- // peek and its claim, which is the race the "done" answer exists for.
240
- // No handler runs here either, so it is a replay like any other.
318
+ // peek and its claim. No handler runs, so it is a replay like any
319
+ // other.
241
320
  if (begun.state === "done") {
242
321
  trace.replayed = true;
243
322
  return answerFromRecord(begun);
244
323
  }
245
324
  const ownerToken = begun.ownerToken;
246
325
  // Store the answer for replays when it qualifies, release the claim
247
- // otherwise. A Set-Cookie makes an answer uncacheable (replaying
248
- // another request's cookies, e.g. session tokens, would be wrong), and
249
- // so does a binary body.
326
+ // otherwise.
250
327
  //
251
- // Settling happens after the handler has already run, so a store
252
- // failure here can no longer prevent anything: the work is done and
253
- // the answer is owed to the caller. failOpen governs the decision
254
- // BEFORE execution, at begin(), where refusing still means refusing to
255
- // act. Applying it here would turn a completed operation into a 500
256
- // and hand the retry a released claim, which is exactly the double
257
- // execution idempotency exists to prevent, so a settle failure is
258
- // reported and swallowed.
259
- const settleClaim = async (answer) => {
260
- const cacheable = answer.statusCode < 500
261
- && !answer.isBodyBase64
262
- && getAnswerHeader(answer.headers, "Set-Cookie") === undefined;
328
+ // A settle failure is reported and swallowed, not subject to
329
+ // failOpen: the work is done and the answer is owed to the caller.
330
+ // failOpen governs begin(), where refusing still means not acting.
331
+ // Here it would turn a completed operation into a 500 and hand the
332
+ // retry a released claim, which is the double execution idempotency
333
+ // exists to prevent.
334
+ const settleClaim = async (answer, replayable) => {
263
335
  try {
264
- if (cacheable) {
265
- // The store owns the size decision: bodies may be compressed
266
- // there, and only ones exceeding its budget even compressed
267
- // come back as "too-large".
336
+ if (replayable) {
337
+ // The store owns the size decision: it may compress bodies,
338
+ // and answers "too-large" only when even that exceeds its
339
+ // budget.
268
340
  const completion = await store.complete(scopeKey, ownerToken, {
269
341
  statusCode: answer.statusCode,
270
- // Copied, not handed over: the pipeline is about to
271
- // apply this call's own headers into answer.headers,
272
- // and a store that kept the reference would have them
273
- // in the record.
342
+ // Copied: see copyAnswerHeaders.
274
343
  headers: copyAnswerHeaders(answer.headers),
275
344
  body: answer.body,
345
+ fingerprint,
276
346
  ttlSeconds,
277
347
  });
278
348
  if (completion !== "too-large")
@@ -283,41 +353,46 @@ export class LambderApiIdempotencyEngine {
283
353
  await store.abandon(scopeKey, ownerToken);
284
354
  }
285
355
  catch (storeErr) {
286
- // Two cases, one release. A complete() that never landed must
287
- // not leave the pending claim dangling: it would 409 the
288
- // caller's genuine retries until the pending TTL runs out. A
289
- // complete() whose write DID land and then timed out is
290
- // released too, so the record stops replaying and a retry
291
- // re-executes rather than replaying. That is the safe
292
- // direction of the two: the work has already been done once
293
- // and its answer is with the caller, so a retry that runs
294
- // again costs an execution, while a claim nobody can clear is
295
- // a caller who cannot get through at all.
356
+ // The failed call may have landed anyway (a complete() whose
357
+ // answer was lost), and abandon() releases only a claim still
358
+ // pending, so one more release settles every case. A
359
+ // complete() that never landed frees the scope, where the
360
+ // pending claim would 409 genuine retries until
361
+ // pendingTtlSeconds ran out; one that landed keeps its record,
362
+ // and the retry replays it rather than running the operation
363
+ // a second time.
296
364
  try {
297
365
  await store.abandon(scopeKey, ownerToken);
298
366
  }
299
367
  catch { /* claim expires on its own */ }
300
- console.warn(`Lambder idempotency: "${apiName}" ran and answered, but its record could not be stored. ` +
301
- "The caller keeps this answer; a retry under the same key will execute again.", storeErr);
368
+ console.warn(`Lambder idempotency: "${apiName}" ran and answered, but storing its record failed. ` +
369
+ "The caller keeps this answer; a retry under the same key replays it if the write landed after all, and executes again if it did not.", storeErr);
302
370
  }
303
371
  };
304
372
  try {
305
373
  const answer = await exec();
306
- await settleClaim(answer);
374
+ await settleClaim(answer, isReplayableAnswer(answer));
307
375
  return answer;
308
376
  }
309
377
  catch (err) {
310
- // A real crash, or a thrown refusal (LambderApiRefusal, refuse()),
311
- // releases the claim so a retry actually retries. This is the
312
- // deliberate rule: ANSWERS are stored and replayed, refusals
313
- // delivered as returned envelopes included; EXCEPTIONS are not, so
314
- // a thrown refusal re-executes on retry and the handler decides
315
- // afresh. Pick the idiom accordingly. (On the server, a response
316
- // delivered by throwing, res.die.api(), is an answer: the adapter
317
- // catches it before it reaches here.)
318
- // Whatever happens to the claim, the handler's own failure is the
319
- // one worth reporting: replacing it with a cleanup error would
320
- // hide the reason the call failed.
378
+ // The handler ran to its answer, and the answer broke its output
379
+ // schema: whatever the operation wrote or charged is done, so a
380
+ // released claim would run it again on every retry. The
381
+ // framework's crash answer is recorded as the key's answer
382
+ // instead, and a retry is told the same thing.
383
+ if (err instanceof LambderApiOutputValidationError) {
384
+ await settleClaim(crashAnswer(this.apiVersion), true);
385
+ throw err;
386
+ }
387
+ // A crash or a thrown refusal (LambderApiRefusal, refuse())
388
+ // releases the claim so a retry retries. The rule is deliberate:
389
+ // ANSWERS are stored and replayed, returned refusal envelopes
390
+ // included; EXCEPTIONS are not, so a thrown refusal re-executes
391
+ // on retry and the handler decides afresh. (On the server,
392
+ // res.die.api() is an answer: the adapter catches it before it
393
+ // reaches here.) The handler's own error is what gets rethrown,
394
+ // since a cleanup error in its place would hide why the call
395
+ // failed.
321
396
  try {
322
397
  await store.abandon(scopeKey, ownerToken);
323
398
  }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A handler answered a payload its API's output schema could not turn into
4
+ * the declared shape, so the payload was not sent and the call is answered
5
+ * as a crash. Either the schema rejected the payload, or the parse threw: an
6
+ * async refinement or transform (the output is parsed synchronously, so an
7
+ * output schema cannot be async), or a transform that threw. The output
8
+ * side's counterpart of LambderApiValidationRefusal, with the API it happened
9
+ * in, for a crash reporter.
10
+ *
11
+ * Its own class because it is a crash after the fact: the handler ran to
12
+ * its answer, whatever it wrote or charged along the way included. The
13
+ * idempotency engine reads it that way and keeps the key's record, so a
14
+ * retry is told the same thing instead of running the operation again.
15
+ */
16
+ export declare class LambderApiOutputValidationError extends Error {
17
+ readonly apiName: string;
18
+ /**
19
+ * The schema's issues when it rejected the payload. Null when the parse
20
+ * threw instead; what it threw is the `cause`.
21
+ */
22
+ readonly zodError: z.ZodError | null;
23
+ /**
24
+ * `failure` is how the parse ended: `{ zodError }` when the schema
25
+ * rejected the payload, `{ thrown }` when parsing it threw.
26
+ */
27
+ constructor(apiName: string, failure: {
28
+ zodError: z.ZodError;
29
+ } | {
30
+ thrown: unknown;
31
+ });
32
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A handler answered a payload its API's output schema could not turn into
4
+ * the declared shape, so the payload was not sent and the call is answered
5
+ * as a crash. Either the schema rejected the payload, or the parse threw: an
6
+ * async refinement or transform (the output is parsed synchronously, so an
7
+ * output schema cannot be async), or a transform that threw. The output
8
+ * side's counterpart of LambderApiValidationRefusal, with the API it happened
9
+ * in, for a crash reporter.
10
+ *
11
+ * Its own class because it is a crash after the fact: the handler ran to
12
+ * its answer, whatever it wrote or charged along the way included. The
13
+ * idempotency engine reads it that way and keeps the key's record, so a
14
+ * retry is told the same thing instead of running the operation again.
15
+ */
16
+ export class LambderApiOutputValidationError extends Error {
17
+ apiName;
18
+ /**
19
+ * The schema's issues when it rejected the payload. Null when the parse
20
+ * threw instead; what it threw is the `cause`.
21
+ */
22
+ zodError;
23
+ /**
24
+ * `failure` is how the parse ended: `{ zodError }` when the schema
25
+ * rejected the payload, `{ thrown }` when parsing it threw.
26
+ */
27
+ constructor(apiName, failure) {
28
+ let message;
29
+ if ("zodError" in failure) {
30
+ // Paths and messages only: an issue's message names the expected
31
+ // type, and the values themselves stay out of a crash report.
32
+ const issues = failure.zodError.issues.slice(0, 3).map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
33
+ message = `Lambder: API "${apiName}" answered a payload its output schema does not accept, so it was not sent. ${issues}`;
34
+ }
35
+ else if (failure.thrown instanceof z.core.$ZodAsyncError) {
36
+ message = `Lambder: API "${apiName}" has an async refinement or transform in its output schema, and an output schema cannot be async: `
37
+ + "a payload is parsed synchronously on its way out. The payload was not sent.";
38
+ }
39
+ else {
40
+ // The thrown error's own message stays in the cause: it is the
41
+ // app's text and may carry the value the transform was given.
42
+ message = `Lambder: API "${apiName}" answered a payload its output schema threw on while parsing it (a transform that threw, `
43
+ + "or an async step a synchronous parse cannot run), so it was not sent. What the schema threw is the cause.";
44
+ }
45
+ super(message, { cause: "zodError" in failure ? failure.zodError : failure.thrown });
46
+ this.name = "LambderApiOutputValidationError";
47
+ this.apiName = apiName;
48
+ this.zodError = "zodError" in failure ? failure.zodError : null;
49
+ }
50
+ }