lambder 14.1.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.
- package/CHANGELOG.md +407 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +7 -2
- package/dist/build/checkApiRefusals.js +56 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- 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
|
-
/**
|
|
11
|
-
export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning",
|
|
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,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* indistinguishable from a real
|
|
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
|
-
*
|
|
20
|
-
* (a plain-JS caller, a cast) cannot replace
|
|
21
|
-
* an endpoint declares, and the pipeline
|
|
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,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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:
|
|
296
|
-
*
|
|
297
|
-
* failure the limiter throws again (see
|
|
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
|
|
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
|
|
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
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
141
|
-
*
|
|
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
|
|
166
|
-
*
|
|
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,
|
|
32
|
-
*
|
|
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
|
|
98
|
-
*
|
|
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: (
|
|
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 = (
|
|
52
|
-
|
|
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 = {
|
|
@@ -20,15 +22,18 @@ export type LambderRefusalCheckFinding = {
|
|
|
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)
|
|
@@ -106,27 +107,42 @@ export const checkApiRefusals = async (options) => {
|
|
|
106
107
|
return [{ at, unreadable: true }];
|
|
107
108
|
return values.map((value) => ({ at, code: value }));
|
|
108
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Whether a signature's declaration (one of Lambder's) is a refuse
|
|
112
|
+
* function: a function type in LambderDeclaredRefuse, which types every
|
|
113
|
+
* context's, guard's and init's refuse, or the type of something Lambder
|
|
114
|
+
* declares as `refuse`, the free refuse() and the members an emitted
|
|
115
|
+
* declaration file spells out in place of the alias. Read off the
|
|
116
|
+
* declaration rather than off the call, so a refuse the app reaches under
|
|
117
|
+
* a name of its own (an import renamed, a context's refuse held in a
|
|
118
|
+
* variable or destructured under another name, a parameter typed as one)
|
|
119
|
+
* is a raise all the same.
|
|
120
|
+
*/
|
|
121
|
+
const isRefuseDeclaration = (declaration) => {
|
|
122
|
+
let node = declaration;
|
|
123
|
+
while (ts.isTypeNode(node.parent))
|
|
124
|
+
node = node.parent;
|
|
125
|
+
const holder = node.parent;
|
|
126
|
+
if (ts.isTypeAliasDeclaration(holder))
|
|
127
|
+
return holder.name.text === "LambderDeclaredRefuse";
|
|
128
|
+
return (ts.isVariableDeclaration(holder) || ts.isPropertySignature(holder) || ts.isPropertyDeclaration(holder))
|
|
129
|
+
&& holder.type === node && ts.isIdentifier(holder.name) && holder.name.text === "refuse";
|
|
130
|
+
};
|
|
109
131
|
/**
|
|
110
132
|
* The refusal a call or construction raises, when it is one of Lambder's
|
|
111
|
-
* refuse functions or its refusal class's constructor.
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* constructed.
|
|
133
|
+
* refuse functions or its refusal class's constructor. Both are told by
|
|
134
|
+
* the signature the call resolves to rather than by the name written, so
|
|
135
|
+
* a refuse counts however the app names it, and the constructor counts
|
|
136
|
+
* constructed as itself, as a class of the app's own that declares no
|
|
137
|
+
* constructor and inherits it, or as the super() of one that declares
|
|
138
|
+
* its own, which is then raised wherever that class is constructed.
|
|
117
139
|
*/
|
|
118
140
|
const raiseSitesOf = (node) => {
|
|
119
|
-
const
|
|
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
|
-
}
|
|
141
|
+
const constructs = ts.isNewExpression(node) || node.expression.kind === ts.SyntaxKind.SuperKeyword;
|
|
126
142
|
const declaration = checker.getResolvedSignature(node)?.declaration;
|
|
127
|
-
if (!isLambderDeclaration(declaration))
|
|
143
|
+
if (!declaration || !isLambderDeclaration(declaration))
|
|
128
144
|
return [];
|
|
129
|
-
if (constructs
|
|
145
|
+
if (constructs ? !(ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal") : !isRefuseDeclaration(declaration))
|
|
130
146
|
return [];
|
|
131
147
|
const args = node.arguments ?? ts.factory.createNodeArray();
|
|
132
148
|
return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
|
|
@@ -269,14 +285,20 @@ export const checkApiRefusals = async (options) => {
|
|
|
269
285
|
}
|
|
270
286
|
return [...codes];
|
|
271
287
|
};
|
|
272
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* The codes an options object's `refusals` property names: one code or a
|
|
290
|
+
* list, each a literal or a constant typed as one. Null where it has no
|
|
291
|
+
* such property, which for a guard built without the vocabulary is the
|
|
292
|
+
* difference between declaring no codes (`refusals: []`) and saying
|
|
293
|
+
* nothing of what it may send.
|
|
294
|
+
*/
|
|
273
295
|
const ownRefusalsOf = (optionsNode) => {
|
|
274
296
|
const options = optionsNode && unwrap(optionsNode);
|
|
275
297
|
if (!options || !ts.isObjectLiteralExpression(options))
|
|
276
|
-
return
|
|
298
|
+
return null;
|
|
277
299
|
const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
|
|
278
300
|
if (!property || !ts.isPropertyAssignment(property))
|
|
279
|
-
return
|
|
301
|
+
return null;
|
|
280
302
|
const value = unwrap(property.initializer);
|
|
281
303
|
const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
|
|
282
304
|
return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
|
|
@@ -334,9 +356,11 @@ export const checkApiRefusals = async (options) => {
|
|
|
334
356
|
return;
|
|
335
357
|
const name = handlerNameOf(call, registration);
|
|
336
358
|
const at = where(call);
|
|
337
|
-
|
|
359
|
+
// A guard built without the vocabulary may send the codes its own
|
|
360
|
+
// refusals option names, which is what the server checks it against.
|
|
361
|
+
const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOf(optionsNode) : null);
|
|
338
362
|
if (allowed === null) {
|
|
339
|
-
unchecked.push(
|
|
363
|
+
unchecked.push({ handler: name, at, problem: "unchecked" });
|
|
340
364
|
return;
|
|
341
365
|
}
|
|
342
366
|
handlers += 1;
|
|
@@ -366,16 +390,11 @@ export const checkApiRefusals = async (options) => {
|
|
|
366
390
|
findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
|
|
367
391
|
}
|
|
368
392
|
}
|
|
369
|
-
for (const code of ownRefusalsOf(optionsNode)) {
|
|
393
|
+
for (const code of ownRefusalsOf(optionsNode) ?? []) {
|
|
370
394
|
if (!reached.has(code))
|
|
371
395
|
findings.push({ handler: name, at, problem: "unused", code });
|
|
372
396
|
}
|
|
373
397
|
};
|
|
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
398
|
for (const sourceFile of program.getSourceFiles()) {
|
|
380
399
|
if (!isOwnFile(sourceFile))
|
|
381
400
|
continue;
|
|
@@ -386,16 +405,23 @@ export const checkApiRefusals = async (options) => {
|
|
|
386
405
|
};
|
|
387
406
|
visit(sourceFile);
|
|
388
407
|
}
|
|
389
|
-
|
|
408
|
+
// Found, or only listed where the caller lets them stand unchecked.
|
|
409
|
+
if (requireTypedRefuse)
|
|
410
|
+
findings.push(...unchecked);
|
|
411
|
+
const uncheckedLine = !requireTypedRefuse && unchecked.length
|
|
412
|
+
? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.map(({ handler, at }) => `${handler} (${at})`).join(", ")}`]
|
|
413
|
+
: [];
|
|
390
414
|
if (handlers === 0) {
|
|
391
|
-
|
|
415
|
+
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?`;
|
|
416
|
+
return { ...failed(nothingChecked, [...findings.map(describeFinding), ...uncheckedLine]), findings };
|
|
392
417
|
}
|
|
393
418
|
// Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
|
|
394
419
|
const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
|
|
395
420
|
const lines = [
|
|
396
421
|
unique.length === 0
|
|
397
422
|
? `✓ ${handlers} handlers reach only the refusals they may send`
|
|
398
|
-
|
|
423
|
+
// Out of every handler found, the unchecked ones among them, which are never counted as checked.
|
|
424
|
+
: `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers + unchecked.length} handlers`,
|
|
399
425
|
...unique.map(describeFinding),
|
|
400
426
|
...uncheckedLine,
|
|
401
427
|
];
|
|
@@ -408,6 +434,7 @@ const describeFinding = (finding) => {
|
|
|
408
434
|
case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
|
|
409
435
|
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
436
|
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`;
|
|
437
|
+
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
438
|
}
|
|
412
439
|
};
|
|
413
440
|
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
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CONTRACT_REPORT_PREFIX } from "./generateApiFiles.js";
|
|
2
|
+
import { writeApiContract } from "./writeApiContract.js";
|
|
3
|
+
const request = JSON.parse(process.argv[2] ?? "null");
|
|
4
|
+
let report;
|
|
5
|
+
try {
|
|
6
|
+
report = { result: await writeApiContract(request) };
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
const thrown = err instanceof Error ? [] : [String(err)];
|
|
10
|
+
for (let cause = err; cause instanceof Error && thrown.length < 8; cause = cause.cause)
|
|
11
|
+
thrown.push(cause.message);
|
|
12
|
+
report = { thrown };
|
|
13
|
+
}
|
|
14
|
+
// Waited for: a write to a pipe can still be pending when the process exits,
|
|
15
|
+
// and the parent reads the pipe.
|
|
16
|
+
await new Promise((resolveWrite) => process.stdout.write(`\n${CONTRACT_REPORT_PREFIX}${JSON.stringify(report)}\n`, () => resolveWrite()));
|
|
17
|
+
process.exit(0);
|