lambder 14.1.1 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,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
  }
@@ -130,6 +130,8 @@ type LambderRefusalViolation = {
130
130
  thrown: unknown;
131
131
  } | {
132
132
  notObject: string;
133
+ } | {
134
+ notRateLimitData: true;
133
135
  };
134
136
  /**
135
137
  * A refusal an endpoint is not allowed to send, so it was not sent and the
@@ -137,8 +139,10 @@ type LambderRefusalViolation = {
137
139
  * the endpoint does not declare (neither
138
140
  * in its own `refusals` option nor through a guard), data on a refusal whose
139
141
  * code declares none, no data on one whose code carries data, data its code's
140
- * schema rejects or that is not an object or an array, or a declared code
141
- * 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.
142
146
  *
143
147
  * What makes a client's refusal type exact: a reader narrows `code` to the
144
148
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -162,8 +166,10 @@ export declare class LambderApiRefusalValidationError extends Error {
162
166
  * here first.
163
167
  *
164
168
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
165
- * provided it carries no data. Where the app requires codes, an uncoded
166
- * refusal is refused too; a framework code still passes.
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.
167
173
  * - A declared code leaves with its declaration's status and flags, and
168
174
  * with data exactly when the code carries data, parsed through
169
175
  * the code's schema as an output is: undeclared fields stripped, defaults
@@ -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
  /**
@@ -22,14 +22,27 @@ export const allowedRefusalOf = (declaration) => {
22
22
  };
23
23
  return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
24
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
+ };
25
36
  /**
26
37
  * A refusal an endpoint is not allowed to send, so it was not sent and the
27
38
  * call is answered as a crash: no code where the app requires one, a code
28
39
  * the endpoint does not declare (neither
29
40
  * in its own `refusals` option nor through a guard), data on a refusal whose
30
41
  * code declares none, no data on one whose code carries data, data its code's
31
- * schema rejects or that is not an object or an array, or a declared code
32
- * 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.
33
46
  *
34
47
  * What makes a client's refusal type exact: a reader narrows `code` to the
35
48
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -76,6 +89,10 @@ export class LambderApiRefusalValidationError extends Error {
76
89
  message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema threw on while parsing it (a transform that threw, `
77
90
  + "or an async step: a refusal's data is parsed synchronously), so the refusal was not sent.";
78
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
+ }
79
96
  else {
80
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.`;
81
98
  }
@@ -94,8 +111,10 @@ export class LambderApiRefusalValidationError extends Error {
94
111
  * here first.
95
112
  *
96
113
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
97
- * provided it carries no data. Where the app requires codes, an uncoded
98
- * refusal is refused too; a framework code still passes.
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.
99
118
  * - A declared code leaves with its declaration's status and flags, and
100
119
  * with data exactly when the code carries data, parsed through
101
120
  * the code's schema as an output is: undeclared fields stripped, defaults
@@ -111,6 +130,13 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
111
130
  if (endpoint === undefined)
112
131
  return thrown;
113
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
+ }
114
140
  if (code === undefined || isLambderRefusalCode(code)) {
115
141
  if (code === undefined && endpoint.codeRequired)
116
142
  throw new LambderApiRefusalValidationError(apiName, thrown, { uncoded: true });
@@ -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
@@ -5,6 +5,8 @@ export type LambderApiRefusalCheckOptions = {
5
5
  files?: string[];
6
6
  /** Whether a refusal with no code is a finding, as it is a crash in an app whose vocabulary requires codes (declareRefusals's requireCodes). Default: true. */
7
7
  requireCodes?: boolean;
8
+ /** Whether a handler handed no typed refuse is a finding, since nothing it reaches could be checked. Default: true; false lists such handlers without failing. */
9
+ requireTypedRefuse?: boolean;
8
10
  };
9
11
  /** What one handler's check found. */
10
12
  export type LambderRefusalCheckFinding = {
@@ -14,21 +16,24 @@ export type LambderRefusalCheckFinding = {
14
16
  at: string;
15
17
  /**
16
18
  * - `undeclared`: a code the handler can reach that it may not send.
17
- * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises.
19
+ * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises. Never a mock guard's, whose codes are its server guard's.
18
20
  * - `uncoded`: a refusal with no code it can reach, where codes are required.
19
21
  * - `unreadable`: a refusal it can reach whose code is not a string literal type.
20
22
  * - `untraced`: a handler handed a typed refuse whose function cannot be
21
23
  * found (a parameter, a dependency's value), so nothing it reaches was
22
24
  * checked.
25
+ * - `unchecked`: a handler handed no typed refuse, built by an init that
26
+ * declared no refusal vocabulary, so there is nothing to hold what it
27
+ * reaches to. Not a finding with `requireTypedRefuse: false`.
23
28
  */
24
- problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced";
29
+ problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced" | "unchecked";
25
30
  /** The code, for `undeclared` and `unused`. */
26
31
  code?: string;
27
32
  /** Where the refusal is raised, as `file:line`, for `undeclared`, `uncoded` and `unreadable`. */
28
33
  raisedAt?: string;
29
34
  };
30
35
  export type LambderApiRefusalCheckResult = {
31
- /** False when the project could not be read, no handler in it could be checked, or any handler has a finding. */
36
+ /** False when the project could not be read, no handler in it could be checked, or any handler has a finding, an unchecked one included unless `requireTypedRefuse` is false. */
32
37
  ok: boolean;
33
38
  /** How many handlers were checked. */
34
39
  handlers: number;
@@ -27,6 +27,7 @@ const HANDLER_REGISTRATIONS = {
27
27
  export const checkApiRefusals = async (options) => {
28
28
  const ts = await loadTypeScriptCompiler("checkApiRefusals reads the project");
29
29
  const requireCodes = options.requireCodes ?? true;
30
+ const requireTypedRefuse = options.requireTypedRefuse ?? true;
30
31
  const configPath = resolve(options.tsconfig);
31
32
  const read = ts.readConfigFile(configPath, ts.sys.readFile);
32
33
  if (read.error)
@@ -66,6 +67,24 @@ export const checkApiRefusals = async (options) => {
66
67
  return true;
67
68
  return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
68
69
  };
70
+ /**
71
+ * Whether a registration is a mock guard: `guard` read off a mock init,
72
+ * whose builder Lambder declares in its mock entry, where a server init's
73
+ * is declared in its own.
74
+ */
75
+ const isMockGuard = (call, registration) => {
76
+ if (registration !== "guard" || !ts.isPropertyAccessExpression(call.expression))
77
+ return false;
78
+ const declaration = checker.getSymbolAtLocation(call.expression.name)?.declarations?.[0];
79
+ if (!declaration || !isLambderDeclaration(declaration))
80
+ return false;
81
+ const path = realPathOf(ts, declaration.getSourceFile().fileName);
82
+ const parts = path.split(sep);
83
+ const packageAt = parts.findIndex((part, index) => part === "lambder" && parts[index - 1] === "node_modules");
84
+ // The path inside the package: dist/mock/... installed, src/mock/... over its own sources.
85
+ const inside = packageAt >= 0 ? parts.slice(packageAt + 1) : relative(lambderRoot, path).split(sep);
86
+ return inside[1] === "mock";
87
+ };
69
88
  const where = (node) => {
70
89
  const sourceFile = node.getSourceFile();
71
90
  const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
@@ -106,27 +125,42 @@ export const checkApiRefusals = async (options) => {
106
125
  return [{ at, unreadable: true }];
107
126
  return values.map((value) => ({ at, code: value }));
108
127
  };
128
+ /**
129
+ * Whether a signature's declaration (one of Lambder's) is a refuse
130
+ * function: a function type in LambderDeclaredRefuse, which types every
131
+ * context's, guard's and init's refuse, or the type of something Lambder
132
+ * declares as `refuse`, the free refuse() and the members an emitted
133
+ * declaration file spells out in place of the alias. Read off the
134
+ * declaration rather than off the call, so a refuse the app reaches under
135
+ * a name of its own (an import renamed, a context's refuse held in a
136
+ * variable or destructured under another name, a parameter typed as one)
137
+ * is a raise all the same.
138
+ */
139
+ const isRefuseDeclaration = (declaration) => {
140
+ let node = declaration;
141
+ while (ts.isTypeNode(node.parent))
142
+ node = node.parent;
143
+ const holder = node.parent;
144
+ if (ts.isTypeAliasDeclaration(holder))
145
+ return holder.name.text === "LambderDeclaredRefuse";
146
+ return (ts.isVariableDeclaration(holder) || ts.isPropertySignature(holder) || ts.isPropertyDeclaration(holder))
147
+ && holder.type === node && ts.isIdentifier(holder.name) && holder.name.text === "refuse";
148
+ };
109
149
  /**
110
150
  * The refusal a call or construction raises, when it is one of Lambder's
111
- * refuse functions or its refusal class's constructor. The constructor is
112
- * told by the signature the call resolves to rather than by the name
113
- * written, so it counts constructed as itself, as a class of the app's
114
- * own that declares no constructor and inherits it, or as the super() of
115
- * one that declares its own, which is then raised wherever that class is
116
- * constructed.
151
+ * refuse functions or its refusal class's constructor. Both are told by
152
+ * the signature the call resolves to rather than by the name written, so
153
+ * a refuse counts however the app names it, and the constructor counts
154
+ * constructed as itself, as a class of the app's own that declares no
155
+ * constructor and inherits it, or as the super() of one that declares
156
+ * its own, which is then raised wherever that class is constructed.
117
157
  */
118
158
  const raiseSitesOf = (node) => {
119
- const callee = node.expression;
120
- const constructs = ts.isNewExpression(node) || callee.kind === ts.SyntaxKind.SuperKeyword;
121
- if (!constructs) {
122
- const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
123
- if (name !== "refuse")
124
- return [];
125
- }
159
+ const constructs = ts.isNewExpression(node) || node.expression.kind === ts.SyntaxKind.SuperKeyword;
126
160
  const declaration = checker.getResolvedSignature(node)?.declaration;
127
- if (!isLambderDeclaration(declaration))
161
+ if (!declaration || !isLambderDeclaration(declaration))
128
162
  return [];
129
- if (constructs && !(declaration && ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal"))
163
+ if (constructs ? !(ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal") : !isRefuseDeclaration(declaration))
130
164
  return [];
131
165
  const args = node.arguments ?? ts.factory.createNodeArray();
132
166
  return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
@@ -269,14 +303,20 @@ export const checkApiRefusals = async (options) => {
269
303
  }
270
304
  return [...codes];
271
305
  };
272
- /** The codes an options object's `refusals` property names: one code or a list, each a literal or a constant typed as one. */
306
+ /**
307
+ * The codes an options object's `refusals` property names: one code or a
308
+ * list, each a literal or a constant typed as one. Null where it has no
309
+ * such property, which for a guard built without the vocabulary is the
310
+ * difference between declaring no codes (`refusals: []`) and saying
311
+ * nothing of what it may send.
312
+ */
273
313
  const ownRefusalsOf = (optionsNode) => {
274
314
  const options = optionsNode && unwrap(optionsNode);
275
315
  if (!options || !ts.isObjectLiteralExpression(options))
276
- return [];
316
+ return null;
277
317
  const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
278
318
  if (!property || !ts.isPropertyAssignment(property))
279
- return [];
319
+ return null;
280
320
  const value = unwrap(property.initializer);
281
321
  const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
282
322
  return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
@@ -334,9 +374,11 @@ export const checkApiRefusals = async (options) => {
334
374
  return;
335
375
  const name = handlerNameOf(call, registration);
336
376
  const at = where(call);
337
- const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOfOrNull(optionsNode) : null);
377
+ // A guard built without the vocabulary may send the codes its own
378
+ // refusals option names, which is what the server checks it against.
379
+ const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOf(optionsNode) : null);
338
380
  if (allowed === null) {
339
- unchecked.push(`${name} (${at})`);
381
+ unchecked.push({ handler: name, at, problem: "unchecked" });
340
382
  return;
341
383
  }
342
384
  handlers += 1;
@@ -366,16 +408,17 @@ export const checkApiRefusals = async (options) => {
366
408
  findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
367
409
  }
368
410
  }
369
- for (const code of ownRefusalsOf(optionsNode)) {
411
+ // A mock guard declares its server guard's codes, which create() holds
412
+ // it to against guardDeclarations. Standing in for that guard it may
413
+ // raise fewer of them, which only lets more through, so a code it
414
+ // never raises is no declaration to trim.
415
+ if (isMockGuard(call, registration))
416
+ return;
417
+ for (const code of ownRefusalsOf(optionsNode) ?? []) {
370
418
  if (!reached.has(code))
371
419
  findings.push({ handler: name, at, problem: "unused", code });
372
420
  }
373
421
  };
374
- /** A guard's `refusals` option, or null when it has none: what a guard built without the vocabulary may send. */
375
- const ownRefusalsOfOrNull = (optionsNode) => {
376
- const own = ownRefusalsOf(optionsNode);
377
- return own.length ? own : null;
378
- };
379
422
  for (const sourceFile of program.getSourceFiles()) {
380
423
  if (!isOwnFile(sourceFile))
381
424
  continue;
@@ -386,16 +429,23 @@ export const checkApiRefusals = async (options) => {
386
429
  };
387
430
  visit(sourceFile);
388
431
  }
389
- const uncheckedLine = unchecked.length ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.join(", ")}`] : [];
432
+ // Found, or only listed where the caller lets them stand unchecked.
433
+ if (requireTypedRefuse)
434
+ findings.push(...unchecked);
435
+ const uncheckedLine = !requireTypedRefuse && unchecked.length
436
+ ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.map(({ handler, at }) => `${handler} (${at})`).join(", ")}`]
437
+ : [];
390
438
  if (handlers === 0) {
391
- return failed(`✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`, uncheckedLine);
439
+ const nothingChecked = `✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`;
440
+ return { ...failed(nothingChecked, [...findings.map(describeFinding), ...uncheckedLine]), findings };
392
441
  }
393
442
  // Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
394
443
  const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
395
444
  const lines = [
396
445
  unique.length === 0
397
446
  ? `✓ ${handlers} handlers reach only the refusals they may send`
398
- : `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers} handlers`,
447
+ // Out of every handler found, the unchecked ones among them, which are never counted as checked.
448
+ : `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers + unchecked.length} handlers`,
399
449
  ...unique.map(describeFinding),
400
450
  ...uncheckedLine,
401
451
  ];
@@ -408,6 +458,7 @@ const describeFinding = (finding) => {
408
458
  case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
409
459
  case "unreadable": return ` ${finding.handler} (${finding.at}) can reach a refusal whose code is not a string literal (${finding.raisedAt}), so which code it sends cannot be told`;
410
460
  case "untraced": return ` ${finding.handler} (${finding.at}) is handed a typed refuse, but its function cannot be found (a parameter, a dependency's value), so nothing it reaches was checked`;
461
+ case "unchecked": return ` ${finding.handler} (${finding.at}) is handed no typed refuse, so nothing it reaches could be checked: build it from an init that declares the refusal vocabulary (declareRefusals, on initLambderMock() as on initLambder()), or give a guard a refusals option naming the codes it may send`;
411
462
  }
412
463
  };
413
464
  const failed = (line, details = []) => ({ ok: false, handlers: 0, findings: [], lines: [line, ...details] });
@@ -0,0 +1,11 @@
1
+ import { type LambderApiContractFileOptions, type LambderApiContractFileResult } from "./writeApiContract.js";
2
+ /** writeApiContract's options as they cross the process boundary: the module as a string, a URL by its href. */
3
+ export type ContractPrinterRequest = Omit<LambderApiContractFileOptions, "module"> & {
4
+ module: string;
5
+ };
6
+ /** The result, or the messages of what was thrown, outermost first, down its chain of causes. */
7
+ export type ContractPrinterReport = {
8
+ result: LambderApiContractFileResult;
9
+ } | {
10
+ thrown: string[];
11
+ };