lambder 14.0.1 → 15.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +450 -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 +47 -13
- package/dist/api/LambderApiPipeline.js +62 -20
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +26 -10
- package/dist/api/LambderApiRefusals.js +48 -15
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/api/LambderApiSignature.d.ts +5 -5
- package/dist/api/LambderApiSignature.js +7 -5
- package/dist/build/checkApiRefusals.d.ts +59 -0
- package/dist/build/checkApiRefusals.js +442 -0
- 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/loadTypeScriptCompiler.d.ts +10 -0
- package/dist/build/loadTypeScriptCompiler.js +20 -0
- package/dist/build/writeApiContract.js +21 -15
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +8 -3
- package/dist/build.js +6 -2
- 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 +37 -26
- package/dist/core/Lambder.js +89 -32
- 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 +41 -14
- 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,31 +1,33 @@
|
|
|
1
1
|
import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
|
|
2
2
|
import type { LambderRateLimitMessage, LambderRateLimitOptionValue, LambderRateLimitOverride } from "../shared/wire/LambderApiOptionValues.js";
|
|
3
|
+
import type { LambderKeyFieldDigest } from "../shared/util/LambderKeyFieldDigest.js";
|
|
3
4
|
import type { z } from "zod";
|
|
4
5
|
import type { LambderApiRequest } from "./LambderApiRequest.js";
|
|
5
6
|
import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
6
7
|
import { type LambderRateLimiter, type LambderRateLimitExceeded, type LambderRateLimitPolicy } from "../shared/contracts/LambderRateLimiter.js";
|
|
7
8
|
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
8
|
-
import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
9
|
+
import { LambderApiRefusal, type LambderRateLimitRefusalData } from "../shared/wire/LambderApiRefusal.js";
|
|
9
10
|
import type { LambderNonEmptyOptionMap } from "../shared/util/LambderTypeUtilities.js";
|
|
10
11
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
11
|
-
/**
|
|
12
|
+
/** The words a rate-limited call answers with unless the policy or the API's override writes its own (the `refusal` option of each). */
|
|
12
13
|
export declare const DEFAULT_RATE_LIMIT_REFUSAL: {
|
|
13
14
|
type: "warning";
|
|
14
|
-
code: "lambder/rate-limited";
|
|
15
15
|
content: string;
|
|
16
16
|
};
|
|
17
17
|
/**
|
|
18
18
|
* The refusal a rate-limited call answers with: a 429 envelope carrying the
|
|
19
|
-
* framework code,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* indistinguishable from a real
|
|
19
|
+
* framework code, the words a policy or an API wrote, the policy's name and
|
|
20
|
+
* the seconds to wait as its data, and a Retry-After header of those same
|
|
21
|
+
* seconds. The engine throws it; the mock runtime's failure injection throws
|
|
22
|
+
* the same one, so an injected rate limit is indistinguishable from a real
|
|
23
|
+
* one.
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
* (a plain-JS caller, a cast) cannot replace
|
|
26
|
-
* an endpoint declares, and the pipeline
|
|
25
|
+
* Only the words are read off the message, so one that carries a code or
|
|
26
|
+
* data anyway (a plain-JS caller, a cast) cannot replace them: a rate limit
|
|
27
|
+
* is never a code an endpoint declares, and the pipeline sends one only with
|
|
28
|
+
* the data a client narrows it by.
|
|
27
29
|
*/
|
|
28
|
-
export declare const rateLimitRefusal: (detail: string,
|
|
30
|
+
export declare const rateLimitRefusal: (detail: string, data: LambderRateLimitRefusalData, message?: LambderRateLimitMessage) => LambderApiRefusal;
|
|
29
31
|
/**
|
|
30
32
|
* A custom rate-limit key. `apiInput` names the fields of the API's OWN
|
|
31
33
|
* payload the key derives from: that slice is validated against the raw
|
|
@@ -110,7 +112,7 @@ export type LambderApiRateLimitPolicyConfig<TCtx = any> = LambderRateLimitPolicy
|
|
|
110
112
|
* takes it: `per: "ip"` and `per: "session"` have one place each.
|
|
111
113
|
*/
|
|
112
114
|
chargeAt?: LambderRateLimitChargeAt;
|
|
113
|
-
/** The refusal's
|
|
115
|
+
/** The refusal's words for refused requests: its type, title and content, under the code "lambder/rate-limited" and beside the data that names this policy to the client. Default: a warning saying too many requests. */
|
|
114
116
|
refusal?: LambderRateLimitMessage;
|
|
115
117
|
};
|
|
116
118
|
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig<any>>> = {
|
|
@@ -276,8 +278,13 @@ type LambderRateLimitPhase = "beforeSession" | LambderRateLimitChargeAt;
|
|
|
276
278
|
* slice) and hands the context to a custom key's handler, reading nothing
|
|
277
279
|
* of the context itself but its session, so it runs unchanged under the
|
|
278
280
|
* server and the mock runtime.
|
|
281
|
+
*
|
|
282
|
+
* The caller's half of every tracker key (the address, the session key, a
|
|
283
|
+
* custom key) reaches the limiter as a digest (LambderKeyFieldDigest), so a
|
|
284
|
+
* table read names the API and the policy and never who was counted.
|
|
279
285
|
*/
|
|
280
286
|
export declare class LambderApiRateLimitsEngine {
|
|
287
|
+
private readonly keyFieldDigest;
|
|
281
288
|
private limiter;
|
|
282
289
|
private failOpen;
|
|
283
290
|
private ipv6PrefixLength;
|
|
@@ -289,6 +296,7 @@ export declare class LambderApiRateLimitsEngine {
|
|
|
289
296
|
* second would otherwise be as many identical log lines.
|
|
290
297
|
*/
|
|
291
298
|
private readonly loggedFailures;
|
|
299
|
+
constructor(keyFieldDigest: LambderKeyFieldDigest);
|
|
292
300
|
/** True once rateLimits were configured. */
|
|
293
301
|
get isConfigured(): boolean;
|
|
294
302
|
configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
@@ -318,7 +326,7 @@ export declare class LambderApiRateLimitsEngine {
|
|
|
318
326
|
/**
|
|
319
327
|
* One policy charged by code rather than by a declaration, for
|
|
320
328
|
* `ctx.rateLimit` and `ctx.isRateLimited`. Counters, failOpen, key
|
|
321
|
-
*
|
|
329
|
+
* digests and refusal are those of a declared limit; only who knows the
|
|
322
330
|
* key differs. A policy without `per` takes the key the code passes, a
|
|
323
331
|
* `per: "ip"` or `per: "session"` one reads it off the request, and one
|
|
324
332
|
* keyed by an API payload is refused, since only its APIs know the key.
|
|
@@ -334,20 +342,19 @@ export declare class LambderApiRateLimitsEngine {
|
|
|
334
342
|
/**
|
|
335
343
|
* Counts one attempt, or lets it through when the limiter itself fails
|
|
336
344
|
* and failOpen is on. The log line names the policy and its windows,
|
|
337
|
-
* never the tracker key:
|
|
338
|
-
*
|
|
339
|
-
* failure the limiter throws again (see
|
|
340
|
-
* again.
|
|
345
|
+
* never the tracker key: digested as its caller's half is, the key is
|
|
346
|
+
* still the same for every attempt of one caller, and a log is read by
|
|
347
|
+
* more people than the table. A failure the limiter throws again (see
|
|
348
|
+
* loggedFailures) is not logged again.
|
|
341
349
|
*/
|
|
342
350
|
private countAttempt;
|
|
343
351
|
private chargeKeyOf;
|
|
344
352
|
private resolveKey;
|
|
345
353
|
/**
|
|
346
354
|
* The key a `per: "ip"` or `per: "session"` policy counts under: what the
|
|
347
|
-
* request carries, however the policy is charged
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* header or gateway field named it.
|
|
355
|
+
* request carries, however the policy is charged, digested as a custom
|
|
356
|
+
* key is. The address is the subject rateLimitSubjectOf counts (an IPv6
|
|
357
|
+
* caller's /64), so the digest of one subscriber's every address is one.
|
|
351
358
|
*/
|
|
352
359
|
private requestKeyOf;
|
|
353
360
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
2
|
-
import { boundKeyField } from "../shared/util/boundKeyField.js";
|
|
3
2
|
import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
4
3
|
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
5
4
|
import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
|
|
@@ -7,29 +6,43 @@ import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js"
|
|
|
7
6
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
8
7
|
import { DEFAULT_IPV6_RATE_LIMIT_PREFIX, rateLimitSubjectOf } from "../shared/util/LambderClientIp.js";
|
|
9
8
|
const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
10
|
-
/**
|
|
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
|
}
|
|
@@ -8,7 +8,7 @@ import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderN
|
|
|
8
8
|
* One code of an app's refusal vocabulary: the schema of the data it carries,
|
|
9
9
|
* when it carries data, and how every refusal with the code leaves the
|
|
10
10
|
* server. A code's data is an object or an array, as an API's output is. The
|
|
11
|
-
* status and the
|
|
11
|
+
* status and the flags are declared once here rather than at each raise site,
|
|
12
12
|
* so a code always reaches a caller the same way.
|
|
13
13
|
*/
|
|
14
14
|
export type LambderRefusalDeclaration = {
|
|
@@ -18,6 +18,14 @@ export type LambderRefusalDeclaration = {
|
|
|
18
18
|
status?: LambderRefusalStatusCode;
|
|
19
19
|
/** Whether every refusal with this code sets the envelope's notAuthorized flag, which a caller routes to its notAuthorizedHandler. */
|
|
20
20
|
notAuthorized?: true;
|
|
21
|
+
/**
|
|
22
|
+
* Whether every refusal with this code sets the envelope's sessionExpired
|
|
23
|
+
* flag, which a caller answers by clearing its session and routing to its
|
|
24
|
+
* sessionExpiredHandler: for a session the handler found no longer good
|
|
25
|
+
* (its login deleted, say), which the pipeline's own check could not know.
|
|
26
|
+
* The server ends the session the call held as the refusal leaves.
|
|
27
|
+
*/
|
|
28
|
+
sessionExpired?: true;
|
|
21
29
|
};
|
|
22
30
|
/**
|
|
23
31
|
* One code as its endpoint may send it: the status and flag its declaration
|
|
@@ -30,6 +38,8 @@ export type LambderApiAllowedRefusal = {
|
|
|
30
38
|
status?: LambderRefusalStatusCode;
|
|
31
39
|
/** Present when every refusal with the code sets the envelope's notAuthorized flag. */
|
|
32
40
|
notAuthorized?: true;
|
|
41
|
+
/** Present when every refusal with the code sets the envelope's sessionExpired flag. */
|
|
42
|
+
sessionExpired?: true;
|
|
33
43
|
} & ({
|
|
34
44
|
data: false;
|
|
35
45
|
} | {
|
|
@@ -61,7 +71,7 @@ type LambderRefusalDataRefusal = {
|
|
|
61
71
|
};
|
|
62
72
|
/**
|
|
63
73
|
* The vocabulary as declareRefusals() checks it, code by code: no `lambder/`
|
|
64
|
-
* prefix, no key beside data, status and
|
|
74
|
+
* prefix, no key beside data, status and the two flags (a `dat:` would leave
|
|
65
75
|
* a code that carries data declared as one that does not), and data that is
|
|
66
76
|
* an object or an array.
|
|
67
77
|
*/
|
|
@@ -120,6 +130,8 @@ type LambderRefusalViolation = {
|
|
|
120
130
|
thrown: unknown;
|
|
121
131
|
} | {
|
|
122
132
|
notObject: string;
|
|
133
|
+
} | {
|
|
134
|
+
notRateLimitData: true;
|
|
123
135
|
};
|
|
124
136
|
/**
|
|
125
137
|
* A refusal an endpoint is not allowed to send, so it was not sent and the
|
|
@@ -127,8 +139,10 @@ type LambderRefusalViolation = {
|
|
|
127
139
|
* the endpoint does not declare (neither
|
|
128
140
|
* in its own `refusals` option nor through a guard), data on a refusal whose
|
|
129
141
|
* code declares none, no data on one whose code carries data, data its code's
|
|
130
|
-
* schema rejects or that is not an object or an array,
|
|
131
|
-
*
|
|
142
|
+
* schema rejects or that is not an object or an array, a framework code
|
|
143
|
+
* without the data it carries (`lambder/rate-limited`'s policy and wait), or
|
|
144
|
+
* a declared code raised with a status or flag of its own, which its
|
|
145
|
+
* declaration owns.
|
|
132
146
|
*
|
|
133
147
|
* What makes a client's refusal type exact: a reader narrows `code` to the
|
|
134
148
|
* endpoint's declared codes and the framework's own, and relies on this to
|
|
@@ -152,10 +166,12 @@ export declare class LambderApiRefusalValidationError extends Error {
|
|
|
152
166
|
* here first.
|
|
153
167
|
*
|
|
154
168
|
* - An uncoded refusal, or one carrying a framework code, goes out as it is,
|
|
155
|
-
* provided it carries
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
169
|
+
* provided it carries the data its code does: the policy and the seconds
|
|
170
|
+
* to wait for `lambder/rate-limited`, none for every other. Where the app
|
|
171
|
+
* requires codes, an uncoded refusal is refused too; a framework code
|
|
172
|
+
* still passes.
|
|
173
|
+
* - A declared code leaves with its declaration's status and flags, and
|
|
174
|
+
* with data exactly when the code carries data, parsed through
|
|
159
175
|
* the code's schema as an output is: undeclared fields stripped, defaults
|
|
160
176
|
* filled, transforms run. The parse is synchronous, so a data schema cannot
|
|
161
177
|
* be async.
|
|
@@ -187,8 +203,8 @@ export declare const toRefusalCodes: (value: string | readonly string[] | undefi
|
|
|
187
203
|
* The vocabulary, checked: every code a non-empty string outside the
|
|
188
204
|
* framework's `lambder/` prefix, every declaration an object whose `data`,
|
|
189
205
|
* when present, is a zod schema, whose `status`, when present, is one a
|
|
190
|
-
* reader files as a refusal, and whose `notAuthorized
|
|
191
|
-
* true. A Map, so a code named for something Object.prototype carries
|
|
206
|
+
* reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
|
|
207
|
+
* when present, are true. A Map, so a code named for something Object.prototype carries
|
|
192
208
|
* ("toString") is still an ordinary code.
|
|
193
209
|
*/
|
|
194
210
|
export declare const readRefusalVocabulary: (refusals: Record<string, LambderRefusalDeclaration> | undefined) => Map<string, LambderRefusalDeclaration> | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
|
|
1
|
+
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
|
|
2
2
|
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
3
3
|
import { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
|
|
4
4
|
/**
|
|
@@ -18,17 +18,31 @@ export const allowedRefusalOf = (declaration) => {
|
|
|
18
18
|
const leaves = {
|
|
19
19
|
...(declaration.status !== undefined ? { status: declaration.status } : {}),
|
|
20
20
|
...(declaration.notAuthorized ? { notAuthorized: true } : {}),
|
|
21
|
+
...(declaration.sessionExpired ? { sessionExpired: true } : {}),
|
|
21
22
|
};
|
|
22
23
|
return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
|
|
23
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Whether a `lambder/rate-limited` refusal's data is the shape a client
|
|
27
|
+
* narrows it to: the policy's name, and the whole seconds of at least one
|
|
28
|
+
* that the Retry-After header carries.
|
|
29
|
+
*/
|
|
30
|
+
const isRateLimitRefusalData = (data) => {
|
|
31
|
+
if (data === null || typeof data !== "object")
|
|
32
|
+
return false;
|
|
33
|
+
const { policy, retryAfterSeconds } = data;
|
|
34
|
+
return typeof policy === "string" && Number.isInteger(retryAfterSeconds) && retryAfterSeconds >= 1;
|
|
35
|
+
};
|
|
24
36
|
/**
|
|
25
37
|
* A refusal an endpoint is not allowed to send, so it was not sent and the
|
|
26
38
|
* call is answered as a crash: no code where the app requires one, a code
|
|
27
39
|
* the endpoint does not declare (neither
|
|
28
40
|
* in its own `refusals` option nor through a guard), data on a refusal whose
|
|
29
41
|
* code declares none, no data on one whose code carries data, data its code's
|
|
30
|
-
* schema rejects or that is not an object or an array,
|
|
31
|
-
*
|
|
42
|
+
* schema rejects or that is not an object or an array, a framework code
|
|
43
|
+
* without the data it carries (`lambder/rate-limited`'s policy and wait), or
|
|
44
|
+
* a declared code raised with a status or flag of its own, which its
|
|
45
|
+
* declaration owns.
|
|
32
46
|
*
|
|
33
47
|
* What makes a client's refusal type exact: a reader narrows `code` to the
|
|
34
48
|
* endpoint's declared codes and the framework's own, and relies on this to
|
|
@@ -63,7 +77,7 @@ export class LambderApiRefusalValidationError extends Error {
|
|
|
63
77
|
}
|
|
64
78
|
else if ("ownStatusOrFlag" in violation) {
|
|
65
79
|
message = `Lambder: API "${apiName}" refused with the declared code "${String(code)}" and a status or flag of its own, so the refusal was not sent. `
|
|
66
|
-
+ "A declared code's status and notAuthorized
|
|
80
|
+
+ "A declared code's status and its notAuthorized and sessionExpired flags are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
|
|
67
81
|
}
|
|
68
82
|
else if ("zodError" in violation) {
|
|
69
83
|
// Paths and messages only, never the values: they may be the
|
|
@@ -75,6 +89,10 @@ export class LambderApiRefusalValidationError extends Error {
|
|
|
75
89
|
message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema threw on while parsing it (a transform that threw, `
|
|
76
90
|
+ "or an async step: a refusal's data is parsed synchronously), so the refusal was not sent.";
|
|
77
91
|
}
|
|
92
|
+
else if ("notRateLimitData" in violation) {
|
|
93
|
+
message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is not { policy, retryAfterSeconds }, so the refusal was not sent. `
|
|
94
|
+
+ "Refuse a rate limit through ctx.rateLimit, or throw rateLimitRefusal(), which writes the data a client narrows it by.";
|
|
95
|
+
}
|
|
78
96
|
else {
|
|
79
97
|
message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is ${violation.notObject}, and a refusal's data is an object or an array, so the refusal was not sent.`;
|
|
80
98
|
}
|
|
@@ -93,10 +111,12 @@ export class LambderApiRefusalValidationError extends Error {
|
|
|
93
111
|
* here first.
|
|
94
112
|
*
|
|
95
113
|
* - An uncoded refusal, or one carrying a framework code, goes out as it is,
|
|
96
|
-
* provided it carries
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
114
|
+
* provided it carries the data its code does: the policy and the seconds
|
|
115
|
+
* to wait for `lambder/rate-limited`, none for every other. Where the app
|
|
116
|
+
* requires codes, an uncoded refusal is refused too; a framework code
|
|
117
|
+
* still passes.
|
|
118
|
+
* - A declared code leaves with its declaration's status and flags, and
|
|
119
|
+
* with data exactly when the code carries data, parsed through
|
|
100
120
|
* the code's schema as an output is: undeclared fields stripped, defaults
|
|
101
121
|
* filled, transforms run. The parse is synchronous, so a data schema cannot
|
|
102
122
|
* be async.
|
|
@@ -110,6 +130,13 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
|
|
|
110
130
|
if (endpoint === undefined)
|
|
111
131
|
return thrown;
|
|
112
132
|
const { code, data } = thrown.refusal;
|
|
133
|
+
if (code === LAMBDER_REFUSAL_CODES.rateLimited) {
|
|
134
|
+
if (data === undefined)
|
|
135
|
+
throw new LambderApiRefusalValidationError(apiName, thrown, { missingData: code });
|
|
136
|
+
if (!isRateLimitRefusalData(data))
|
|
137
|
+
throw new LambderApiRefusalValidationError(apiName, thrown, { notRateLimitData: true });
|
|
138
|
+
return thrown;
|
|
139
|
+
}
|
|
113
140
|
if (code === undefined || isLambderRefusalCode(code)) {
|
|
114
141
|
if (code === undefined && endpoint.codeRequired)
|
|
115
142
|
throw new LambderApiRefusalValidationError(apiName, thrown, { uncoded: true });
|
|
@@ -145,11 +172,12 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
|
|
|
145
172
|
throw new LambderApiRefusalValidationError(apiName, thrown, { notObject: describePayloadKind(parsed.data) });
|
|
146
173
|
sent = parsed.data;
|
|
147
174
|
}
|
|
148
|
-
// The declaration's status and
|
|
175
|
+
// The declaration's status and flags, and the data as parsed: how every
|
|
149
176
|
// refusal with this code leaves, wherever it was raised.
|
|
150
177
|
return new LambderApiRefusal(thrown.message, {
|
|
151
178
|
refusal: { ...thrown.refusal, ...(sent !== undefined ? { data: sent } : {}) },
|
|
152
179
|
notAuthorized: declaration.notAuthorized,
|
|
180
|
+
sessionExpired: declaration.sessionExpired,
|
|
153
181
|
statusCode: declaration.status,
|
|
154
182
|
headers: thrown.headers,
|
|
155
183
|
cause: thrown.cause,
|
|
@@ -195,8 +223,8 @@ export const toRefusalCodes = (value) => value === undefined ? [] : typeof value
|
|
|
195
223
|
* The vocabulary, checked: every code a non-empty string outside the
|
|
196
224
|
* framework's `lambder/` prefix, every declaration an object whose `data`,
|
|
197
225
|
* when present, is a zod schema, whose `status`, when present, is one a
|
|
198
|
-
* reader files as a refusal, and whose `notAuthorized
|
|
199
|
-
* true. A Map, so a code named for something Object.prototype carries
|
|
226
|
+
* reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
|
|
227
|
+
* when present, are true. A Map, so a code named for something Object.prototype carries
|
|
200
228
|
* ("toString") is still an ordinary code.
|
|
201
229
|
*/
|
|
202
230
|
export const readRefusalVocabulary = (refusals) => {
|
|
@@ -215,9 +243,9 @@ export const readRefusalVocabulary = (refusals) => {
|
|
|
215
243
|
if (declaration === null || typeof declaration !== "object") {
|
|
216
244
|
throw new Error(`Lambder: the refusal "${code}" is not declared as an object: write {} for a code with no data, or { data: schema }.`);
|
|
217
245
|
}
|
|
218
|
-
const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized");
|
|
246
|
+
const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized" && key !== "sessionExpired");
|
|
219
247
|
if (extra.length) {
|
|
220
|
-
throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status and
|
|
248
|
+
throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status, notAuthorized and sessionExpired, which a refusal declaration does not take.`);
|
|
221
249
|
}
|
|
222
250
|
// Read for what it is used as rather than by class, so a schema from
|
|
223
251
|
// a second copy of zod in the bundle still counts.
|
|
@@ -228,8 +256,13 @@ export const readRefusalVocabulary = (refusals) => {
|
|
|
228
256
|
if (status !== undefined && (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status >= 500 || status === 422)) {
|
|
229
257
|
throw new Error(`Lambder: the refusal "${code}" declares the status ${String(status)}. A declared refusal leaves with a status from 200 to 499 other than 422: a reader files a 5xx as a server failure and a 422 as an input validation failure.`);
|
|
230
258
|
}
|
|
231
|
-
|
|
232
|
-
|
|
259
|
+
for (const flag of ["notAuthorized", "sessionExpired"]) {
|
|
260
|
+
if (declaration[flag] !== undefined && declaration[flag] !== true) {
|
|
261
|
+
throw new Error(`Lambder: the refusal "${code}" declares ${flag} as ${String(declaration[flag])}; write true, or leave it off.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (declaration.notAuthorized && declaration.sessionExpired) {
|
|
265
|
+
throw new Error(`Lambder: the refusal "${code}" declares both notAuthorized and sessionExpired. A caller routes a refusal one way: a session that is gone is not also a session that is not allowed.`);
|
|
233
266
|
}
|
|
234
267
|
}
|
|
235
268
|
return new Map(entries);
|
|
@@ -27,6 +27,13 @@ export type LambderApiRequest = {
|
|
|
27
27
|
* expired, which reloads it.
|
|
28
28
|
*/
|
|
29
29
|
retiredPath?: true;
|
|
30
|
+
/**
|
|
31
|
+
* Set when the call's body is no envelope: not a JSON object (a number,
|
|
32
|
+
* a string, a boolean, null, an array) or not JSON at all. The
|
|
33
|
+
* client-facing reason, which the pipeline answers with the
|
|
34
|
+
* invalid-payload refusal (prepare) before anything reads the call.
|
|
35
|
+
*/
|
|
36
|
+
invalidEnvelope?: string;
|
|
30
37
|
/** The caller's apiVersion, informational; null when it sent none. */
|
|
31
38
|
version: string | null;
|
|
32
39
|
/** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
|
|
@@ -91,10 +98,23 @@ export declare const isApiCallContentType: (lowercasedHeaders: Record<string, st
|
|
|
91
98
|
* comes from where the call was posted, never from the body. Everything is
|
|
92
99
|
* taken as posted: a malformed idempotencyKey or guardInputs value is the
|
|
93
100
|
* engines' to refuse, with the client-facing message they already give.
|
|
101
|
+
*
|
|
102
|
+
* `posted` is the body as JSON parsed it; undefined (no body) reads as an
|
|
103
|
+
* empty envelope. A body that is not a JSON object, or one the adapter could
|
|
104
|
+
* not parse (`bodyNotJson`), is flagged on the request as invalidEnvelope
|
|
105
|
+
* and read as an empty envelope, so every adapter refuses it alike, in the
|
|
106
|
+
* pipeline, rather than each deciding what such a body means.
|
|
94
107
|
*/
|
|
95
|
-
export declare const readApiEnvelope: (
|
|
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
|