lambder 14.1.1 → 15.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +429 -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 +8 -3
- package/dist/build/checkApiRefusals.js +80 -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 +37 -19
- 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
|
@@ -46,6 +46,23 @@ export type LambderGuardPlacement = {
|
|
|
46
46
|
/** Default: "beforeInputValidation". */
|
|
47
47
|
runAt?: LambderGuardRunAt;
|
|
48
48
|
};
|
|
49
|
+
/** What a `guardInput` guard's value is to an idempotency key's request. */
|
|
50
|
+
export type LambderGuardInputUse = {
|
|
51
|
+
/**
|
|
52
|
+
* The value can differ between attempts of one operation: a single-use
|
|
53
|
+
* proof (a captcha token, a one-time code) or a credential refreshed
|
|
54
|
+
* between attempts (a short-lived token). A genuine retry may carry a new
|
|
55
|
+
* one, so the value is left out of the fingerprint that tells one request
|
|
56
|
+
* under an idempotency key from another; counted, it would turn the retry
|
|
57
|
+
* into a 409 and the person's next attempt into a second run. Default:
|
|
58
|
+
* false, the value counts, so the same key sent with
|
|
59
|
+
* another value (another store, another tenant) is refused as another
|
|
60
|
+
* request rather than replayed the first one's answer. Only a
|
|
61
|
+
* `guardInput` guard takes it: an `apiInput` guard's value is the
|
|
62
|
+
* payload's, which always counts.
|
|
63
|
+
*/
|
|
64
|
+
singleUseInput?: boolean;
|
|
65
|
+
};
|
|
49
66
|
/**
|
|
50
67
|
* The refusal codes a guard may refuse with, from the app's vocabulary (the
|
|
51
68
|
* `refusals` option at creation). They join the codes of every API that
|
|
@@ -70,6 +87,13 @@ export type LambderGuardRefusals<TRefusals extends readonly string[] = readonly
|
|
|
70
87
|
* call that omits it. The API payload and handler never see the value.
|
|
71
88
|
* - neither: the guard reads only the context.
|
|
72
89
|
*
|
|
90
|
+
* A `guardInput` is part of what an idempotency key's request is, beside its
|
|
91
|
+
* payload: the same key sent with another value (another store, another
|
|
92
|
+
* tenant) is another request. A guard whose value can differ between
|
|
93
|
+
* attempts of one operation (a captcha token, a one-time code, a short-lived
|
|
94
|
+
* token refreshed in between) declares `singleUseInput: true`, and its value
|
|
95
|
+
* is left out of that, since a genuine retry may carry a new one.
|
|
96
|
+
*
|
|
73
97
|
* Orthogonally, a guard may also:
|
|
74
98
|
*
|
|
75
99
|
* - declare `session: true`: the guard needs ctx.session, so it makes every
|
|
@@ -102,31 +126,35 @@ export type LambderGuardRefusals<TRefusals extends readonly string[] = readonly
|
|
|
102
126
|
export type LambderApiGuard<TInput extends z.ZodType = z.ZodType, TParam = any, TOutput = any, TCtx = any, TSessionCtx = TCtx> = LambderGuardPlacement & LambderGuardRefusals & ({
|
|
103
127
|
apiInput: TInput;
|
|
104
128
|
guardInput?: undefined;
|
|
129
|
+
singleUseInput?: undefined;
|
|
105
130
|
session: true;
|
|
106
131
|
handler: LambderGuardHandler<TSessionCtx, z.output<TInput>, TParam, TOutput>;
|
|
107
132
|
} | {
|
|
108
133
|
apiInput: TInput;
|
|
109
134
|
guardInput?: undefined;
|
|
135
|
+
singleUseInput?: undefined;
|
|
110
136
|
session?: false;
|
|
111
137
|
handler: LambderGuardHandler<TCtx, z.output<TInput>, TParam, TOutput>;
|
|
112
|
-
} | {
|
|
138
|
+
} | ({
|
|
113
139
|
guardInput: TInput;
|
|
114
140
|
apiInput?: undefined;
|
|
115
141
|
session: true;
|
|
116
142
|
handler: LambderGuardHandler<TSessionCtx, z.output<TInput>, TParam, TOutput>;
|
|
117
|
-
} | {
|
|
143
|
+
} & LambderGuardInputUse) | ({
|
|
118
144
|
guardInput: TInput;
|
|
119
145
|
apiInput?: undefined;
|
|
120
146
|
session?: false;
|
|
121
147
|
handler: LambderGuardHandler<TCtx, z.output<TInput>, TParam, TOutput>;
|
|
122
|
-
} | {
|
|
148
|
+
} & LambderGuardInputUse) | {
|
|
123
149
|
apiInput?: undefined;
|
|
124
150
|
guardInput?: undefined;
|
|
151
|
+
singleUseInput?: undefined;
|
|
125
152
|
session: true;
|
|
126
153
|
handler: LambderGuardHandler<TSessionCtx, undefined, TParam, TOutput>;
|
|
127
154
|
} | {
|
|
128
155
|
apiInput?: undefined;
|
|
129
156
|
guardInput?: undefined;
|
|
157
|
+
singleUseInput?: undefined;
|
|
130
158
|
session?: false;
|
|
131
159
|
handler: LambderGuardHandler<TCtx, undefined, TParam, TOutput>;
|
|
132
160
|
});
|
|
@@ -190,20 +218,22 @@ export type LambderGuardBuilder<TCtx, TSessionCtx, TVocabulary = never, TCodesRe
|
|
|
190
218
|
guardInput: TInput;
|
|
191
219
|
session: true;
|
|
192
220
|
handler: (ctx: TSessionCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
193
|
-
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
221
|
+
} & LambderGuardInputUse & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
194
222
|
refusals?: NoInfer<TRefusals>;
|
|
195
223
|
guardInput: TInput;
|
|
196
224
|
apiInput?: undefined;
|
|
225
|
+
singleUseInput?: boolean;
|
|
197
226
|
session: true;
|
|
198
227
|
handler: (ctx: TSessionCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
199
228
|
}>;
|
|
200
229
|
<TInput extends z.ZodType, TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
201
230
|
guardInput: TInput;
|
|
202
231
|
handler: (ctx: TCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
203
|
-
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
232
|
+
} & LambderGuardInputUse & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
204
233
|
refusals?: NoInfer<TRefusals>;
|
|
205
234
|
guardInput: TInput;
|
|
206
235
|
apiInput?: undefined;
|
|
236
|
+
singleUseInput?: boolean;
|
|
207
237
|
session?: undefined;
|
|
208
238
|
handler: (ctx: TCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
209
239
|
}>;
|
|
@@ -375,6 +405,15 @@ export declare class LambderApiGuardsEngine {
|
|
|
375
405
|
configure(guards: Record<string, LambderApiGuard<any, any, any>>): void;
|
|
376
406
|
/** Startup validation of one API registration's guards option. */
|
|
377
407
|
assertRegistration(apiName: string, mode: LambderApiMode, guardsOption?: LambderGuardsOptionValue): void;
|
|
408
|
+
/**
|
|
409
|
+
* The posted guard inputs an idempotency fingerprint holds for an API
|
|
410
|
+
* declaring `guardsOption`: the value of each of its `guardInput` guards
|
|
411
|
+
* by name, except a guard's declared singleUseInput. Read as posted, as
|
|
412
|
+
* the payload is, before any guard parses it; a guard the client sent
|
|
413
|
+
* nothing for is left out. A null-prototype map, so a guard named
|
|
414
|
+
* "__proto__" is a key like any other.
|
|
415
|
+
*/
|
|
416
|
+
fingerprintedInputsOf(request: LambderApiRequest, guardsOption: LambderGuardsOptionValue | undefined): Record<string, unknown>;
|
|
378
417
|
/**
|
|
379
418
|
* Run the API's guards that run at `runAt` (see LambderGuardRunAt) in
|
|
380
419
|
* declared order. Refusals throw; outputs land on ctx.guardData; the
|
|
@@ -80,6 +80,13 @@ export class LambderApiGuardsEngine {
|
|
|
80
80
|
if (runAt !== undefined && runAt !== "beforeInputValidation" && runAt !== "afterInputValidation") {
|
|
81
81
|
throw new Error(`Lambder: guard "${name}" has runAt "${String(runAt)}"; use "beforeInputValidation" (default) or "afterInputValidation".`);
|
|
82
82
|
}
|
|
83
|
+
const singleUseInput = guardDef.singleUseInput;
|
|
84
|
+
if (singleUseInput !== undefined && typeof singleUseInput !== "boolean") {
|
|
85
|
+
throw new Error(`Lambder: guard "${name}" has singleUseInput ${String(singleUseInput)}; write true for a value a retry carries fresh, or leave it off.`);
|
|
86
|
+
}
|
|
87
|
+
if (singleUseInput && !guardDef.guardInput) {
|
|
88
|
+
throw new Error(`Lambder: guard "${name}" declares singleUseInput without a guardInput. Only a value the client sends beside the payload can be left out of an idempotent request's fingerprint; a slice of the payload always counts.`);
|
|
89
|
+
}
|
|
83
90
|
this.guards.set(name, guardDef);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
@@ -105,6 +112,26 @@ export class LambderApiGuardsEngine {
|
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The posted guard inputs an idempotency fingerprint holds for an API
|
|
117
|
+
* declaring `guardsOption`: the value of each of its `guardInput` guards
|
|
118
|
+
* by name, except a guard's declared singleUseInput. Read as posted, as
|
|
119
|
+
* the payload is, before any guard parses it; a guard the client sent
|
|
120
|
+
* nothing for is left out. A null-prototype map, so a guard named
|
|
121
|
+
* "__proto__" is a key like any other.
|
|
122
|
+
*/
|
|
123
|
+
fingerprintedInputsOf(request, guardsOption) {
|
|
124
|
+
const inputs = Object.create(null);
|
|
125
|
+
for (const { name } of toGuardEntries(guardsOption)) {
|
|
126
|
+
const guardDef = this.guards.get(name);
|
|
127
|
+
if (!guardDef?.guardInput || guardDef.singleUseInput)
|
|
128
|
+
continue;
|
|
129
|
+
const input = readGuardInput(request.guardInputs, name);
|
|
130
|
+
if (input !== undefined)
|
|
131
|
+
inputs[name] = input;
|
|
132
|
+
}
|
|
133
|
+
return inputs;
|
|
134
|
+
}
|
|
108
135
|
/**
|
|
109
136
|
* Run the API's guards that run at `runAt` (see LambderGuardRunAt) in
|
|
110
137
|
* declared order. Refusals throw; outputs land on ctx.guardData; the
|
|
@@ -3,6 +3,7 @@ import type { LambderApiCallContext, LambderApiCallTrace } from "./LambderApiCal
|
|
|
3
3
|
import type { LambderApiAnswer } from "./LambderApiAnswer.js";
|
|
4
4
|
import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
|
|
5
5
|
import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
|
|
6
|
+
import type { LambderKeyFieldDigest } from "../shared/util/LambderKeyFieldDigest.js";
|
|
6
7
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
7
8
|
export type LambderApiIdempotencyConfig = {
|
|
8
9
|
/** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
|
|
@@ -57,12 +58,14 @@ export type LambderIdempotentCall = {
|
|
|
57
58
|
export declare class LambderApiIdempotencyEngine {
|
|
58
59
|
/** Stamped on the crash answer the engine records for an answer that broke its output schema. */
|
|
59
60
|
private readonly apiVersion;
|
|
61
|
+
/** What every caller-controlled field of a scope is written as. */
|
|
62
|
+
private readonly keyFieldDigest;
|
|
60
63
|
private store;
|
|
61
64
|
private defaultTtlSeconds;
|
|
62
65
|
private defaultPendingTtlSeconds;
|
|
63
66
|
private failOpen;
|
|
64
67
|
private callerIdentity;
|
|
65
|
-
constructor(apiVersion: string | null);
|
|
68
|
+
constructor(apiVersion: string | null, keyFieldDigest: LambderKeyFieldDigest);
|
|
66
69
|
configure(config: LambderApiIdempotencyConfig): void;
|
|
67
70
|
/**
|
|
68
71
|
* Puts the engine over another store, for `lambder/testing`; the replay
|
|
@@ -92,10 +95,12 @@ export declare class LambderApiIdempotencyEngine {
|
|
|
92
95
|
* served before guards run.
|
|
93
96
|
*
|
|
94
97
|
* joinKeyFields escapes the fields, so no two distinct scopes collide.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
98
|
+
* Every field but the API name is the caller's (a session key, an
|
|
99
|
+
* identity such as the docs' device token, the posted key), so each is
|
|
100
|
+
* written as its digest (LambderKeyFieldDigest): a table read shows which
|
|
101
|
+
* API a record answers and never whose it is, and no field is long
|
|
102
|
+
* enough to push the scope past a store's key limit, whose refusal is a
|
|
103
|
+
* throw that failOpen turns into no idempotency for that caller.
|
|
99
104
|
*/
|
|
100
105
|
private scopeOf;
|
|
101
106
|
/**
|
|
@@ -105,9 +110,11 @@ export declare class LambderApiIdempotencyEngine {
|
|
|
105
110
|
* refuses with a 400. Taken at the replay lookup, before input validation
|
|
106
111
|
* replaces the payload with its parsed form, so the fingerprint is of the
|
|
107
112
|
* request as it was posted, and callerIdentity (app code that may verify
|
|
108
|
-
* a token or read a store) runs once.
|
|
113
|
+
* a token or read a store) runs once. `guardInputs` are the posted guard
|
|
114
|
+
* inputs the fingerprint holds, as the guards engine picks them
|
|
115
|
+
* (fingerprintedInputsOf): the engine that knows which are single use.
|
|
109
116
|
*/
|
|
110
|
-
resolveKeyedCall(apiName: string, request: LambderApiRequest, ctx: LambderApiCallContext): Promise<LambderIdempotentCall | null>;
|
|
117
|
+
resolveKeyedCall(apiName: string, request: LambderApiRequest, ctx: LambderApiCallContext, guardInputs: Readonly<Record<string, unknown>>): Promise<LambderIdempotentCall | null>;
|
|
111
118
|
/**
|
|
112
119
|
* Replay fast path, run before guards and all rate limits except
|
|
113
120
|
* `per: "ip"`: a completed record returns its stored answer, so a
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
2
2
|
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
3
3
|
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
4
|
-
import { boundKeyField } from "../shared/util/boundKeyField.js";
|
|
5
4
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
6
5
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
7
6
|
import { canonicalJson } from "../shared/util/canonicalJson.js";
|
|
@@ -85,22 +84,26 @@ const keyReusedRefusal = (apiName) => new LambderApiRefusal(`Idempotency key reu
|
|
|
85
84
|
});
|
|
86
85
|
/**
|
|
87
86
|
* A request's fingerprint: what makes two requests under one key the same
|
|
88
|
-
* request. The payload as it was posted,
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
87
|
+
* request. The payload as it was posted, beside the guard inputs that say
|
|
88
|
+
* what the call is about (see LambderApiGuardsEngine.fingerprintedInputsOf),
|
|
89
|
+
* as canonical JSON so key order does not matter, digested so a record keeps
|
|
90
|
+
* 64 characters rather than the request. A guard input selecting a store or
|
|
91
|
+
* a tenant is part of the request as surely as the payload is: left out, the
|
|
92
|
+
* same key sent for another tenant would replay the first tenant's answer. A
|
|
93
|
+
* guard input declared single use (a captcha token) is left out by the
|
|
94
|
+
* guards engine, since a genuine retry carries a fresh one.
|
|
95
|
+
*
|
|
96
|
+
* A runtime without WebCrypto (the mock on a page served over plain http, a
|
|
97
|
+
* phone on the LAN) keeps the canonical JSON itself: a fingerprint is only
|
|
98
|
+
* compared with ones the same runtime took, so its form does not matter,
|
|
99
|
+
* only that one request always gives the same one. Either form is non-empty,
|
|
100
|
+
* which is what lets a store report the empty string for a record it cannot
|
|
101
|
+
* tie to any request.
|
|
99
102
|
*/
|
|
100
|
-
const requestFingerprintOf = async (payload) => {
|
|
103
|
+
const requestFingerprintOf = async (payload, guardInputs) => {
|
|
101
104
|
let canonical;
|
|
102
105
|
try {
|
|
103
|
-
canonical = canonicalJson(payload ?? null);
|
|
106
|
+
canonical = canonicalJson({ payload: payload ?? null, guardInputs });
|
|
104
107
|
}
|
|
105
108
|
catch {
|
|
106
109
|
// Sorting the keys recurses once per nesting level, so a payload
|
|
@@ -125,8 +128,9 @@ const requestFingerprintOf = async (payload) => {
|
|
|
125
128
|
* is the right default, but silently it looks exactly like working: permanent
|
|
126
129
|
* failures (a missing table, a missing IAM action, an SDK that would not
|
|
127
130
|
* install) could have an app execute every retry twice for months with
|
|
128
|
-
* nothing in its logs. The scope key is left out
|
|
129
|
-
* caller
|
|
131
|
+
* nothing in its logs. The scope key is left out: digested as its caller's
|
|
132
|
+
* fields are, it is still the same for every attempt of one caller, and a
|
|
133
|
+
* log is read by more people than the table.
|
|
130
134
|
*/
|
|
131
135
|
const reportFailOpen = (apiName, attempted, err) => {
|
|
132
136
|
console.error(`Lambder idempotency: "${apiName}" could not ${attempted}; the request is being executed as if it carried no idempotency key. ` +
|
|
@@ -141,13 +145,16 @@ const reportFailOpen = (apiName, attempted, err) => {
|
|
|
141
145
|
export class LambderApiIdempotencyEngine {
|
|
142
146
|
/** Stamped on the crash answer the engine records for an answer that broke its output schema. */
|
|
143
147
|
apiVersion;
|
|
148
|
+
/** What every caller-controlled field of a scope is written as. */
|
|
149
|
+
keyFieldDigest;
|
|
144
150
|
store = null;
|
|
145
151
|
defaultTtlSeconds = 24 * 3600;
|
|
146
152
|
defaultPendingTtlSeconds = DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
|
|
147
153
|
failOpen = true;
|
|
148
154
|
callerIdentity = undefined;
|
|
149
|
-
constructor(apiVersion) {
|
|
155
|
+
constructor(apiVersion, keyFieldDigest) {
|
|
150
156
|
this.apiVersion = apiVersion;
|
|
157
|
+
this.keyFieldDigest = keyFieldDigest;
|
|
151
158
|
}
|
|
152
159
|
configure(config) {
|
|
153
160
|
if (this.store)
|
|
@@ -210,22 +217,25 @@ export class LambderApiIdempotencyEngine {
|
|
|
210
217
|
* served before guards run.
|
|
211
218
|
*
|
|
212
219
|
* joinKeyFields escapes the fields, so no two distinct scopes collide.
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
220
|
+
* Every field but the API name is the caller's (a session key, an
|
|
221
|
+
* identity such as the docs' device token, the posted key), so each is
|
|
222
|
+
* written as its digest (LambderKeyFieldDigest): a table read shows which
|
|
223
|
+
* API a record answers and never whose it is, and no field is long
|
|
224
|
+
* enough to push the scope past a store's key limit, whose refusal is a
|
|
225
|
+
* throw that failOpen turns into no idempotency for that caller.
|
|
217
226
|
*/
|
|
218
227
|
async scopeOf(apiName, ctx, request, key) {
|
|
228
|
+
const postedKey = await this.keyFieldDigest.digestOf("key", key);
|
|
219
229
|
const sessionKey = ctx.session?.sessionKey;
|
|
220
230
|
if (sessionKey)
|
|
221
|
-
return joinKeyFields(await
|
|
231
|
+
return joinKeyFields(await this.keyFieldDigest.digestOf("s", sessionKey), apiName, postedKey);
|
|
222
232
|
// No session to scope by. The app may still say who this is, through
|
|
223
233
|
// callerIdentity; without one the key alone is the scope, which is
|
|
224
234
|
// what makes it a bearer token for its own answer.
|
|
225
235
|
const identity = this.callerIdentity ? await this.callerIdentity(ctx, request) : null;
|
|
226
236
|
return identity
|
|
227
|
-
? joinKeyFields(await
|
|
228
|
-
: joinKeyFields("k", apiName,
|
|
237
|
+
? joinKeyFields(await this.keyFieldDigest.digestOf("i", identity), apiName, postedKey)
|
|
238
|
+
: joinKeyFields("k", apiName, postedKey);
|
|
229
239
|
}
|
|
230
240
|
/**
|
|
231
241
|
* The call's key worked out, once per call: its scope and its request's
|
|
@@ -234,9 +244,11 @@ export class LambderApiIdempotencyEngine {
|
|
|
234
244
|
* refuses with a 400. Taken at the replay lookup, before input validation
|
|
235
245
|
* replaces the payload with its parsed form, so the fingerprint is of the
|
|
236
246
|
* request as it was posted, and callerIdentity (app code that may verify
|
|
237
|
-
* a token or read a store) runs once.
|
|
247
|
+
* a token or read a store) runs once. `guardInputs` are the posted guard
|
|
248
|
+
* inputs the fingerprint holds, as the guards engine picks them
|
|
249
|
+
* (fingerprintedInputsOf): the engine that knows which are single use.
|
|
238
250
|
*/
|
|
239
|
-
async resolveKeyedCall(apiName, request, ctx) {
|
|
251
|
+
async resolveKeyedCall(apiName, request, ctx, guardInputs) {
|
|
240
252
|
if (!this.store)
|
|
241
253
|
return null;
|
|
242
254
|
const key = this.readKey(request);
|
|
@@ -244,7 +256,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
244
256
|
return null;
|
|
245
257
|
return {
|
|
246
258
|
scopeKey: await this.scopeOf(apiName, ctx, request, key),
|
|
247
|
-
fingerprint: await requestFingerprintOf(request.payload),
|
|
259
|
+
fingerprint: await requestFingerprintOf(request.payload, guardInputs),
|
|
248
260
|
};
|
|
249
261
|
}
|
|
250
262
|
/**
|
|
@@ -39,8 +39,7 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
|
|
|
39
39
|
* The floor under the signature gate: a request naming a `version` below
|
|
40
40
|
* it answers versionExpired whatever its signature says. Dotted numbers
|
|
41
41
|
* ("1.2.10"), compared segment by segment. A floor above apiVersion is
|
|
42
|
-
*
|
|
43
|
-
* clients.
|
|
42
|
+
* refused at construction, since it would refuse the build's own clients.
|
|
44
43
|
*/
|
|
45
44
|
minApiVersion?: string | null;
|
|
46
45
|
/**
|
|
@@ -60,6 +59,16 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
|
|
|
60
59
|
session: LambderSessionRecord<TSessionData>;
|
|
61
60
|
}>>;
|
|
62
61
|
idempotency?: LambderApiIdempotencyConfig;
|
|
62
|
+
/**
|
|
63
|
+
* The app's at-rest secret: the session salt, which both adapters pass
|
|
64
|
+
* when sessions are configured. A subkey derived from it for this one
|
|
65
|
+
* purpose keys the digest every caller-controlled field of a rate-limit
|
|
66
|
+
* tracker key and an idempotency scope is written as
|
|
67
|
+
* (LambderKeyFieldDigest), so a table read can neither show who called
|
|
68
|
+
* nor test a guess. Without one the digest is a plain SHA-256, which
|
|
69
|
+
* keeps the values out of the table but lets a reader test guesses.
|
|
70
|
+
*/
|
|
71
|
+
atRestSecret?: string;
|
|
63
72
|
};
|
|
64
73
|
/** The stores `lambder/testing` puts under a built pipeline. One the pipeline has no subsystem for is left aside. */
|
|
65
74
|
export type LambderPipelineBackends = {
|
|
@@ -93,11 +102,11 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
93
102
|
* are adapters over this class; neither reimplements a step of it.
|
|
94
103
|
*
|
|
95
104
|
* ```
|
|
96
|
-
* version floor → signature gate → restore payload → rate
|
|
97
|
-
* → session (session mode) → idempotency replay → rate
|
|
98
|
-
* (and custom keys charged beforeGuards) → guards →
|
|
99
|
-
* placed after it → rate limits keyed by a custom
|
|
100
|
-
* idempotency claim → drain response headers → answer
|
|
105
|
+
* envelope check → version floor → signature gate → restore payload → rate
|
|
106
|
+
* limits keyed per ip → session (session mode) → idempotency replay → rate
|
|
107
|
+
* limits keyed per session (and custom keys charged beforeGuards) → guards →
|
|
108
|
+
* input validation → guards placed after it → rate limits keyed by a custom
|
|
109
|
+
* key → exec, inside the idempotency claim → drain response headers → answer
|
|
101
110
|
* ```
|
|
102
111
|
*
|
|
103
112
|
* Each policy subsystem (rate limits, guards, idempotency) is its own
|
|
@@ -182,10 +191,15 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
182
191
|
*/
|
|
183
192
|
answerRefusal(apiName: string, refusals: LambderEndpointRefusals | undefined, thrown: LambderApiRefusal, ctx: TCtx): Promise<LambderApiAnswer>;
|
|
184
193
|
/**
|
|
185
|
-
* The steps that come before anything may read the request: the
|
|
186
|
-
* floor, the signature gate, then the
|
|
187
|
-
* every later reader (a rate-limit key
|
|
188
|
-
* relies on.
|
|
194
|
+
* The steps that come before anything may read the request: the
|
|
195
|
+
* envelope check, the version floor, the signature gate, then the
|
|
196
|
+
* compressed-payload restore that every later reader (a rate-limit key
|
|
197
|
+
* slice, a guard, the input schema) relies on.
|
|
198
|
+
*
|
|
199
|
+
* A body that is no envelope (readApiEnvelope flagged it invalidEnvelope)
|
|
200
|
+
* carries nothing to call with, no payload, version or signature, and is
|
|
201
|
+
* answered with the invalid-payload refusal, here, so every adapter
|
|
202
|
+
* refuses it alike.
|
|
189
203
|
*
|
|
190
204
|
* The floor refuses a request naming a version below minApiVersion,
|
|
191
205
|
* whatever its signature says: the lever for a change the digest cannot
|
|
@@ -11,6 +11,7 @@ import { LambderApiGuardsEngine } from "./LambderApiGuards.js";
|
|
|
11
11
|
import { LambderApiRateLimitsEngine, } from "./LambderApiRateLimits.js";
|
|
12
12
|
import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
|
|
13
13
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
14
|
+
import { LambderKeyFieldDigest } from "../shared/util/LambderKeyFieldDigest.js";
|
|
14
15
|
import LambderSessionController, { assertSessionCookiePrefixes, LambderSessionNotFoundError, } from "../session/LambderSessionController.js";
|
|
15
16
|
import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
|
|
16
17
|
/** An API that asks for idempotency: declared, and not the explicit `false` opt-out. */
|
|
@@ -21,11 +22,11 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
21
22
|
* are adapters over this class; neither reimplements a step of it.
|
|
22
23
|
*
|
|
23
24
|
* ```
|
|
24
|
-
* version floor → signature gate → restore payload → rate
|
|
25
|
-
* → session (session mode) → idempotency replay → rate
|
|
26
|
-
* (and custom keys charged beforeGuards) → guards →
|
|
27
|
-
* placed after it → rate limits keyed by a custom
|
|
28
|
-
* idempotency claim → drain response headers → answer
|
|
25
|
+
* envelope check → version floor → signature gate → restore payload → rate
|
|
26
|
+
* limits keyed per ip → session (session mode) → idempotency replay → rate
|
|
27
|
+
* limits keyed per session (and custom keys charged beforeGuards) → guards →
|
|
28
|
+
* input validation → guards placed after it → rate limits keyed by a custom
|
|
29
|
+
* key → exec, inside the idempotency claim → drain response headers → answer
|
|
29
30
|
* ```
|
|
30
31
|
*
|
|
31
32
|
* Each policy subsystem (rate limits, guards, idempotency) is its own
|
|
@@ -52,7 +53,7 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
52
53
|
export class LambderApiPipeline {
|
|
53
54
|
apiVersion;
|
|
54
55
|
minApiVersion;
|
|
55
|
-
rateLimits
|
|
56
|
+
rateLimits;
|
|
56
57
|
guards = new LambderApiGuardsEngine();
|
|
57
58
|
idempotency;
|
|
58
59
|
maxRequestPayloadBytes;
|
|
@@ -75,14 +76,19 @@ export class LambderApiPipeline {
|
|
|
75
76
|
}
|
|
76
77
|
// A floor above the version this server stamps would refuse this
|
|
77
78
|
// build's own clients, and the first symptom would be every tab
|
|
78
|
-
// reloading.
|
|
79
|
-
//
|
|
79
|
+
// reloading. Refused at creation, as every other misconfiguration
|
|
80
|
+
// is, rather than quietly read as some other floor than the one
|
|
81
|
+
// written: which of the two versions is wrong is the app's call.
|
|
80
82
|
if (this.apiVersion !== null && compareDottedVersions(this.minApiVersion, this.apiVersion) > 0) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
throw new Error(`Lambder: minApiVersion ${this.minApiVersion} is above apiVersion ${this.apiVersion}, so it would refuse this build's own clients. ` +
|
|
84
|
+
`Set minApiVersion to the oldest client version still served, at most ${this.apiVersion}.`);
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
|
-
|
|
87
|
+
// One digest for both engines, so its subkey is derived once per
|
|
88
|
+
// instance.
|
|
89
|
+
const keyFieldDigest = new LambderKeyFieldDigest(options.atRestSecret ?? null);
|
|
90
|
+
this.rateLimits = new LambderApiRateLimitsEngine(keyFieldDigest);
|
|
91
|
+
this.idempotency = new LambderApiIdempotencyEngine(this.apiVersion, keyFieldDigest);
|
|
86
92
|
this.apiSignatures = options.apiSignatures ?? null;
|
|
87
93
|
this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
|
|
88
94
|
this.onInvalidInput = options.onInvalidInput ?? null;
|
|
@@ -211,10 +217,15 @@ export class LambderApiPipeline {
|
|
|
211
217
|
return refusalAnswer(checked, this.apiVersion, ctx.logList);
|
|
212
218
|
}
|
|
213
219
|
/**
|
|
214
|
-
* The steps that come before anything may read the request: the
|
|
215
|
-
* floor, the signature gate, then the
|
|
216
|
-
* every later reader (a rate-limit key
|
|
217
|
-
* relies on.
|
|
220
|
+
* The steps that come before anything may read the request: the
|
|
221
|
+
* envelope check, the version floor, the signature gate, then the
|
|
222
|
+
* compressed-payload restore that every later reader (a rate-limit key
|
|
223
|
+
* slice, a guard, the input schema) relies on.
|
|
224
|
+
*
|
|
225
|
+
* A body that is no envelope (readApiEnvelope flagged it invalidEnvelope)
|
|
226
|
+
* carries nothing to call with, no payload, version or signature, and is
|
|
227
|
+
* answered with the invalid-payload refusal, here, so every adapter
|
|
228
|
+
* refuses it alike.
|
|
218
229
|
*
|
|
219
230
|
* The floor refuses a request naming a version below minApiVersion,
|
|
220
231
|
* whatever its signature says: the lever for a change the digest cannot
|
|
@@ -236,6 +247,8 @@ export class LambderApiPipeline {
|
|
|
236
247
|
* ready to dispatch.
|
|
237
248
|
*/
|
|
238
249
|
async prepare(request) {
|
|
250
|
+
if (request.invalidEnvelope !== undefined)
|
|
251
|
+
return invalidPayloadAnswer(this.apiVersion, request.invalidEnvelope);
|
|
239
252
|
if (this.minApiVersion !== null && request.version !== null && compareDottedVersions(request.version, this.minApiVersion) < 0) {
|
|
240
253
|
return versionExpiredAnswer(this.apiVersion);
|
|
241
254
|
}
|
|
@@ -318,8 +331,12 @@ export class LambderApiPipeline {
|
|
|
318
331
|
// Replay fast path: a completed idempotent request answers its stored
|
|
319
332
|
// answer without burning the remaining rate-limit quota or re-running
|
|
320
333
|
// guards. After the session read, because the replay scope is keyed
|
|
321
|
-
// per user, by the session's sessionKey.
|
|
322
|
-
|
|
334
|
+
// per user, by the session's sessionKey. The guards engine says which
|
|
335
|
+
// posted guard inputs belong to the request's fingerprint, since only
|
|
336
|
+
// it knows which of them are single use.
|
|
337
|
+
const keyedCall = usesIdempotency(definition)
|
|
338
|
+
? await this.idempotency.resolveKeyedCall(definition.name, request, ctx, this.guards.fingerprintedInputsOf(request, definition.guards))
|
|
339
|
+
: null;
|
|
323
340
|
const replay = keyedCall ? await this.idempotency.findReplay(definition.name, keyedCall, trace) : null;
|
|
324
341
|
if (replay)
|
|
325
342
|
return replay;
|
|
@@ -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
|
}
|