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
|
@@ -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
|
/**
|
|
@@ -5,6 +5,8 @@ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
|
5
5
|
import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
|
|
6
6
|
import type { LambderApiDefinition } from "./LambderApiDefinition.js";
|
|
7
7
|
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
8
|
+
import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
9
|
+
import { type LambderEndpointRefusals } from "./LambderApiRefusals.js";
|
|
8
10
|
import { type LambderApiGuard } from "./LambderApiGuards.js";
|
|
9
11
|
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitChargeResult, type LambderRateLimitChargeSubject } from "./LambderApiRateLimits.js";
|
|
10
12
|
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
@@ -37,8 +39,7 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
|
|
|
37
39
|
* The floor under the signature gate: a request naming a `version` below
|
|
38
40
|
* it answers versionExpired whatever its signature says. Dotted numbers
|
|
39
41
|
* ("1.2.10"), compared segment by segment. A floor above apiVersion is
|
|
40
|
-
*
|
|
41
|
-
* clients.
|
|
42
|
+
* refused at construction, since it would refuse the build's own clients.
|
|
42
43
|
*/
|
|
43
44
|
minApiVersion?: string | null;
|
|
44
45
|
/**
|
|
@@ -58,6 +59,16 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
|
|
|
58
59
|
session: LambderSessionRecord<TSessionData>;
|
|
59
60
|
}>>;
|
|
60
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;
|
|
61
72
|
};
|
|
62
73
|
/** The stores `lambder/testing` puts under a built pipeline. One the pipeline has no subsystem for is left aside. */
|
|
63
74
|
export type LambderPipelineBackends = {
|
|
@@ -91,11 +102,11 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
91
102
|
* are adapters over this class; neither reimplements a step of it.
|
|
92
103
|
*
|
|
93
104
|
* ```
|
|
94
|
-
* version floor → signature gate → restore payload → rate
|
|
95
|
-
* → session (session mode) → idempotency replay → rate
|
|
96
|
-
* (and custom keys charged beforeGuards) → guards →
|
|
97
|
-
* placed after it → rate limits keyed by a custom
|
|
98
|
-
* 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
|
|
99
110
|
* ```
|
|
100
111
|
*
|
|
101
112
|
* Each policy subsystem (rate limits, guards, idempotency) is its own
|
|
@@ -107,8 +118,12 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
107
118
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
108
119
|
* envelope once checked against the endpoint's declared codes
|
|
109
120
|
* (checkedRefusal), and a session ended while the handler held it
|
|
110
|
-
* (LambderSessionNotFoundError) as sessionExpired.
|
|
111
|
-
*
|
|
121
|
+
* (LambderSessionNotFoundError) as sessionExpired. A refusal that leaves
|
|
122
|
+
* with the sessionExpired flag ends the session the call held
|
|
123
|
+
* (answerRefusal), so the server keeps no session its caller has been told
|
|
124
|
+
* is over. Anything else
|
|
125
|
+
* propagates, because only the adapter knows what a crash means (a global
|
|
126
|
+
* error handler, a mock event).
|
|
112
127
|
*
|
|
113
128
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
114
129
|
* definition is the one thing the adapters legitimately do differently (an
|
|
@@ -162,10 +177,29 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
162
177
|
*/
|
|
163
178
|
answerUnknownApi(ctx?: TCtx): LambderApiAnswer;
|
|
164
179
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
180
|
+
* A thrown refusal as the answer to a call of `apiName`: checked against
|
|
181
|
+
* the codes the endpoint declares (checkedRefusal, which throws for one it
|
|
182
|
+
* may not send), then rendered. run() answers a step's, a guard's or a
|
|
183
|
+
* handler's refusal with it, and the server a hook's.
|
|
184
|
+
*
|
|
185
|
+
* A refusal telling the caller its session is over (a declared code's
|
|
186
|
+
* flag, or a raise site's own) ends the session the call held: the caller
|
|
187
|
+
* stops sending it, and nothing else would delete it before it expires.
|
|
188
|
+
* The record is deleted and the cookies left alone, which is what the
|
|
189
|
+
* pipeline's own sessionExpired leaves behind (endWithNoSession in the
|
|
190
|
+
* session controller says why the cookies stay).
|
|
191
|
+
*/
|
|
192
|
+
answerRefusal(apiName: string, refusals: LambderEndpointRefusals | undefined, thrown: LambderApiRefusal, ctx: TCtx): Promise<LambderApiAnswer>;
|
|
193
|
+
/**
|
|
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.
|
|
169
203
|
*
|
|
170
204
|
* The floor refuses a request naming a version below minApiVersion,
|
|
171
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
|
|
@@ -37,8 +38,12 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
37
38
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
38
39
|
* envelope once checked against the endpoint's declared codes
|
|
39
40
|
* (checkedRefusal), and a session ended while the handler held it
|
|
40
|
-
* (LambderSessionNotFoundError) as sessionExpired.
|
|
41
|
-
*
|
|
41
|
+
* (LambderSessionNotFoundError) as sessionExpired. A refusal that leaves
|
|
42
|
+
* with the sessionExpired flag ends the session the call held
|
|
43
|
+
* (answerRefusal), so the server keeps no session its caller has been told
|
|
44
|
+
* is over. Anything else
|
|
45
|
+
* propagates, because only the adapter knows what a crash means (a global
|
|
46
|
+
* error handler, a mock event).
|
|
42
47
|
*
|
|
43
48
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
44
49
|
* definition is the one thing the adapters legitimately do differently (an
|
|
@@ -48,7 +53,7 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
48
53
|
export class LambderApiPipeline {
|
|
49
54
|
apiVersion;
|
|
50
55
|
minApiVersion;
|
|
51
|
-
rateLimits
|
|
56
|
+
rateLimits;
|
|
52
57
|
guards = new LambderApiGuardsEngine();
|
|
53
58
|
idempotency;
|
|
54
59
|
maxRequestPayloadBytes;
|
|
@@ -71,14 +76,19 @@ export class LambderApiPipeline {
|
|
|
71
76
|
}
|
|
72
77
|
// A floor above the version this server stamps would refuse this
|
|
73
78
|
// build's own clients, and the first symptom would be every tab
|
|
74
|
-
// reloading.
|
|
75
|
-
//
|
|
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.
|
|
76
82
|
if (this.apiVersion !== null && compareDottedVersions(this.minApiVersion, this.apiVersion) > 0) {
|
|
77
|
-
|
|
78
|
-
|
|
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}.`);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
|
-
|
|
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);
|
|
82
92
|
this.apiSignatures = options.apiSignatures ?? null;
|
|
83
93
|
this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
|
|
84
94
|
this.onInvalidInput = options.onInvalidInput ?? null;
|
|
@@ -186,10 +196,36 @@ export class LambderApiPipeline {
|
|
|
186
196
|
return answer;
|
|
187
197
|
}
|
|
188
198
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
199
|
+
* A thrown refusal as the answer to a call of `apiName`: checked against
|
|
200
|
+
* the codes the endpoint declares (checkedRefusal, which throws for one it
|
|
201
|
+
* may not send), then rendered. run() answers a step's, a guard's or a
|
|
202
|
+
* handler's refusal with it, and the server a hook's.
|
|
203
|
+
*
|
|
204
|
+
* A refusal telling the caller its session is over (a declared code's
|
|
205
|
+
* flag, or a raise site's own) ends the session the call held: the caller
|
|
206
|
+
* stops sending it, and nothing else would delete it before it expires.
|
|
207
|
+
* The record is deleted and the cookies left alone, which is what the
|
|
208
|
+
* pipeline's own sessionExpired leaves behind (endWithNoSession in the
|
|
209
|
+
* session controller says why the cookies stay).
|
|
210
|
+
*/
|
|
211
|
+
async answerRefusal(apiName, refusals, thrown, ctx) {
|
|
212
|
+
const checked = checkedRefusal(apiName, refusals, thrown);
|
|
213
|
+
if (checked.sessionExpired && ctx.session && this.sessions) {
|
|
214
|
+
await this.sessions.manager.deleteSession(ctx.session);
|
|
215
|
+
ctx.session = null;
|
|
216
|
+
}
|
|
217
|
+
return refusalAnswer(checked, this.apiVersion, ctx.logList);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
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.
|
|
193
229
|
*
|
|
194
230
|
* The floor refuses a request naming a version below minApiVersion,
|
|
195
231
|
* whatever its signature says: the lever for a change the digest cannot
|
|
@@ -211,6 +247,8 @@ export class LambderApiPipeline {
|
|
|
211
247
|
* ready to dispatch.
|
|
212
248
|
*/
|
|
213
249
|
async prepare(request) {
|
|
250
|
+
if (request.invalidEnvelope !== undefined)
|
|
251
|
+
return invalidPayloadAnswer(this.apiVersion, request.invalidEnvelope);
|
|
214
252
|
if (this.minApiVersion !== null && request.version !== null && compareDottedVersions(request.version, this.minApiVersion) < 0) {
|
|
215
253
|
return versionExpiredAnswer(this.apiVersion);
|
|
216
254
|
}
|
|
@@ -250,7 +288,7 @@ export class LambderApiPipeline {
|
|
|
250
288
|
// idempotency engine has already released the claim, as it
|
|
251
289
|
// does for every thrown refusal: a retry runs the handler
|
|
252
290
|
// again and decides afresh.
|
|
253
|
-
answer =
|
|
291
|
+
answer = await this.answerRefusal(definition.name, definition.refusals, err, ctx);
|
|
254
292
|
}
|
|
255
293
|
else if (err instanceof LambderSessionNotFoundError) {
|
|
256
294
|
// No usable session: it ended while the handler held it (a
|
|
@@ -293,8 +331,12 @@ export class LambderApiPipeline {
|
|
|
293
331
|
// Replay fast path: a completed idempotent request answers its stored
|
|
294
332
|
// answer without burning the remaining rate-limit quota or re-running
|
|
295
333
|
// guards. After the session read, because the replay scope is keyed
|
|
296
|
-
// per user, by the session's sessionKey.
|
|
297
|
-
|
|
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;
|
|
298
340
|
const replay = keyedCall ? await this.idempotency.findReplay(definition.name, keyedCall, trace) : null;
|
|
299
341
|
if (replay)
|
|
300
342
|
return replay;
|