lambder 14.0.1 → 15.0.1

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 (163) hide show
  1. package/CHANGELOG.md +450 -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 +47 -13
  12. package/dist/api/LambderApiPipeline.js +62 -20
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +26 -10
  16. package/dist/api/LambderApiRefusals.js +48 -15
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/api/LambderApiSignature.d.ts +5 -5
  20. package/dist/api/LambderApiSignature.js +7 -5
  21. package/dist/build/checkApiRefusals.d.ts +59 -0
  22. package/dist/build/checkApiRefusals.js +442 -0
  23. package/dist/build/contractPrinterProcess.d.ts +11 -0
  24. package/dist/build/contractPrinterProcess.js +17 -0
  25. package/dist/build/generateApiFiles.d.ts +26 -5
  26. package/dist/build/generateApiFiles.js +68 -7
  27. package/dist/build/loadTypeScriptCompiler.d.ts +10 -0
  28. package/dist/build/loadTypeScriptCompiler.js +20 -0
  29. package/dist/build/writeApiContract.js +21 -15
  30. package/dist/build/writeApiSchemas.d.ts +109 -0
  31. package/dist/build/writeApiSchemas.js +308 -0
  32. package/dist/build.d.ts +8 -3
  33. package/dist/build.js +6 -2
  34. package/dist/client/LambderCaller.d.ts +25 -11
  35. package/dist/client/LambderCaller.js +57 -49
  36. package/dist/client/LambderUploadRunner.d.ts +55 -27
  37. package/dist/client/LambderUploadRunner.js +89 -34
  38. package/dist/client.d.ts +6 -6
  39. package/dist/client.js +2 -2
  40. package/dist/core/Lambder.d.ts +37 -26
  41. package/dist/core/Lambder.js +89 -32
  42. package/dist/core/LambderCallSummary.d.ts +1 -1
  43. package/dist/core/LambderContext.d.ts +59 -7
  44. package/dist/core/LambderContext.js +31 -15
  45. package/dist/core/LambderCrashHandling.d.ts +5 -0
  46. package/dist/core/LambderCrashHandling.js +11 -1
  47. package/dist/core/LambderCreateOptions.d.ts +28 -17
  48. package/dist/core/LambderCreateOptions.js +14 -7
  49. package/dist/core/LambderResolver.d.ts +3 -2
  50. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  51. package/dist/core/LambderResponseBuilder.js +39 -8
  52. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  53. package/dist/core/LambderTemplatingEngine.js +23 -3
  54. package/dist/index.d.ts +15 -12
  55. package/dist/index.js +7 -5
  56. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  57. package/dist/invoke/LambderInvokeCaller.js +47 -9
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  59. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  60. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  61. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  62. package/dist/mock/LambderMockApiSchemas.js +150 -0
  63. package/dist/mock/LambderMockApp.d.ts +56 -48
  64. package/dist/mock/LambderMockApp.js +107 -62
  65. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  66. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  67. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  68. package/dist/mock/LambderMockFailureInjector.js +12 -8
  69. package/dist/mock/LambderMockTypes.d.ts +8 -4
  70. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  71. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  72. package/dist/mock/lambderMockMswHandler.js +25 -22
  73. package/dist/mock.d.ts +1 -0
  74. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  75. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  76. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  77. package/dist/secrets/LambderPasswordHasher.js +168 -0
  78. package/dist/session/LambderSessionController.d.ts +37 -4
  79. package/dist/session/LambderSessionController.js +36 -6
  80. package/dist/session/LambderSessionManager.d.ts +33 -3
  81. package/dist/session/LambderSessionManager.js +55 -16
  82. package/dist/shared/LambderHtml.d.ts +23 -4
  83. package/dist/shared/LambderHtml.js +17 -5
  84. package/dist/shared/LambderI18n.d.ts +106 -16
  85. package/dist/shared/LambderI18n.js +259 -74
  86. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  87. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  88. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  89. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  90. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  91. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  92. package/dist/shared/util/LambderCallAbort.js +37 -9
  93. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  94. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  95. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  96. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  97. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  98. package/dist/shared/util/LambderSignedClaims.js +54 -0
  99. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  100. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  101. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  102. package/dist/shared/util/LambderTestingDoors.js +6 -2
  103. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  104. package/dist/shared/util/LambderTextDigest.js +5 -5
  105. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  106. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  107. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  108. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  109. package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
  110. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  111. package/dist/shared/wire/LambderApiRefusal.d.ts +41 -14
  112. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  113. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  114. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  115. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  116. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  117. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  118. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  119. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  120. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  121. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  122. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  123. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  124. package/dist/stores/LambderCacheValues.d.ts +2 -5
  125. package/dist/stores/LambderDdbCache.d.ts +15 -9
  126. package/dist/stores/LambderDdbCache.js +25 -6
  127. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  128. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  129. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  130. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  131. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  132. package/dist/stores/LambderDdbSdk.js +116 -44
  133. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  134. package/dist/stores/LambderDdbSessionStore.js +133 -68
  135. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  136. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  137. package/dist/stores/LambderS3FileSource.js +11 -13
  138. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  139. package/dist/stores/LambderS3UploadBucket.js +23 -0
  140. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  141. package/dist/stores/LambderStoredText.d.ts +26 -0
  142. package/dist/stores/LambderStoredText.js +25 -0
  143. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  144. package/dist/testing/LambderConformanceRunner.js +9 -2
  145. package/dist/testing/LambderTestApp.d.ts +65 -7
  146. package/dist/testing/LambderTestApp.js +101 -6
  147. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  148. package/dist/testing/LambderTestVisitor.js +35 -11
  149. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  150. package/dist/testing/bootLambdaPackage.js +265 -0
  151. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  152. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  153. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  154. package/dist/testing/lambderCacheConformance.js +287 -0
  155. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  156. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  157. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  158. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  159. package/dist/testing.d.ts +7 -1
  160. package/dist/testing.js +5 -0
  161. package/package.json +1 -6
  162. package/dist/shared/util/boundKeyField.d.ts +0 -20
  163. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,31 +1,33 @@
1
1
  import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
2
2
  import type { LambderRateLimitMessage, LambderRateLimitOptionValue, LambderRateLimitOverride } from "../shared/wire/LambderApiOptionValues.js";
3
+ import type { LambderKeyFieldDigest } from "../shared/util/LambderKeyFieldDigest.js";
3
4
  import type { z } from "zod";
4
5
  import type { LambderApiRequest } from "./LambderApiRequest.js";
5
6
  import type { LambderApiCallContext } from "./LambderApiCallContext.js";
6
7
  import { type LambderRateLimiter, type LambderRateLimitExceeded, type LambderRateLimitPolicy } from "../shared/contracts/LambderRateLimiter.js";
7
8
  import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
8
- import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
9
+ import { LambderApiRefusal, type LambderRateLimitRefusalData } from "../shared/wire/LambderApiRefusal.js";
9
10
  import type { LambderNonEmptyOptionMap } from "../shared/util/LambderTypeUtilities.js";
10
11
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
11
- /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
12
+ /** The words a rate-limited call answers with unless the policy or the API's override writes its own (the `refusal` option of each). */
12
13
  export declare const DEFAULT_RATE_LIMIT_REFUSAL: {
13
14
  type: "warning";
14
- code: "lambder/rate-limited";
15
15
  content: string;
16
16
  };
17
17
  /**
18
18
  * The refusal a rate-limited call answers with: a 429 envelope carrying the
19
- * framework code, whatever message a policy or an API wrote, and a
20
- * Retry-After header. The engine throws it; the mock runtime's failure
21
- * injection throws the same one, so an injected rate limit is
22
- * indistinguishable from a real one.
19
+ * framework code, the words a policy or an API wrote, the policy's name and
20
+ * the seconds to wait as its data, and a Retry-After header of those same
21
+ * seconds. The engine throws it; the mock runtime's failure injection throws
22
+ * the same one, so an injected rate limit is indistinguishable from a real
23
+ * one.
23
24
  *
24
- * The code goes on after the message, so a message that carries one anyway
25
- * (a plain-JS caller, a cast) cannot replace it: a rate limit is never a code
26
- * an endpoint declares, and the pipeline would refuse to send one it did not.
25
+ * Only the words are read off the message, so one that carries a code or
26
+ * data anyway (a plain-JS caller, a cast) cannot replace them: a rate limit
27
+ * is never a code an endpoint declares, and the pipeline sends one only with
28
+ * the data a client narrows it by.
27
29
  */
28
- export declare const rateLimitRefusal: (detail: string, retryAfterSeconds: number, message?: LambderRateLimitMessage) => LambderApiRefusal;
30
+ export declare const rateLimitRefusal: (detail: string, data: LambderRateLimitRefusalData, message?: LambderRateLimitMessage) => LambderApiRefusal;
29
31
  /**
30
32
  * A custom rate-limit key. `apiInput` names the fields of the API's OWN
31
33
  * payload the key derives from: that slice is validated against the raw
@@ -110,7 +112,7 @@ export type LambderApiRateLimitPolicyConfig<TCtx = any> = LambderRateLimitPolicy
110
112
  * takes it: `per: "ip"` and `per: "session"` have one place each.
111
113
  */
112
114
  chargeAt?: LambderRateLimitChargeAt;
113
- /** The refusal's message for refused requests: its type, title and content, under the code "lambder/rate-limited". Default: a warning saying too many requests. */
115
+ /** The refusal's words for refused requests: its type, title and content, under the code "lambder/rate-limited" and beside the data that names this policy to the client. Default: a warning saying too many requests. */
114
116
  refusal?: LambderRateLimitMessage;
115
117
  };
116
118
  export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig<any>>> = {
@@ -276,8 +278,13 @@ type LambderRateLimitPhase = "beforeSession" | LambderRateLimitChargeAt;
276
278
  * slice) and hands the context to a custom key's handler, reading nothing
277
279
  * of the context itself but its session, so it runs unchanged under the
278
280
  * server and the mock runtime.
281
+ *
282
+ * The caller's half of every tracker key (the address, the session key, a
283
+ * custom key) reaches the limiter as a digest (LambderKeyFieldDigest), so a
284
+ * table read names the API and the policy and never who was counted.
279
285
  */
280
286
  export declare class LambderApiRateLimitsEngine {
287
+ private readonly keyFieldDigest;
281
288
  private limiter;
282
289
  private failOpen;
283
290
  private ipv6PrefixLength;
@@ -289,6 +296,7 @@ export declare class LambderApiRateLimitsEngine {
289
296
  * second would otherwise be as many identical log lines.
290
297
  */
291
298
  private readonly loggedFailures;
299
+ constructor(keyFieldDigest: LambderKeyFieldDigest);
292
300
  /** True once rateLimits were configured. */
293
301
  get isConfigured(): boolean;
294
302
  configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
@@ -318,7 +326,7 @@ export declare class LambderApiRateLimitsEngine {
318
326
  /**
319
327
  * One policy charged by code rather than by a declaration, for
320
328
  * `ctx.rateLimit` and `ctx.isRateLimited`. Counters, failOpen, key
321
- * bounding and refusal are those of a declared limit; only who knows the
329
+ * digests and refusal are those of a declared limit; only who knows the
322
330
  * key differs. A policy without `per` takes the key the code passes, a
323
331
  * `per: "ip"` or `per: "session"` one reads it off the request, and one
324
332
  * keyed by an API payload is refused, since only its APIs know the key.
@@ -334,20 +342,19 @@ export declare class LambderApiRateLimitsEngine {
334
342
  /**
335
343
  * Counts one attempt, or lets it through when the limiter itself fails
336
344
  * and failOpen is on. The log line names the policy and its windows,
337
- * never the tracker key: the key carries whatever a custom handler
338
- * returned, which the docs' own example makes an email address. A
339
- * failure the limiter throws again (see loggedFailures) is not logged
340
- * again.
345
+ * never the tracker key: digested as its caller's half is, the key is
346
+ * still the same for every attempt of one caller, and a log is read by
347
+ * more people than the table. A failure the limiter throws again (see
348
+ * loggedFailures) is not logged again.
341
349
  */
342
350
  private countAttempt;
343
351
  private chargeKeyOf;
344
352
  private resolveKey;
345
353
  /**
346
354
  * The key a `per: "ip"` or `per: "session"` policy counts under: what the
347
- * request carries, however the policy is charged. A session key is
348
- * bounded like a custom one (see boundKeyField); an address needs no
349
- * bound, since normalizeClientIp caps it at 45 characters whichever
350
- * header or gateway field named it.
355
+ * request carries, however the policy is charged, digested as a custom
356
+ * key is. The address is the subject rateLimitSubjectOf counts (an IPv6
357
+ * caller's /64), so the digest of one subscriber's every address is one.
351
358
  */
352
359
  private requestKeyOf;
353
360
  }
@@ -1,5 +1,4 @@
1
1
  import { joinKeyFields } from "../shared/util/joinKeyFields.js";
2
- import { boundKeyField } from "../shared/util/boundKeyField.js";
3
2
  import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
4
3
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
5
4
  import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
@@ -7,29 +6,43 @@ import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js"
7
6
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
8
7
  import { DEFAULT_IPV6_RATE_LIMIT_PREFIX, rateLimitSubjectOf } from "../shared/util/LambderClientIp.js";
9
8
  const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
10
- /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
11
- export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
9
+ /** The words a rate-limited call answers with unless the policy or the API's override writes its own (the `refusal` option of each). */
10
+ export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", content: "Too many requests. Please try again later." };
12
11
  /**
13
12
  * The refusal a rate-limited call answers with: a 429 envelope carrying the
14
- * framework code, whatever message a policy or an API wrote, and a
15
- * Retry-After header. The engine throws it; the mock runtime's failure
16
- * injection throws the same one, so an injected rate limit is
17
- * indistinguishable from a real one.
13
+ * framework code, the words a policy or an API wrote, the policy's name and
14
+ * the seconds to wait as its data, and a Retry-After header of those same
15
+ * seconds. The engine throws it; the mock runtime's failure injection throws
16
+ * the same one, so an injected rate limit is indistinguishable from a real
17
+ * one.
18
18
  *
19
- * The code goes on after the message, so a message that carries one anyway
20
- * (a plain-JS caller, a cast) cannot replace it: a rate limit is never a code
21
- * an endpoint declares, and the pipeline would refuse to send one it did not.
19
+ * Only the words are read off the message, so one that carries a code or
20
+ * data anyway (a plain-JS caller, a cast) cannot replace them: a rate limit
21
+ * is never a code an endpoint declares, and the pipeline sends one only with
22
+ * the data a client narrows it by.
22
23
  */
23
- export const rateLimitRefusal = (detail, retryAfterSeconds, message) => new LambderApiRefusal(detail, {
24
- refusal: message ? { type: message.type, ...(message.title !== undefined ? { title: message.title } : {}), content: message.content, code: LAMBDER_REFUSAL_CODES.rateLimited } : DEFAULT_RATE_LIMIT_REFUSAL,
25
- statusCode: 429,
26
- headers: { "Retry-After": String(Math.max(1, Math.floor(retryAfterSeconds))) },
27
- });
24
+ export const rateLimitRefusal = (detail, data, message = DEFAULT_RATE_LIMIT_REFUSAL) => {
25
+ // The header carries whole seconds of at least one, and the data says the
26
+ // same number: a client reading either waits as long.
27
+ if (!Number.isFinite(data.retryAfterSeconds))
28
+ throw new Error(`Lambder: a rate-limit refusal waits a finite number of seconds, got ${String(data.retryAfterSeconds)}.`);
29
+ const retryAfterSeconds = Math.max(1, Math.floor(data.retryAfterSeconds));
30
+ return new LambderApiRefusal(detail, {
31
+ refusal: {
32
+ type: message.type,
33
+ ...(message.title !== undefined ? { title: message.title } : {}),
34
+ content: message.content,
35
+ code: LAMBDER_REFUSAL_CODES.rateLimited,
36
+ data: { policy: data.policy, retryAfterSeconds },
37
+ },
38
+ statusCode: 429,
39
+ headers: { "Retry-After": String(retryAfterSeconds) },
40
+ });
41
+ };
28
42
  /**
29
- * A rate-limit message carries no code of its own and no data: its code is
30
- * always the framework's, which rateLimitRefusal sets, and a code there would
31
- * read as one the app chose for clients to branch on when it never reaches
32
- * them.
43
+ * A rate-limit message carries no code of its own and no data: both are the
44
+ * framework's, which rateLimitRefusal sets, and a code there would read as
45
+ * one the app chose for clients to branch on when it never reaches them.
33
46
  */
34
47
  const assertRateLimitMessage = (where, message) => {
35
48
  if (message === undefined)
@@ -39,7 +52,7 @@ const assertRateLimitMessage = (where, message) => {
39
52
  throw new Error(`Lambder: ${where} sets a refusal code. A rate-limit refusal is always "${LAMBDER_REFUSAL_CODES.rateLimited}", so an endpoint never has to declare it; write the type, title and content only.`);
40
53
  }
41
54
  if (data !== undefined) {
42
- throw new Error(`Lambder: ${where} sets refusal data. A rate-limit refusal carries none.`);
55
+ throw new Error(`Lambder: ${where} sets refusal data. A rate-limit refusal carries the framework's: the policy that refused and the seconds to wait.`);
43
56
  }
44
57
  };
45
58
  /**
@@ -118,8 +131,13 @@ const trackerKeyOf = (name, policy, apiName, key) => policy.budget === "perPolic
118
131
  * slice) and hands the context to a custom key's handler, reading nothing
119
132
  * of the context itself but its session, so it runs unchanged under the
120
133
  * server and the mock runtime.
134
+ *
135
+ * The caller's half of every tracker key (the address, the session key, a
136
+ * custom key) reaches the limiter as a digest (LambderKeyFieldDigest), so a
137
+ * table read names the API and the policy and never who was counted.
121
138
  */
122
139
  export class LambderApiRateLimitsEngine {
140
+ keyFieldDigest;
123
141
  limiter = null;
124
142
  failOpen = true;
125
143
  ipv6PrefixLength = DEFAULT_IPV6_RATE_LIMIT_PREFIX;
@@ -134,6 +152,9 @@ export class LambderApiRateLimitsEngine {
134
152
  * second would otherwise be as many identical log lines.
135
153
  */
136
154
  loggedFailures = new WeakSet();
155
+ constructor(keyFieldDigest) {
156
+ this.keyFieldDigest = keyFieldDigest;
157
+ }
137
158
  /** True once rateLimits were configured. */
138
159
  get isConfigured() { return this.limiter !== null; }
139
160
  configure(config) {
@@ -249,14 +270,14 @@ export class LambderApiRateLimitsEngine {
249
270
  const limits = windowsOf(policy, override);
250
271
  const exceeded = await this.countAttempt(name, trackerKeyOf(name, policy, apiName, key), limits, `API "${apiName}"`);
251
272
  if (exceeded) {
252
- throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, this.retryAfterOf(exceeded), override?.refusal ?? policy.refusal);
273
+ throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, { policy: name, retryAfterSeconds: this.retryAfterOf(exceeded) }, override?.refusal ?? policy.refusal);
253
274
  }
254
275
  }
255
276
  }
256
277
  /**
257
278
  * One policy charged by code rather than by a declaration, for
258
279
  * `ctx.rateLimit` and `ctx.isRateLimited`. Counters, failOpen, key
259
- * bounding and refusal are those of a declared limit; only who knows the
280
+ * digests and refusal are those of a declared limit; only who knows the
260
281
  * key differs. A policy without `per` takes the key the code passes, a
261
282
  * `per: "ip"` or `per: "session"` one reads it off the request, and one
262
283
  * keyed by an API payload is refused, since only its APIs know the key.
@@ -276,7 +297,7 @@ export class LambderApiRateLimitsEngine {
276
297
  const retryAfterSeconds = this.retryAfterOf(exceeded);
277
298
  return {
278
299
  checkResult: { ...exceeded, retryAfterSeconds },
279
- refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.refusal),
300
+ refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, { policy: name, retryAfterSeconds }, policy.refusal),
280
301
  };
281
302
  }
282
303
  /**
@@ -292,10 +313,10 @@ export class LambderApiRateLimitsEngine {
292
313
  /**
293
314
  * Counts one attempt, or lets it through when the limiter itself fails
294
315
  * and failOpen is on. The log line names the policy and its windows,
295
- * never the tracker key: the key carries whatever a custom handler
296
- * returned, which the docs' own example makes an email address. A
297
- * failure the limiter throws again (see loggedFailures) is not logged
298
- * again.
316
+ * never the tracker key: digested as its caller's half is, the key is
317
+ * still the same for every attempt of one caller, and a log is read by
318
+ * more people than the table. A failure the limiter throws again (see
319
+ * loggedFailures) is not logged again.
299
320
  */
300
321
  async countAttempt(name, trackerKey, limits, where) {
301
322
  try {
@@ -319,7 +340,7 @@ export class LambderApiRateLimitsEngine {
319
340
  if (per === undefined) {
320
341
  if (subject.key === undefined)
321
342
  throw new Error(`Lambder: rate-limit policy "${name}" declares no per, so the code charging it passes the key: ctx.rateLimit("${name}", key).`);
322
- return await boundKeyField("custom", subject.key);
343
+ return await this.keyFieldDigest.digestOf("custom", subject.key);
323
344
  }
324
345
  if (per !== "ip" && per !== "session") {
325
346
  throw new Error(`Lambder: rate-limit policy "${name}" derives its key from an API's payload, so only the APIs declaring it can charge it.`);
@@ -332,21 +353,20 @@ export class LambderApiRateLimitsEngine {
332
353
  if (per === "ip" || per === "session")
333
354
  return await this.requestKeyOf(name, per, request.ip, ctx.session);
334
355
  const payload = per.apiInput ? await parsePreflightSlice(per.apiInput, request.payload) : undefined;
335
- return await boundKeyField("custom", await per.handler(ctx, payload));
356
+ return await this.keyFieldDigest.digestOf("custom", await per.handler(ctx, payload));
336
357
  }
337
358
  /**
338
359
  * The key a `per: "ip"` or `per: "session"` policy counts under: what the
339
- * request carries, however the policy is charged. A session key is
340
- * bounded like a custom one (see boundKeyField); an address needs no
341
- * bound, since normalizeClientIp caps it at 45 characters whichever
342
- * header or gateway field named it.
360
+ * request carries, however the policy is charged, digested as a custom
361
+ * key is. The address is the subject rateLimitSubjectOf counts (an IPv6
362
+ * caller's /64), so the digest of one subscriber's every address is one.
343
363
  */
344
364
  async requestKeyOf(name, per, ip, session) {
345
365
  if (per === "ip")
346
- return `ip:${rateLimitSubjectOf(ip, this.ipv6PrefixLength)}`;
366
+ return await this.keyFieldDigest.digestOf("ip", rateLimitSubjectOf(ip, this.ipv6PrefixLength));
347
367
  const sessionKey = session?.sessionKey;
348
368
  if (!sessionKey)
349
369
  throw new Error(`Lambder: rate-limit policy "${name}" is keyed per "session", and the request charging it has no session.`);
350
- return await boundKeyField("session", sessionKey);
370
+ return await this.keyFieldDigest.digestOf("session", sessionKey);
351
371
  }
352
372
  }
@@ -8,7 +8,7 @@ import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderN
8
8
  * One code of an app's refusal vocabulary: the schema of the data it carries,
9
9
  * when it carries data, and how every refusal with the code leaves the
10
10
  * server. A code's data is an object or an array, as an API's output is. The
11
- * status and the flag are declared once here rather than at each raise site,
11
+ * status and the flags are declared once here rather than at each raise site,
12
12
  * so a code always reaches a caller the same way.
13
13
  */
14
14
  export type LambderRefusalDeclaration = {
@@ -18,6 +18,14 @@ export type LambderRefusalDeclaration = {
18
18
  status?: LambderRefusalStatusCode;
19
19
  /** Whether every refusal with this code sets the envelope's notAuthorized flag, which a caller routes to its notAuthorizedHandler. */
20
20
  notAuthorized?: true;
21
+ /**
22
+ * Whether every refusal with this code sets the envelope's sessionExpired
23
+ * flag, which a caller answers by clearing its session and routing to its
24
+ * sessionExpiredHandler: for a session the handler found no longer good
25
+ * (its login deleted, say), which the pipeline's own check could not know.
26
+ * The server ends the session the call held as the refusal leaves.
27
+ */
28
+ sessionExpired?: true;
21
29
  };
22
30
  /**
23
31
  * One code as its endpoint may send it: the status and flag its declaration
@@ -30,6 +38,8 @@ export type LambderApiAllowedRefusal = {
30
38
  status?: LambderRefusalStatusCode;
31
39
  /** Present when every refusal with the code sets the envelope's notAuthorized flag. */
32
40
  notAuthorized?: true;
41
+ /** Present when every refusal with the code sets the envelope's sessionExpired flag. */
42
+ sessionExpired?: true;
33
43
  } & ({
34
44
  data: false;
35
45
  } | {
@@ -61,7 +71,7 @@ type LambderRefusalDataRefusal = {
61
71
  };
62
72
  /**
63
73
  * The vocabulary as declareRefusals() checks it, code by code: no `lambder/`
64
- * prefix, no key beside data, status and notAuthorized (a `dat:` would leave
74
+ * prefix, no key beside data, status and the two flags (a `dat:` would leave
65
75
  * a code that carries data declared as one that does not), and data that is
66
76
  * an object or an array.
67
77
  */
@@ -120,6 +130,8 @@ type LambderRefusalViolation = {
120
130
  thrown: unknown;
121
131
  } | {
122
132
  notObject: string;
133
+ } | {
134
+ notRateLimitData: true;
123
135
  };
124
136
  /**
125
137
  * A refusal an endpoint is not allowed to send, so it was not sent and the
@@ -127,8 +139,10 @@ type LambderRefusalViolation = {
127
139
  * the endpoint does not declare (neither
128
140
  * in its own `refusals` option nor through a guard), data on a refusal whose
129
141
  * code declares none, no data on one whose code carries data, data its code's
130
- * schema rejects or that is not an object or an array, or a declared code
131
- * raised with a status or flag of its own, which its declaration owns.
142
+ * schema rejects or that is not an object or an array, a framework code
143
+ * without the data it carries (`lambder/rate-limited`'s policy and wait), or
144
+ * a declared code raised with a status or flag of its own, which its
145
+ * declaration owns.
132
146
  *
133
147
  * What makes a client's refusal type exact: a reader narrows `code` to the
134
148
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -152,10 +166,12 @@ export declare class LambderApiRefusalValidationError extends Error {
152
166
  * here first.
153
167
  *
154
168
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
155
- * provided it carries no data. Where the app requires codes, an uncoded
156
- * refusal is refused too; a framework code still passes.
157
- * - A declared code leaves with its declaration's status and notAuthorized
158
- * flag, and with data exactly when the code carries data, parsed through
169
+ * provided it carries the data its code does: the policy and the seconds
170
+ * to wait for `lambder/rate-limited`, none for every other. Where the app
171
+ * requires codes, an uncoded refusal is refused too; a framework code
172
+ * still passes.
173
+ * - A declared code leaves with its declaration's status and flags, and
174
+ * with data exactly when the code carries data, parsed through
159
175
  * the code's schema as an output is: undeclared fields stripped, defaults
160
176
  * filled, transforms run. The parse is synchronous, so a data schema cannot
161
177
  * be async.
@@ -187,8 +203,8 @@ export declare const toRefusalCodes: (value: string | readonly string[] | undefi
187
203
  * The vocabulary, checked: every code a non-empty string outside the
188
204
  * framework's `lambder/` prefix, every declaration an object whose `data`,
189
205
  * when present, is a zod schema, whose `status`, when present, is one a
190
- * reader files as a refusal, and whose `notAuthorized`, when present, is
191
- * true. A Map, so a code named for something Object.prototype carries
206
+ * reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
207
+ * when present, are true. A Map, so a code named for something Object.prototype carries
192
208
  * ("toString") is still an ordinary code.
193
209
  */
194
210
  export declare const readRefusalVocabulary: (refusals: Record<string, LambderRefusalDeclaration> | undefined) => Map<string, LambderRefusalDeclaration> | null;
@@ -1,4 +1,4 @@
1
- import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
1
+ import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
2
2
  import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
3
3
  import { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
4
4
  /**
@@ -18,17 +18,31 @@ export const allowedRefusalOf = (declaration) => {
18
18
  const leaves = {
19
19
  ...(declaration.status !== undefined ? { status: declaration.status } : {}),
20
20
  ...(declaration.notAuthorized ? { notAuthorized: true } : {}),
21
+ ...(declaration.sessionExpired ? { sessionExpired: true } : {}),
21
22
  };
22
23
  return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
23
24
  };
25
+ /**
26
+ * Whether a `lambder/rate-limited` refusal's data is the shape a client
27
+ * narrows it to: the policy's name, and the whole seconds of at least one
28
+ * that the Retry-After header carries.
29
+ */
30
+ const isRateLimitRefusalData = (data) => {
31
+ if (data === null || typeof data !== "object")
32
+ return false;
33
+ const { policy, retryAfterSeconds } = data;
34
+ return typeof policy === "string" && Number.isInteger(retryAfterSeconds) && retryAfterSeconds >= 1;
35
+ };
24
36
  /**
25
37
  * A refusal an endpoint is not allowed to send, so it was not sent and the
26
38
  * call is answered as a crash: no code where the app requires one, a code
27
39
  * the endpoint does not declare (neither
28
40
  * in its own `refusals` option nor through a guard), data on a refusal whose
29
41
  * code declares none, no data on one whose code carries data, data its code's
30
- * schema rejects or that is not an object or an array, or a declared code
31
- * raised with a status or flag of its own, which its declaration owns.
42
+ * schema rejects or that is not an object or an array, a framework code
43
+ * without the data it carries (`lambder/rate-limited`'s policy and wait), or
44
+ * a declared code raised with a status or flag of its own, which its
45
+ * declaration owns.
32
46
  *
33
47
  * What makes a client's refusal type exact: a reader narrows `code` to the
34
48
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -63,7 +77,7 @@ export class LambderApiRefusalValidationError extends Error {
63
77
  }
64
78
  else if ("ownStatusOrFlag" in violation) {
65
79
  message = `Lambder: API "${apiName}" refused with the declared code "${String(code)}" and a status or flag of its own, so the refusal was not sent. `
66
- + "A declared code's status and notAuthorized flag are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
80
+ + "A declared code's status and its notAuthorized and sessionExpired flags are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
67
81
  }
68
82
  else if ("zodError" in violation) {
69
83
  // Paths and messages only, never the values: they may be the
@@ -75,6 +89,10 @@ export class LambderApiRefusalValidationError extends Error {
75
89
  message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema threw on while parsing it (a transform that threw, `
76
90
  + "or an async step: a refusal's data is parsed synchronously), so the refusal was not sent.";
77
91
  }
92
+ else if ("notRateLimitData" in violation) {
93
+ message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is not { policy, retryAfterSeconds }, so the refusal was not sent. `
94
+ + "Refuse a rate limit through ctx.rateLimit, or throw rateLimitRefusal(), which writes the data a client narrows it by.";
95
+ }
78
96
  else {
79
97
  message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is ${violation.notObject}, and a refusal's data is an object or an array, so the refusal was not sent.`;
80
98
  }
@@ -93,10 +111,12 @@ export class LambderApiRefusalValidationError extends Error {
93
111
  * here first.
94
112
  *
95
113
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
96
- * provided it carries no data. Where the app requires codes, an uncoded
97
- * refusal is refused too; a framework code still passes.
98
- * - A declared code leaves with its declaration's status and notAuthorized
99
- * flag, and with data exactly when the code carries data, parsed through
114
+ * provided it carries the data its code does: the policy and the seconds
115
+ * to wait for `lambder/rate-limited`, none for every other. Where the app
116
+ * requires codes, an uncoded refusal is refused too; a framework code
117
+ * still passes.
118
+ * - A declared code leaves with its declaration's status and flags, and
119
+ * with data exactly when the code carries data, parsed through
100
120
  * the code's schema as an output is: undeclared fields stripped, defaults
101
121
  * filled, transforms run. The parse is synchronous, so a data schema cannot
102
122
  * be async.
@@ -110,6 +130,13 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
110
130
  if (endpoint === undefined)
111
131
  return thrown;
112
132
  const { code, data } = thrown.refusal;
133
+ if (code === LAMBDER_REFUSAL_CODES.rateLimited) {
134
+ if (data === undefined)
135
+ throw new LambderApiRefusalValidationError(apiName, thrown, { missingData: code });
136
+ if (!isRateLimitRefusalData(data))
137
+ throw new LambderApiRefusalValidationError(apiName, thrown, { notRateLimitData: true });
138
+ return thrown;
139
+ }
113
140
  if (code === undefined || isLambderRefusalCode(code)) {
114
141
  if (code === undefined && endpoint.codeRequired)
115
142
  throw new LambderApiRefusalValidationError(apiName, thrown, { uncoded: true });
@@ -145,11 +172,12 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
145
172
  throw new LambderApiRefusalValidationError(apiName, thrown, { notObject: describePayloadKind(parsed.data) });
146
173
  sent = parsed.data;
147
174
  }
148
- // The declaration's status and flag, and the data as parsed: how every
175
+ // The declaration's status and flags, and the data as parsed: how every
149
176
  // refusal with this code leaves, wherever it was raised.
150
177
  return new LambderApiRefusal(thrown.message, {
151
178
  refusal: { ...thrown.refusal, ...(sent !== undefined ? { data: sent } : {}) },
152
179
  notAuthorized: declaration.notAuthorized,
180
+ sessionExpired: declaration.sessionExpired,
153
181
  statusCode: declaration.status,
154
182
  headers: thrown.headers,
155
183
  cause: thrown.cause,
@@ -195,8 +223,8 @@ export const toRefusalCodes = (value) => value === undefined ? [] : typeof value
195
223
  * The vocabulary, checked: every code a non-empty string outside the
196
224
  * framework's `lambder/` prefix, every declaration an object whose `data`,
197
225
  * when present, is a zod schema, whose `status`, when present, is one a
198
- * reader files as a refusal, and whose `notAuthorized`, when present, is
199
- * true. A Map, so a code named for something Object.prototype carries
226
+ * reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
227
+ * when present, are true. A Map, so a code named for something Object.prototype carries
200
228
  * ("toString") is still an ordinary code.
201
229
  */
202
230
  export const readRefusalVocabulary = (refusals) => {
@@ -215,9 +243,9 @@ export const readRefusalVocabulary = (refusals) => {
215
243
  if (declaration === null || typeof declaration !== "object") {
216
244
  throw new Error(`Lambder: the refusal "${code}" is not declared as an object: write {} for a code with no data, or { data: schema }.`);
217
245
  }
218
- const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized");
246
+ const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized" && key !== "sessionExpired");
219
247
  if (extra.length) {
220
- throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status and notAuthorized, which a refusal declaration does not take.`);
248
+ throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status, notAuthorized and sessionExpired, which a refusal declaration does not take.`);
221
249
  }
222
250
  // Read for what it is used as rather than by class, so a schema from
223
251
  // a second copy of zod in the bundle still counts.
@@ -228,8 +256,13 @@ export const readRefusalVocabulary = (refusals) => {
228
256
  if (status !== undefined && (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status >= 500 || status === 422)) {
229
257
  throw new Error(`Lambder: the refusal "${code}" declares the status ${String(status)}. A declared refusal leaves with a status from 200 to 499 other than 422: a reader files a 5xx as a server failure and a 422 as an input validation failure.`);
230
258
  }
231
- if (declaration.notAuthorized !== undefined && declaration.notAuthorized !== true) {
232
- throw new Error(`Lambder: the refusal "${code}" declares notAuthorized as ${String(declaration.notAuthorized)}; write true, or leave it off.`);
259
+ for (const flag of ["notAuthorized", "sessionExpired"]) {
260
+ if (declaration[flag] !== undefined && declaration[flag] !== true) {
261
+ throw new Error(`Lambder: the refusal "${code}" declares ${flag} as ${String(declaration[flag])}; write true, or leave it off.`);
262
+ }
263
+ }
264
+ if (declaration.notAuthorized && declaration.sessionExpired) {
265
+ throw new Error(`Lambder: the refusal "${code}" declares both notAuthorized and sessionExpired. A caller routes a refusal one way: a session that is gone is not also a session that is not allowed.`);
233
266
  }
234
267
  }
235
268
  return new Map(entries);
@@ -27,6 +27,13 @@ export type LambderApiRequest = {
27
27
  * expired, which reloads it.
28
28
  */
29
29
  retiredPath?: true;
30
+ /**
31
+ * Set when the call's body is no envelope: not a JSON object (a number,
32
+ * a string, a boolean, null, an array) or not JSON at all. The
33
+ * client-facing reason, which the pipeline answers with the
34
+ * invalid-payload refusal (prepare) before anything reads the call.
35
+ */
36
+ invalidEnvelope?: string;
30
37
  /** The caller's apiVersion, informational; null when it sent none. */
31
38
  version: string | null;
32
39
  /** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
@@ -91,10 +98,23 @@ export declare const isApiCallContentType: (lowercasedHeaders: Record<string, st
91
98
  * comes from where the call was posted, never from the body. Everything is
92
99
  * taken as posted: a malformed idempotencyKey or guardInputs value is the
93
100
  * engines' to refuse, with the client-facing message they already give.
101
+ *
102
+ * `posted` is the body as JSON parsed it; undefined (no body) reads as an
103
+ * empty envelope. A body that is not a JSON object, or one the adapter could
104
+ * not parse (`bodyNotJson`), is flagged on the request as invalidEnvelope
105
+ * and read as an empty envelope, so every adapter refuses it alike, in the
106
+ * pipeline, rather than each deciding what such a body means.
94
107
  */
95
- export declare const readApiEnvelope: (post: Record<string, unknown> | null | undefined, info: LambderApiRequestInfo, apiName: string, flags?: {
108
+ export declare const readApiEnvelope: (posted: unknown, info: LambderApiRequestInfo, apiName: string, flags?: {
96
109
  retiredPath?: true;
110
+ bodyNotJson?: true;
97
111
  }) => LambderApiRequest;
112
+ /**
113
+ * readApiEnvelope over a body's text, for an adapter that holds the text and
114
+ * nothing parsed from it: an empty body reads as an empty envelope, and text
115
+ * that is not JSON is flagged as no envelope, as the server flags it.
116
+ */
117
+ export declare const readApiEnvelopeText: (text: string, info: LambderApiRequestInfo, apiName: string) => LambderApiRequest;
98
118
  /** Outcome of restoring a compressed request payload; the message is client-facing. */
99
119
  export type LambderRestorePayloadResult = {
100
120
  ok: true;
@@ -42,20 +42,34 @@ export const lowercaseHeaderNames = (headers) => {
42
42
  * read the same rule here.
43
43
  */
44
44
  export const isApiCallContentType = (lowercasedHeaders) => (lowercasedHeaders["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
45
+ /** The one shape an API call's body may take: a JSON object, the envelope every Lambder caller posts. */
46
+ const isEnvelopeObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
47
+ /** Why a body that did parse is no envelope, client-facing: its kind, never its value. */
48
+ const notAnEnvelopeMessage = (posted) => `Request body must be a JSON object, got ${Array.isArray(posted) ? "an array" : posted === null ? "null" : `a ${typeof posted}`}.`;
45
49
  /**
46
50
  * Reads the posted envelope of a call to `apiName` into a request; the name
47
51
  * comes from where the call was posted, never from the body. Everything is
48
52
  * taken as posted: a malformed idempotencyKey or guardInputs value is the
49
53
  * engines' to refuse, with the client-facing message they already give.
54
+ *
55
+ * `posted` is the body as JSON parsed it; undefined (no body) reads as an
56
+ * empty envelope. A body that is not a JSON object, or one the adapter could
57
+ * not parse (`bodyNotJson`), is flagged on the request as invalidEnvelope
58
+ * and read as an empty envelope, so every adapter refuses it alike, in the
59
+ * pipeline, rather than each deciding what such a body means.
50
60
  */
51
- export const readApiEnvelope = (post, info, apiName, flags = {}) => {
52
- post ??= {};
61
+ export const readApiEnvelope = (posted, info, apiName, flags = {}) => {
62
+ const invalidEnvelope = flags.bodyNotJson ? "Request body must be a JSON object, and it is not valid JSON."
63
+ : posted === undefined || isEnvelopeObject(posted) ? null
64
+ : notAnEnvelopeMessage(posted);
65
+ const post = isEnvelopeObject(posted) ? posted : {};
53
66
  const hasGzip = post[COMPRESSED_PAYLOAD_GZ_FIELD] !== undefined;
54
67
  const hasBrotli = post[COMPRESSED_PAYLOAD_BR_FIELD] !== undefined;
55
68
  const guardInputs = post.guardInputs;
56
69
  return {
57
70
  apiName,
58
71
  ...(flags.retiredPath ? { retiredPath: true } : {}),
72
+ ...(invalidEnvelope !== null ? { invalidEnvelope } : {}),
59
73
  version: typeof post.version === "string" ? post.version : null,
60
74
  signature: typeof post.signature === "string" ? post.signature : null,
61
75
  token: typeof post.token === "string" ? post.token : "",
@@ -76,6 +90,21 @@ export const readApiEnvelope = (post, info, apiName, flags = {}) => {
76
90
  ...(info.signal ? { signal: info.signal } : {}),
77
91
  };
78
92
  };
93
+ /**
94
+ * readApiEnvelope over a body's text, for an adapter that holds the text and
95
+ * nothing parsed from it: an empty body reads as an empty envelope, and text
96
+ * that is not JSON is flagged as no envelope, as the server flags it.
97
+ */
98
+ export const readApiEnvelopeText = (text, info, apiName) => {
99
+ let posted;
100
+ try {
101
+ posted = JSON.parse(text || "{}");
102
+ }
103
+ catch {
104
+ return readApiEnvelope(undefined, info, apiName, { bodyNotJson: true });
105
+ }
106
+ return readApiEnvelope(posted, info, apiName);
107
+ };
79
108
  /**
80
109
  * Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
81
110
  * beside `payloadBytes`) onto request.payload, so every later stage