lambder 7.3.1 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +933 -3
  2. package/README.md +41 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +140 -75
  41. package/dist/core/Lambder.js +347 -227
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +21 -7
  51. package/dist/core/LambderFiles.js +62 -34
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  73. package/dist/invoke/LambderLambdaEvent.js +40 -22
  74. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  75. package/dist/invoke/lambderHandlerTransport.js +15 -18
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +1 -1
  93. package/dist/mock.js +2 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +124 -46
  99. package/dist/session/LambderSessionManager.js +262 -137
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  134. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  135. package/dist/shared/util/boundKeyField.d.ts +20 -0
  136. package/dist/shared/util/boundKeyField.js +34 -0
  137. package/dist/shared/util/canonicalJson.d.ts +11 -0
  138. package/dist/shared/util/canonicalJson.js +28 -0
  139. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  140. package/dist/shared/util/joinKeyFields.js +22 -0
  141. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  142. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  143. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  144. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  145. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  146. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  147. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  148. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  149. package/dist/shared/wire/LambderApiSignature.js +16 -19
  150. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  151. package/dist/shared/wire/LambderCallOptions.js +9 -11
  152. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  153. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  154. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  155. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  156. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  157. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  158. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  159. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  160. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  161. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  162. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  163. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  164. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  165. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  166. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  167. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  168. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  169. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  170. package/dist/stores/LambderCacheFiller.js +119 -0
  171. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  172. package/dist/stores/LambderCacheKeys.js +54 -0
  173. package/dist/stores/LambderCacheValues.d.ts +45 -0
  174. package/dist/stores/LambderCacheValues.js +74 -0
  175. package/dist/stores/LambderDdbCache.d.ts +121 -56
  176. package/dist/stores/LambderDdbCache.js +528 -225
  177. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  178. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  179. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  180. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  181. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  182. package/dist/stores/LambderDdbSdk.js +79 -33
  183. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  184. package/dist/stores/LambderDdbSessionStore.js +119 -47
  185. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  186. package/dist/stores/LambderHttpFileSource.js +15 -13
  187. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  188. package/dist/stores/LambderMemoryCache.js +113 -0
  189. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  190. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  191. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  192. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  193. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  194. package/dist/stores/LambderMemorySessionStore.js +38 -19
  195. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  196. package/dist/stores/LambderS3FileSource.js +12 -7
  197. package/dist/testing/LambderTestApp.d.ts +21 -23
  198. package/dist/testing/LambderTestApp.js +22 -24
  199. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  200. package/dist/testing/LambderTestVisitor.js +15 -15
  201. package/dist/testing.d.ts +1 -0
  202. package/dist/testing.js +1 -0
  203. package/package.json +12 -3
  204. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  205. package/dist/api/LambderApiPolicyEngine.js +0 -85
  206. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  207. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,71 +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";
5
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";
6
11
  /**
7
- * A crashed original must not block retries forever, so a pending claim
8
- * expires on its own. The default is five minutes, which covers the great
9
- * majority of handlers.
10
- *
11
- * It has to outlive the handler, though, and that is the app's business to
12
- * know: a Lambda may run for fifteen minutes, and a claim that expires while
13
- * its own handler is still working hands the next retry a free scope, so the
14
- * operation runs a second time, which is the one thing idempotency exists to
15
- * prevent. An app whose handlers can run long should raise it to just past
16
- * 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.
17
18
  */
18
19
  const DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
19
20
  /**
20
21
  * Keys must be unguessable: without a session, the replay scope is the key
21
22
  * itself, so a guessable key would let one client read another's stored
22
- * response. LambderCaller.createIdempotencyKey() returns 36 chars.
23
+ * response. createIdempotencyKey() returns 36 chars.
23
24
  */
24
25
  const IDEMPOTENCY_MIN_KEY_LENGTH = 16;
25
26
  const IDEMPOTENCY_MAX_KEY_LENGTH = 200;
26
- /** 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
+ */
27
43
  const answerFromRecord = (record) => ({
28
44
  statusCode: record.statusCode,
29
- headers: record.headers,
45
+ headers: copyAnswerHeaders(record.headers),
30
46
  body: record.body,
31
47
  });
32
48
  /**
33
- * A replay window a store can act on, checked where the rate limiter checks
34
- * 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
35
50
  * comparison an expiry test makes, so an in-memory record with a NaN expiry
36
- * outlives every sweep, while DynamoDB rejects the same number outright. Zero
37
- * is rejected too, because a record that expires the instant it is written
38
- * 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.
39
54
  */
40
55
  const assertReplayTtl = (subject, ttlSeconds) => {
41
56
  if (ttlSeconds === undefined)
42
57
  return;
43
- // The shared positive-integer check, with the subject naming the option;
44
- // the wording "a replay window is a positive whole number of seconds"
45
- // lived here alone and said the same thing in different words.
46
58
  assertPositiveInteger(ttlSeconds, `${subject} (a replay window in whole seconds)`);
47
59
  };
48
- /** 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. */
49
61
  const resolveWindowSeconds = (config, field, fallback) => (typeof config === "object" ? config[field] : undefined) ?? fallback;
50
62
  /**
51
- * Headers the store may keep: the map and every value list copied, so what
52
- * the engine hands complete() cannot be rewritten afterwards.
53
- *
54
- * The pipeline applies the CALL's headers onto the answer on the way out,
55
- * into the very object handed here. A store that keeps the reference it was
56
- * given (the interface asks for a copy, and the shipped stores make one, but
57
- * a custom store is under no compiler's supervision) would have this call's
58
- * 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.
59
67
  */
60
- 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
+ };
61
123
  /**
62
124
  * A store failure the engine decided to ignore, said out loud. Failing open
63
- * is the right default (a store outage should not take the app down with it),
64
- * but it is also indistinguishable from working: the failure class includes
65
- * permanent ones (a missing table, a missing IAM action, an SDK that would
66
- * not install), and an app can run for months executing every retry twice
67
- * with nothing in its logs. The scope key never appears, since it carries the
68
- * 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.
69
130
  */
70
131
  const reportFailOpen = (apiName, attempted, err) => {
71
132
  console.error(`Lambder idempotency: "${apiName}" could not ${attempted}; the request is being executed as if it carried no idempotency key. ` +
@@ -74,24 +135,20 @@ const reportFailOpen = (apiName, attempted, err) => {
74
135
  /**
75
136
  * Runtime side of the idempotency subsystem: claims a per-operation scope
76
137
  * around handler execution, replays stored answers, and settles claims.
77
- * Composed into LambderApiPolicyEngine. Works on plain answers, so it runs
138
+ * Held by LambderApiPipeline. Works on plain answers, so it runs
78
139
  * unchanged under the server and the mock runtime.
79
140
  */
80
141
  export class LambderApiIdempotencyEngine {
142
+ /** Stamped on the crash answer the engine records for an answer that broke its output schema. */
143
+ apiVersion;
81
144
  store = null;
82
145
  defaultTtlSeconds = 24 * 3600;
83
146
  defaultPendingTtlSeconds = DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
84
147
  failOpen = true;
85
148
  callerIdentity = undefined;
86
- /**
87
- * The scope this call resolved to, keyed by its context, which is the one
88
- * object per call the engine is handed. A keyed request asks for it
89
- * twice, at the replay lookup and at the claim, and callerIdentity is app
90
- * code that may verify a token or read a store: running it twice per
91
- * request is a cost the app never asked for, and one it cannot see.
92
- * Entries go when the call's context does.
93
- */
94
- scopeByCall = new WeakMap();
149
+ constructor(apiVersion) {
150
+ this.apiVersion = apiVersion;
151
+ }
95
152
  configure(config) {
96
153
  if (this.store)
97
154
  throw new Error("Lambder: idempotency was already configured.");
@@ -142,61 +199,69 @@ export class LambderApiIdempotencyEngine {
142
199
  return rawKey;
143
200
  }
144
201
  /**
145
- * The record's scope. Session APIs scope per session, so even a leaked
146
- * key cannot cross users. Public APIs scope by the key alone unless the
147
- * app supplies callerIdentity, because the key is required to be long
148
- * (and documented to be random), and identity proxies like the client IP
149
- * are deliberately NOT part of the scope: the retry idempotency exists
150
- * for (a timeout followed by a network change) frequently arrives from a
151
- * different IP. An app whose public APIs are authorized by a guard should
152
- * 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.
153
211
  *
154
- * Fields are escaped and joined through joinKeyFields, so no two distinct
155
- * 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.
156
217
  */
157
218
  async scopeOf(apiName, ctx, request, key) {
158
- const cached = this.scopeByCall.get(ctx);
159
- if (cached)
160
- return await cached;
161
- const computed = this.computeScope(apiName, ctx, request, key);
162
- this.scopeByCall.set(ctx, computed);
163
- return await computed;
164
- }
165
- async computeScope(apiName, ctx, request, key) {
166
219
  const sessionKey = ctx.session?.sessionKey;
167
220
  if (sessionKey)
168
- return joinKeyFields(`s:${sessionKey}`, apiName, key);
221
+ return joinKeyFields(await boundKeyField("s", sessionKey), apiName, key);
169
222
  // No session to scope by. The app may still say who this is, through
170
223
  // callerIdentity; without one the key alone is the scope, which is
171
224
  // what makes it a bearer token for its own answer.
172
225
  const identity = this.callerIdentity ? await this.callerIdentity(ctx, request) : null;
173
226
  return identity
174
- ? joinKeyFields(`i:${identity}`, apiName, key)
227
+ ? joinKeyFields(await boundKeyField("i", identity), apiName, key)
175
228
  : joinKeyFields("k", apiName, key);
176
229
  }
177
230
  /**
178
- * Replay fast path, run before the remaining rate limits and before
179
- * guards: a completed record answers with its stored answer, so a
180
- * legitimate retry neither burns rate-limit quota nor re-runs guards (the
181
- * original already passed them, and no handler executes). The `per: "ip"`
182
- * limits are the exception and are checked ahead of this, since the store
183
- * read a replay costs is one of the things they exist to bound. Misses
184
- * fall through to the normal pipeline; store errors follow the failOpen
185
- * 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.
186
238
  */
187
- async findReplay(apiName, request, ctx, trace) {
188
- const store = this.store;
189
- if (!store)
239
+ async resolveKeyedCall(apiName, request, ctx) {
240
+ if (!this.store)
190
241
  return null;
191
242
  const key = this.readKey(request);
192
243
  if (key === null)
193
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;
194
263
  try {
195
- const done = await store.peek(await this.scopeOf(apiName, ctx, request, key));
196
- if (!done)
197
- return null;
198
- trace.replayed = true;
199
- return answerFromRecord(done);
264
+ done = await store.peek(call.scopeKey);
200
265
  }
201
266
  catch (err) {
202
267
  if (!this.failOpen)
@@ -204,36 +269,36 @@ export class LambderApiIdempotencyEngine {
204
269
  reportFailOpen(apiName, "look its replay record up", err);
205
270
  return null;
206
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);
207
278
  }
208
279
  /**
209
- * Idempotency wrapper around validation-passed handler execution. Without
210
- * a client idempotencyKey the handler just runs; with one, the scope
211
- * (identity + api + key) is claimed atomically: duplicates of an
212
- * in-flight original refuse with 409, replays of a completed one return
213
- * the stored answer verbatim, and a crashed original releases its claim
214
- * 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.
215
285
  *
216
- * `exec` must hand back the handler's own answer, the headers the handler
217
- * itself wrote included: the pipeline applies those before returning here,
218
- * 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.
219
288
  * Headers written EARLIER in the call (a session read evicting a stale
220
- * cookie) are deliberately not on it: they are the call's, they reach the
221
- * client either way, and charging them to this answer would make an
222
- * 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.
223
292
  */
224
- async withIdempotency(apiName, request, ctx, config, trace, exec) {
293
+ async withIdempotency(apiName, { scopeKey, fingerprint }, config, trace, exec) {
225
294
  const store = this.store;
226
295
  if (!store)
227
296
  return await exec();
228
- const rawKey = this.readKey(request);
229
- if (rawKey === null)
230
- return await exec();
231
297
  const ttlSeconds = resolveWindowSeconds(config, "ttlSeconds", this.defaultTtlSeconds);
232
298
  const pendingTtlSeconds = resolveWindowSeconds(config, "pendingTtlSeconds", this.defaultPendingTtlSeconds);
233
- const scopeKey = await this.scopeOf(apiName, ctx, request, rawKey);
234
299
  let begun;
235
300
  try {
236
- begun = await store.begin(scopeKey, { pendingTtlSeconds });
301
+ begun = await store.begin(scopeKey, { pendingTtlSeconds, fingerprint });
237
302
  }
238
303
  catch (err) {
239
304
  if (!this.failOpen)
@@ -241,6 +306,8 @@ export class LambderApiIdempotencyEngine {
241
306
  reportFailOpen(apiName, "claim its scope", err);
242
307
  return await exec();
243
308
  }
309
+ if (begun.state !== "new" && begun.fingerprint !== fingerprint)
310
+ throw keyReusedRefusal(apiName);
244
311
  if (begun.state === "pending") {
245
312
  throw new LambderApiRefusal(`Duplicate request for "${apiName}": the original is still processing.`, {
246
313
  statusCode: 409,
@@ -248,43 +315,34 @@ export class LambderApiIdempotencyEngine {
248
315
  });
249
316
  }
250
317
  // The other replay path: the original settled between this request's
251
- // peek and its claim, which is the race the "done" answer exists for.
252
- // 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.
253
320
  if (begun.state === "done") {
254
321
  trace.replayed = true;
255
322
  return answerFromRecord(begun);
256
323
  }
257
324
  const ownerToken = begun.ownerToken;
258
325
  // Store the answer for replays when it qualifies, release the claim
259
- // otherwise. A Set-Cookie makes an answer uncacheable (replaying
260
- // another request's cookies, e.g. session tokens, would be wrong), and
261
- // so does a binary body.
326
+ // otherwise.
262
327
  //
263
- // Settling happens after the handler has already run, so a store
264
- // failure here can no longer prevent anything: the work is done and
265
- // the answer is owed to the caller. failOpen governs the decision
266
- // BEFORE execution, at begin(), where refusing still means refusing to
267
- // act. Applying it here would turn a completed operation into a 500
268
- // and hand the retry a released claim, which is exactly the double
269
- // execution idempotency exists to prevent, so a settle failure is
270
- // reported and swallowed.
271
- const settleClaim = async (answer) => {
272
- const cacheable = answer.statusCode < 500
273
- && !answer.isBodyBase64
274
- && 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) => {
275
335
  try {
276
- if (cacheable) {
277
- // The store owns the size decision: bodies may be compressed
278
- // there, and only ones exceeding its budget even compressed
279
- // 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.
280
340
  const completion = await store.complete(scopeKey, ownerToken, {
281
341
  statusCode: answer.statusCode,
282
- // Copied, not handed over: the pipeline is about to
283
- // apply this call's own headers into answer.headers,
284
- // and a store that kept the reference would have them
285
- // in the record.
342
+ // Copied: see copyAnswerHeaders.
286
343
  headers: copyAnswerHeaders(answer.headers),
287
344
  body: answer.body,
345
+ fingerprint,
288
346
  ttlSeconds,
289
347
  });
290
348
  if (completion !== "too-large")
@@ -295,41 +353,46 @@ export class LambderApiIdempotencyEngine {
295
353
  await store.abandon(scopeKey, ownerToken);
296
354
  }
297
355
  catch (storeErr) {
298
- // Two cases, one release. A complete() that never landed must
299
- // not leave the pending claim dangling: it would 409 the
300
- // caller's genuine retries until the pending TTL runs out. A
301
- // complete() whose write DID land and then timed out is
302
- // released too, so the record stops replaying and a retry
303
- // re-executes rather than replaying. That is the safe
304
- // direction of the two: the work has already been done once
305
- // and its answer is with the caller, so a retry that runs
306
- // again costs an execution, while a claim nobody can clear is
307
- // 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.
308
364
  try {
309
365
  await store.abandon(scopeKey, ownerToken);
310
366
  }
311
367
  catch { /* claim expires on its own */ }
312
- console.warn(`Lambder idempotency: "${apiName}" ran and answered, but its record could not be stored. ` +
313
- "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);
314
370
  }
315
371
  };
316
372
  try {
317
373
  const answer = await exec();
318
- await settleClaim(answer);
374
+ await settleClaim(answer, isReplayableAnswer(answer));
319
375
  return answer;
320
376
  }
321
377
  catch (err) {
322
- // A real crash, or a thrown refusal (LambderApiRefusal, refuse()),
323
- // releases the claim so a retry actually retries. This is the
324
- // deliberate rule: ANSWERS are stored and replayed, refusals
325
- // delivered as returned envelopes included; EXCEPTIONS are not, so
326
- // a thrown refusal re-executes on retry and the handler decides
327
- // afresh. Pick the idiom accordingly. (On the server, a response
328
- // delivered by throwing, res.die.api(), is an answer: the adapter
329
- // catches it before it reaches here.)
330
- // Whatever happens to the claim, the handler's own failure is the
331
- // one worth reporting: replacing it with a cleanup error would
332
- // 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.
333
396
  try {
334
397
  await store.abandon(scopeKey, ownerToken);
335
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
+ }