lambder 9.0.3 → 10.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 +195 -0
- package/README.md +25 -11
- package/dist/api/LambderApiDefinition.d.ts +9 -0
- package/dist/api/LambderApiEnvelope.d.ts +22 -10
- package/dist/api/LambderApiEnvelope.js +57 -22
- package/dist/api/LambderApiGuards.d.ts +77 -22
- package/dist/api/LambderApiGuards.js +13 -1
- package/dist/api/LambderApiIdempotency.js +4 -4
- package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
- package/dist/api/LambderApiOutputValidationError.js +14 -6
- package/dist/api/LambderApiPipeline.d.ts +2 -1
- package/dist/api/LambderApiPipeline.js +10 -2
- package/dist/api/LambderApiRateLimits.d.ts +15 -11
- package/dist/api/LambderApiRateLimits.js +31 -7
- package/dist/api/LambderApiRefusals.d.ts +204 -0
- package/dist/api/LambderApiRefusals.js +223 -0
- package/dist/api/LambderApiSignature.d.ts +13 -4
- package/dist/api/LambderApiSignature.js +21 -4
- package/dist/build/writeApiOptions.js +7 -5
- package/dist/client/LambderCaller.d.ts +28 -17
- package/dist/client/LambderCaller.js +19 -15
- package/dist/client.d.ts +5 -4
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +121 -23
- package/dist/core/Lambder.js +105 -51
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +5 -0
- package/dist/core/LambderCreateOptions.d.ts +58 -15
- package/dist/core/LambderCreateOptions.js +10 -0
- package/dist/core/LambderPolicyBuilders.d.ts +11 -8
- package/dist/core/LambderPolicyBuilders.js +10 -8
- package/dist/core/LambderResolver.d.ts +1 -1
- package/dist/core/LambderResolver.js +1 -1
- package/dist/core/LambderResponseBuilder.d.ts +11 -9
- package/dist/core/LambderResponseBuilder.js +12 -11
- package/dist/index.d.ts +12 -8
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
- package/dist/invoke/LambderInvokeCaller.js +13 -12
- package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
- package/dist/invoke/LambderInvokeOutcome.js +4 -4
- package/dist/mock/LambderMockApp.d.ts +86 -24
- package/dist/mock/LambderMockApp.js +108 -16
- package/dist/mock/LambderMockCallRecorder.js +6 -3
- package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
- package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
- package/dist/mock/LambderMockFailureInjector.js +18 -4
- package/dist/mock/LambderMockTypes.d.ts +34 -11
- package/dist/mock.d.ts +4 -3
- package/dist/mock.js +4 -3
- package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
- package/dist/shared/wire/LambderApiContract.d.ts +96 -31
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
- package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
- package/dist/shared/wire/LambderApiOptionValues.js +3 -3
- package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
- package/dist/shared/wire/LambderApiOutcome.js +28 -11
- package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
- package/dist/shared/wire/LambderApiRefusal.js +15 -7
- package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
- package/dist/shared/wire/LambderCrashDetail.js +1 -1
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
- package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
- package/dist/shared/wire/LambderObjectPayload.js +24 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
- package/dist/testing/LambderTestApp.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +1 -1
|
@@ -3,6 +3,8 @@ import type { z } from "zod";
|
|
|
3
3
|
import type { LambderApiRequest } from "./LambderApiRequest.js";
|
|
4
4
|
import type { LambderApiCallContext, LambderApiCallTrace } from "./LambderApiCallContext.js";
|
|
5
5
|
import type { LambderApiMode, LambderGuardNamesIn } from "../shared/wire/LambderApiContract.js";
|
|
6
|
+
import type { LambderDeclaredRefuse } from "../shared/wire/LambderApiRefusal.js";
|
|
7
|
+
import { type LambderHandlerRefusalsOf, type LambderRefusalDeclaration } from "./LambderApiRefusals.js";
|
|
6
8
|
import type { LambderGuardsOptionValue } from "../shared/wire/LambderApiOptionValues.js";
|
|
7
9
|
import { LAMBDER_RESPONSE_BRAND } from "../shared/util/LambderResponseBrand.js";
|
|
8
10
|
/**
|
|
@@ -44,6 +46,16 @@ export type LambderGuardPlacement = {
|
|
|
44
46
|
/** Default: "beforeInputValidation". */
|
|
45
47
|
runAt?: LambderGuardRunAt;
|
|
46
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* The refusal codes a guard may refuse with, from the app's vocabulary (the
|
|
51
|
+
* `refusals` option at creation). They join the codes of every API that
|
|
52
|
+
* declares the guard, so each such API's contract lists them and its callers
|
|
53
|
+
* narrow on them. Checked against the vocabulary at creation, since a guard
|
|
54
|
+
* is built before the instance that knows it.
|
|
55
|
+
*/
|
|
56
|
+
export type LambderGuardRefusals<TRefusals extends readonly string[] = readonly string[]> = {
|
|
57
|
+
refusals?: TRefusals;
|
|
58
|
+
};
|
|
47
59
|
/**
|
|
48
60
|
* A named guard, run before the API's own input validation unless it says
|
|
49
61
|
* otherwise (LambderGuardPlacement). Three input modes:
|
|
@@ -85,7 +97,7 @@ export type LambderGuardPlacement = {
|
|
|
85
97
|
* the builder's binding: a server guard would compile into a mock guards map
|
|
86
98
|
* and read `ctx.ip` as undefined, authorizing or refusing everything.
|
|
87
99
|
*/
|
|
88
|
-
export type LambderApiGuard<TInput extends z.ZodType = z.ZodType, TParam = any, TOutput = any, TCtx = any, TSessionCtx = TCtx> = LambderGuardPlacement & ({
|
|
100
|
+
export type LambderApiGuard<TInput extends z.ZodType = z.ZodType, TParam = any, TOutput = any, TCtx = any, TSessionCtx = TCtx> = LambderGuardPlacement & LambderGuardRefusals & ({
|
|
89
101
|
apiInput: TInput;
|
|
90
102
|
guardInput?: undefined;
|
|
91
103
|
session: true;
|
|
@@ -116,65 +128,97 @@ export type LambderApiGuard<TInput extends z.ZodType = z.ZodType, TParam = any,
|
|
|
116
128
|
session?: false;
|
|
117
129
|
handler: LambderGuardHandler<TCtx, undefined, TParam, TOutput>;
|
|
118
130
|
});
|
|
131
|
+
/**
|
|
132
|
+
* The refuse a guard's handler context carries when the guard is built from
|
|
133
|
+
* an init with a declared vocabulary: typed to the guard's own refusals,
|
|
134
|
+
* with data required where a code declares it, and never uncoded where the
|
|
135
|
+
* app requires codes. A builder with no vocabulary type (the standalone
|
|
136
|
+
* lambderGuard, the mock's) adds nothing to the context.
|
|
137
|
+
*/
|
|
138
|
+
type LambderGuardRefuse<TVocabulary, TRefusals extends readonly string[], TCodesRequired extends boolean> = [
|
|
139
|
+
TVocabulary
|
|
140
|
+
] extends [never] ? unknown : {
|
|
141
|
+
refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<TVocabulary, TRefusals[number] & keyof TVocabulary & string>, TCodesRequired>;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* A guard's refusals against the vocabulary of the init that builds it: a
|
|
145
|
+
* code the vocabulary does not hold is refused on the `refusals` option, and
|
|
146
|
+
* an init that declared no vocabulary refuses every code. A builder with no
|
|
147
|
+
* vocabulary type checks nothing here; create() checks the guard against the
|
|
148
|
+
* vocabulary then.
|
|
149
|
+
*/
|
|
150
|
+
type LambderGuardRefusalsKnownIn<TVocabulary, TRefusals extends readonly string[]> = [
|
|
151
|
+
TVocabulary
|
|
152
|
+
] extends [never] ? unknown : [Exclude<TRefusals[number], keyof TVocabulary & string>] extends [never] ? unknown : {
|
|
153
|
+
refusals: readonly (keyof TVocabulary & string)[];
|
|
154
|
+
};
|
|
119
155
|
/**
|
|
120
156
|
* The builder's shape, generic over the two context types a guard may
|
|
121
157
|
* receive: the plain one and the session-typed one. Ties the handler's
|
|
122
158
|
* payload, context, param, and output types together inside one literal
|
|
123
159
|
* and returns the exact shape so type extraction (mode, session, param,
|
|
124
160
|
* output) works downstream. The param type is inferred from the handler's
|
|
125
|
-
* 3rd argument annotation; the output from its return type.
|
|
161
|
+
* 3rd argument annotation; the output from its return type. TVocabulary is
|
|
162
|
+
* the app's refusal vocabulary where the init declared one, which types the
|
|
163
|
+
* handler's ctx.refuse and checks the guard's refusals option.
|
|
126
164
|
*/
|
|
127
|
-
export type LambderGuardBuilder<TCtx, TSessionCtx> = {
|
|
128
|
-
<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
|
|
165
|
+
export type LambderGuardBuilder<TCtx, TSessionCtx, TVocabulary = never, TCodesRequired extends boolean = false> = {
|
|
166
|
+
<TInput extends z.ZodType, TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
129
167
|
apiInput: TInput;
|
|
130
168
|
session: true;
|
|
131
|
-
handler: (ctx: TSessionCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
132
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
169
|
+
handler: (ctx: TSessionCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
170
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
171
|
+
refusals?: TRefusals;
|
|
133
172
|
apiInput: TInput;
|
|
134
173
|
guardInput?: undefined;
|
|
135
174
|
session: true;
|
|
136
175
|
handler: (ctx: TSessionCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
137
176
|
}>;
|
|
138
|
-
<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
|
|
177
|
+
<TInput extends z.ZodType, TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
139
178
|
apiInput: TInput;
|
|
140
|
-
handler: (ctx: TCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
141
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
179
|
+
handler: (ctx: TCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
180
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
181
|
+
refusals?: TRefusals;
|
|
142
182
|
apiInput: TInput;
|
|
143
183
|
guardInput?: undefined;
|
|
144
184
|
session?: undefined;
|
|
145
185
|
handler: (ctx: TCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
146
186
|
}>;
|
|
147
|
-
<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
|
|
187
|
+
<TInput extends z.ZodType, TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
148
188
|
guardInput: TInput;
|
|
149
189
|
session: true;
|
|
150
|
-
handler: (ctx: TSessionCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
151
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
190
|
+
handler: (ctx: TSessionCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
191
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
192
|
+
refusals?: TRefusals;
|
|
152
193
|
guardInput: TInput;
|
|
153
194
|
apiInput?: undefined;
|
|
154
195
|
session: true;
|
|
155
196
|
handler: (ctx: TSessionCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
156
197
|
}>;
|
|
157
|
-
<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
|
|
198
|
+
<TInput extends z.ZodType, TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
158
199
|
guardInput: TInput;
|
|
159
|
-
handler: (ctx: TCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
160
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
200
|
+
handler: (ctx: TCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
201
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
202
|
+
refusals?: TRefusals;
|
|
161
203
|
guardInput: TInput;
|
|
162
204
|
apiInput?: undefined;
|
|
163
205
|
session?: undefined;
|
|
164
206
|
handler: (ctx: TCtx, payload: z.output<TInput>, param: TParam) => TOutput | Promise<TOutput>;
|
|
165
207
|
}>;
|
|
166
|
-
<TParam = undefined, TOutput = void>(guard: {
|
|
208
|
+
<TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
167
209
|
session: true;
|
|
168
|
-
handler: (ctx: TSessionCtx, payload: undefined, param: TParam) => TOutput | Promise<TOutput>;
|
|
169
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
210
|
+
handler: (ctx: TSessionCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: undefined, param: TParam) => TOutput | Promise<TOutput>;
|
|
211
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
212
|
+
refusals?: TRefusals;
|
|
170
213
|
apiInput?: undefined;
|
|
171
214
|
guardInput?: undefined;
|
|
172
215
|
session: true;
|
|
173
216
|
handler: (ctx: TSessionCtx, payload: undefined, param: TParam) => TOutput | Promise<TOutput>;
|
|
174
217
|
}>;
|
|
175
|
-
<TParam = undefined, TOutput = void>(guard: {
|
|
176
|
-
handler: (ctx: TCtx, payload: undefined, param: TParam) => TOutput | Promise<TOutput>;
|
|
177
|
-
} & LambderGuardPlacement & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
218
|
+
<TParam = undefined, TOutput = void, const TRefusals extends readonly string[] = readonly []>(guard: {
|
|
219
|
+
handler: (ctx: TCtx & LambderGuardRefuse<TVocabulary, TRefusals, TCodesRequired>, payload: undefined, param: TParam) => TOutput | Promise<TOutput>;
|
|
220
|
+
} & LambderGuardPlacement & LambderGuardRefusals<TRefusals> & LambderGuardRefusalsKnownIn<TVocabulary, TRefusals> & LambderGuardAnswerCheck<TOutput>): LambderGuardOf<TOutput, {
|
|
221
|
+
refusals?: TRefusals;
|
|
178
222
|
apiInput?: undefined;
|
|
179
223
|
guardInput?: undefined;
|
|
180
224
|
session?: undefined;
|
|
@@ -187,7 +231,7 @@ export type LambderGuardBuilder<TCtx, TSessionCtx> = {
|
|
|
187
231
|
* binds it to its own handler contexts, so mock guards are the same shape
|
|
188
232
|
* as server guards and run through the same engine.
|
|
189
233
|
*/
|
|
190
|
-
export declare const lambderGuardBuilder: <TCtx, TSessionCtx>() => LambderGuardBuilder<TCtx, TSessionCtx>;
|
|
234
|
+
export declare const lambderGuardBuilder: <TCtx, TSessionCtx, TVocabulary = never, TCodesRequired extends boolean = false>(vocabulary?: ReadonlyMap<string, LambderRefusalDeclaration> | null) => LambderGuardBuilder<TCtx, TSessionCtx, TVocabulary, TCodesRequired>;
|
|
191
235
|
/** The param type a guard's handler declares as its 3rd argument; undefined for paramless guards. */
|
|
192
236
|
type LambderGuardParamOf<G> = G extends {
|
|
193
237
|
handler: (...args: infer A) => any;
|
|
@@ -218,7 +262,12 @@ export type LambderGuardMeta<G> = (G extends {
|
|
|
218
262
|
} : {}) & {
|
|
219
263
|
param: LambderGuardParamOf<G>;
|
|
220
264
|
output: LambderGuardOutputOf<G>;
|
|
265
|
+
refusals: LambderGuardRefusalNamesIn<G>;
|
|
221
266
|
};
|
|
267
|
+
/** The refusal codes a built guard declares, as a union; never for a guard that declares none. */
|
|
268
|
+
type LambderGuardRefusalNamesIn<G> = G extends {
|
|
269
|
+
refusals?: infer R;
|
|
270
|
+
} ? (NonNullable<R> extends readonly (infer N extends string)[] ? N : never) : never;
|
|
222
271
|
export type LambderGuardMetaMap<TGuards> = {
|
|
223
272
|
[K in keyof TGuards]: LambderGuardMeta<TGuards[K]>;
|
|
224
273
|
};
|
|
@@ -290,6 +339,12 @@ type GuardInputsEntries<TGuards, TOpt> = {
|
|
|
290
339
|
};
|
|
291
340
|
/** The guardInputs map an API's contract requires clients to send; never when no declared guard uses guardInput mode. */
|
|
292
341
|
export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
|
|
342
|
+
/** The refusal codes an API's declared guards add to its own: a union of code names, never when none of them declares any. */
|
|
343
|
+
export type LambderGuardRefusalNamesOf<TGuards, TOpt> = {
|
|
344
|
+
[K in LambderGuardNamesIn<TOpt> & keyof TGuards]: TGuards[K] extends {
|
|
345
|
+
refusals: infer R;
|
|
346
|
+
} ? R : never;
|
|
347
|
+
}[LambderGuardNamesIn<TOpt> & keyof TGuards];
|
|
293
348
|
/** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
|
|
294
349
|
export declare const toGuardEntries: (value?: LambderGuardsOptionValue) => {
|
|
295
350
|
name: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
|
|
2
|
+
import { assertRefusalCodesDeclared } from "./LambderApiRefusals.js";
|
|
2
3
|
import { LAMBDER_RESPONSE_BRAND, isLambderResponseLike } from "../shared/util/LambderResponseBrand.js";
|
|
3
4
|
/**
|
|
4
5
|
* A guard builder bound to a pair of context types. The server's
|
|
@@ -6,7 +7,14 @@ import { LAMBDER_RESPONSE_BRAND, isLambderResponseLike } from "../shared/util/La
|
|
|
6
7
|
* binds it to its own handler contexts, so mock guards are the same shape
|
|
7
8
|
* as server guards and run through the same engine.
|
|
8
9
|
*/
|
|
9
|
-
export const lambderGuardBuilder = () => ((guard) =>
|
|
10
|
+
export const lambderGuardBuilder = (vocabulary) => ((guard) => {
|
|
11
|
+
// An init's builder knows the vocabulary (null: the init declared
|
|
12
|
+
// none) and refuses a guard naming a code outside it as it is built.
|
|
13
|
+
// The standalone builder passes nothing, and create() checks then.
|
|
14
|
+
if (vocabulary !== undefined)
|
|
15
|
+
assertRefusalCodesDeclared("a guard", Array.isArray(guard.refusals) ? guard.refusals : [], vocabulary);
|
|
16
|
+
return guard;
|
|
17
|
+
});
|
|
10
18
|
/** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
|
|
11
19
|
export const toGuardEntries = (value) => {
|
|
12
20
|
if (value === undefined)
|
|
@@ -64,6 +72,10 @@ export class LambderApiGuardsEngine {
|
|
|
64
72
|
throw new Error(`Lambder: guard "${name}" has no handler function.`);
|
|
65
73
|
if (guardDef.apiInput && guardDef.guardInput)
|
|
66
74
|
throw new Error(`Lambder: guard "${name}" declares both apiInput and guardInput; pick one.`);
|
|
75
|
+
const refusals = guardDef.refusals;
|
|
76
|
+
if (refusals !== undefined && (!Array.isArray(refusals) || refusals.some((code) => typeof code !== "string" || code === ""))) {
|
|
77
|
+
throw new Error(`Lambder: guard "${name}" has a refusals option that is not a list of refusal codes.`);
|
|
78
|
+
}
|
|
67
79
|
const runAt = guardDef.runAt;
|
|
68
80
|
if (runAt !== undefined && runAt !== "beforeInputValidation" && runAt !== "afterInputValidation") {
|
|
69
81
|
throw new Error(`Lambder: guard "${name}" has runAt "${String(runAt)}"; use "beforeInputValidation" (default) or "afterInputValidation".`);
|
|
@@ -77,7 +77,7 @@ const isReplayableAnswer = (answer) => answer.statusCode < 500
|
|
|
77
77
|
*/
|
|
78
78
|
const keyReusedRefusal = (apiName) => new LambderApiRefusal(`Idempotency key reused for a different request to "${apiName}".`, {
|
|
79
79
|
statusCode: 409,
|
|
80
|
-
|
|
80
|
+
refusal: {
|
|
81
81
|
type: "error",
|
|
82
82
|
code: LAMBDER_REFUSAL_CODES.idempotencyKeyReused,
|
|
83
83
|
content: "This request key was already used for a different request. Start the operation again.",
|
|
@@ -110,7 +110,7 @@ const requestFingerprintOf = async (payload) => {
|
|
|
110
110
|
const content = "Invalid request payload: nested too deep to fingerprint.";
|
|
111
111
|
throw new LambderApiRefusal(content, {
|
|
112
112
|
statusCode: 400,
|
|
113
|
-
|
|
113
|
+
refusal: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content },
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
try {
|
|
@@ -193,7 +193,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
193
193
|
const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
|
|
194
194
|
throw new LambderApiRefusal(content, {
|
|
195
195
|
statusCode: 400,
|
|
196
|
-
|
|
196
|
+
refusal: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidIdempotencyKey, content },
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
return rawKey;
|
|
@@ -311,7 +311,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
311
311
|
if (begun.state === "pending") {
|
|
312
312
|
throw new LambderApiRefusal(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
313
313
|
statusCode: 409,
|
|
314
|
-
|
|
314
|
+
refusal: { type: "warning", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, content: "This request is already being processed." },
|
|
315
315
|
});
|
|
316
316
|
}
|
|
317
317
|
// The other replay path: the original settled between this request's
|
|
@@ -4,9 +4,11 @@ import { z } from "zod";
|
|
|
4
4
|
* the declared shape, so the payload was not sent and the call is answered
|
|
5
5
|
* as a crash. Either the schema rejected the payload, or the parse threw: an
|
|
6
6
|
* async refinement or transform (the output is parsed synchronously, so an
|
|
7
|
-
* output schema cannot be async), or a transform that threw.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* output schema cannot be async), or a transform that threw. Or the parsed
|
|
8
|
+
* output is not an object or an array, which every answer is (see
|
|
9
|
+
* isObjectPayload). The output side's counterpart of
|
|
10
|
+
* LambderApiValidationRefusal, with the API it happened in, for a crash
|
|
11
|
+
* reporter.
|
|
10
12
|
*
|
|
11
13
|
* Its own class because it is a crash after the fact: the handler ran to
|
|
12
14
|
* its answer, whatever it wrote or charged along the way included. The
|
|
@@ -22,11 +24,15 @@ export declare class LambderApiOutputValidationError extends Error {
|
|
|
22
24
|
readonly zodError: z.ZodError | null;
|
|
23
25
|
/**
|
|
24
26
|
* `failure` is how the parse ended: `{ zodError }` when the schema
|
|
25
|
-
* rejected the payload, `{ thrown }` when parsing it threw
|
|
27
|
+
* rejected the payload, `{ thrown }` when parsing it threw, `{ notObject }`
|
|
28
|
+
* when what it produced is not an object or an array (the kind named, the
|
|
29
|
+
* value never).
|
|
26
30
|
*/
|
|
27
31
|
constructor(apiName: string, failure: {
|
|
28
32
|
zodError: z.ZodError;
|
|
29
33
|
} | {
|
|
30
34
|
thrown: unknown;
|
|
35
|
+
} | {
|
|
36
|
+
notObject: string;
|
|
31
37
|
});
|
|
32
38
|
}
|
|
@@ -4,9 +4,11 @@ import { z } from "zod";
|
|
|
4
4
|
* the declared shape, so the payload was not sent and the call is answered
|
|
5
5
|
* as a crash. Either the schema rejected the payload, or the parse threw: an
|
|
6
6
|
* async refinement or transform (the output is parsed synchronously, so an
|
|
7
|
-
* output schema cannot be async), or a transform that threw.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* output schema cannot be async), or a transform that threw. Or the parsed
|
|
8
|
+
* output is not an object or an array, which every answer is (see
|
|
9
|
+
* isObjectPayload). The output side's counterpart of
|
|
10
|
+
* LambderApiValidationRefusal, with the API it happened in, for a crash
|
|
11
|
+
* reporter.
|
|
10
12
|
*
|
|
11
13
|
* Its own class because it is a crash after the fact: the handler ran to
|
|
12
14
|
* its answer, whatever it wrote or charged along the way included. The
|
|
@@ -22,11 +24,17 @@ export class LambderApiOutputValidationError extends Error {
|
|
|
22
24
|
zodError;
|
|
23
25
|
/**
|
|
24
26
|
* `failure` is how the parse ended: `{ zodError }` when the schema
|
|
25
|
-
* rejected the payload, `{ thrown }` when parsing it threw
|
|
27
|
+
* rejected the payload, `{ thrown }` when parsing it threw, `{ notObject }`
|
|
28
|
+
* when what it produced is not an object or an array (the kind named, the
|
|
29
|
+
* value never).
|
|
26
30
|
*/
|
|
27
31
|
constructor(apiName, failure) {
|
|
28
32
|
let message;
|
|
29
|
-
if ("
|
|
33
|
+
if ("notObject" in failure) {
|
|
34
|
+
message = `Lambder: API "${apiName}" answered ${failure.notObject}, and an API answers with an object or an array, `
|
|
35
|
+
+ "so it was not sent. An API with nothing to answer declares `output: z.object({})` and returns `{}`.";
|
|
36
|
+
}
|
|
37
|
+
else if ("zodError" in failure) {
|
|
30
38
|
// Paths and messages only: an issue's message names the expected
|
|
31
39
|
// type, and the values themselves stay out of a crash report.
|
|
32
40
|
const issues = failure.zodError.issues.slice(0, 3).map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
@@ -42,7 +50,7 @@ export class LambderApiOutputValidationError extends Error {
|
|
|
42
50
|
message = `Lambder: API "${apiName}" answered a payload its output schema threw on while parsing it (a transform that threw, `
|
|
43
51
|
+ "or an async step a synchronous parse cannot run), so it was not sent. What the schema threw is the cause.";
|
|
44
52
|
}
|
|
45
|
-
super(message, { cause: "zodError" in failure ? failure.zodError : failure.thrown });
|
|
53
|
+
super(message, "notObject" in failure ? undefined : { cause: "zodError" in failure ? failure.zodError : failure.thrown });
|
|
46
54
|
this.name = "LambderApiOutputValidationError";
|
|
47
55
|
this.apiName = apiName;
|
|
48
56
|
this.zodError = "zodError" in failure ? failure.zodError : null;
|
|
@@ -105,7 +105,8 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
105
105
|
* Steps whose subsystem is not configured are skipped. A LambderApiRefusal
|
|
106
106
|
* thrown by any step, guard or handler is rendered here, in one place: a
|
|
107
107
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
108
|
-
* envelope
|
|
108
|
+
* envelope once checked against the endpoint's declared codes
|
|
109
|
+
* (checkedRefusal), and a session ended while the handler held it
|
|
109
110
|
* (LambderSessionNotFoundError) as sessionExpired. Anything else propagates, because only the adapter knows what a
|
|
110
111
|
* crash means (a global error handler, a mock event).
|
|
111
112
|
*
|
|
@@ -3,6 +3,7 @@ import { lookupApiSignature } from "../shared/wire/LambderApiSignatureMap.js";
|
|
|
3
3
|
import { apiNotFoundAnswer, invalidPayloadAnswer, refusalAnswer, sessionExpiredAnswer, validationAnswer, versionExpiredAnswer, } from "./LambderApiEnvelope.js";
|
|
4
4
|
import { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./LambderApiValidationRefusal.js";
|
|
5
5
|
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
6
|
+
import { checkedRefusal } from "./LambderApiRefusals.js";
|
|
6
7
|
import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/wire/LambderRequestPayload.js";
|
|
7
8
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
8
9
|
import { compareDottedVersions, isDottedVersion } from "../shared/wire/LambderVersionOrder.js";
|
|
@@ -34,7 +35,8 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
34
35
|
* Steps whose subsystem is not configured are skipped. A LambderApiRefusal
|
|
35
36
|
* thrown by any step, guard or handler is rendered here, in one place: a
|
|
36
37
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
37
|
-
* envelope
|
|
38
|
+
* envelope once checked against the endpoint's declared codes
|
|
39
|
+
* (checkedRefusal), and a session ended while the handler held it
|
|
38
40
|
* (LambderSessionNotFoundError) as sessionExpired. Anything else propagates, because only the adapter knows what a
|
|
39
41
|
* crash means (a global error handler, a mock event).
|
|
40
42
|
*
|
|
@@ -242,7 +244,13 @@ export class LambderApiPipeline {
|
|
|
242
244
|
answer = await this.refuseInput(err, ctx, request);
|
|
243
245
|
}
|
|
244
246
|
else if (isLambderApiRefusal(err)) {
|
|
245
|
-
|
|
247
|
+
// Checked against the endpoint's declared codes, and its data
|
|
248
|
+
// parsed, before it becomes an answer; a refusal the endpoint
|
|
249
|
+
// may not send throws from here as a crash. By this point the
|
|
250
|
+
// idempotency engine has already released the claim, as it
|
|
251
|
+
// does for every thrown refusal: a retry runs the handler
|
|
252
|
+
// again and decides afresh.
|
|
253
|
+
answer = refusalAnswer(checkedRefusal(definition.name, definition.refusals, err), this.apiVersion, ctx.logList);
|
|
246
254
|
}
|
|
247
255
|
else if (err instanceof LambderSessionNotFoundError) {
|
|
248
256
|
// No usable session: it ended while the handler held it (a
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
|
|
2
|
-
import type { LambderRateLimitOptionValue, LambderRateLimitOverride } from "../shared/wire/LambderApiOptionValues.js";
|
|
2
|
+
import type { LambderRateLimitMessage, LambderRateLimitOptionValue, LambderRateLimitOverride } from "../shared/wire/LambderApiOptionValues.js";
|
|
3
3
|
import type { z } from "zod";
|
|
4
4
|
import type { LambderApiRequest } from "./LambderApiRequest.js";
|
|
5
5
|
import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
6
6
|
import { type LambderRateLimiter, type LambderRateLimitExceeded, type LambderRateLimitPolicy } from "../shared/contracts/LambderRateLimiter.js";
|
|
7
7
|
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
8
|
-
import { LambderApiRefusal
|
|
8
|
+
import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
9
9
|
import type { LambderNonEmptyOptionMap } from "../shared/util/LambderTypeUtilities.js";
|
|
10
10
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
11
11
|
/** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
|
|
@@ -16,12 +16,16 @@ export declare const DEFAULT_RATE_LIMIT_REFUSAL: {
|
|
|
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
|
-
*
|
|
19
|
+
* framework code, whatever message a policy or an API wrote, and a
|
|
20
|
+
* Retry-After header. The engine throws it; the mock runtime's failure
|
|
21
|
+
* injection throws the same one, so an injected rate limit is
|
|
22
|
+
* indistinguishable from a real one.
|
|
23
|
+
*
|
|
24
|
+
* The code goes on after the message, so a message that carries one anyway
|
|
25
|
+
* (a plain-JS caller, a cast) cannot replace it: a rate limit is never a code
|
|
26
|
+
* an endpoint declares, and the pipeline would refuse to send one it did not.
|
|
23
27
|
*/
|
|
24
|
-
export declare const rateLimitRefusal: (detail: string, retryAfterSeconds: number, message?:
|
|
28
|
+
export declare const rateLimitRefusal: (detail: string, retryAfterSeconds: number, message?: LambderRateLimitMessage) => LambderApiRefusal;
|
|
25
29
|
/**
|
|
26
30
|
* A custom rate-limit key. `apiInput` names the fields of the API's OWN
|
|
27
31
|
* payload the key derives from: that slice is validated against the raw
|
|
@@ -106,8 +110,8 @@ export type LambderApiRateLimitPolicyConfig<TCtx = any> = LambderRateLimitPolicy
|
|
|
106
110
|
* takes it: `per: "ip"` and `per: "session"` have one place each.
|
|
107
111
|
*/
|
|
108
112
|
chargeAt?: LambderRateLimitChargeAt;
|
|
109
|
-
/**
|
|
110
|
-
|
|
113
|
+
/** The refusal's message for refused requests: its type, title and content, under the code "lambder/rate-limited". Default: a warning saying too many requests. */
|
|
114
|
+
refusal?: LambderRateLimitMessage;
|
|
111
115
|
};
|
|
112
116
|
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig<any>>> = {
|
|
113
117
|
/** Your limiter instance (LambderDdbRateLimiter, LambderMemoryRateLimiter, or your own); its table and keyPrefix apply as configured on it. */
|
|
@@ -188,7 +192,7 @@ export type LambderRateLimitCheckResult = false | (LambderRateLimitExceeded & {
|
|
|
188
192
|
/**
|
|
189
193
|
* `ctx.rateLimit(policy, key?)`: counts one attempt against a named policy
|
|
190
194
|
* and, when it is over, refuses the request the way a declared limit does (a
|
|
191
|
-
* 429 with Retry-After and the policy's
|
|
195
|
+
* 429 with Retry-After and the policy's refusal). For a limit whose key
|
|
192
196
|
* only the handler knows, or one to charge only on some paths through it.
|
|
193
197
|
*
|
|
194
198
|
* The key tuple is NoInfer: left inferable, a key passed where none belongs
|
|
@@ -216,7 +220,7 @@ export type LambderRateLimitChargeResult = {
|
|
|
216
220
|
};
|
|
217
221
|
type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
|
|
218
222
|
budget: "perPolicy";
|
|
219
|
-
} ? Pick<LambderRateLimitOverride, "
|
|
223
|
+
} ? Pick<LambderRateLimitOverride, "refusal"> : LambderRateLimitOverride;
|
|
220
224
|
/** The map form's full shape: every referable policy name, each carrying its own override. */
|
|
221
225
|
type LambderRateLimitMap<TPolicies, TPayload, TIncludeSession extends boolean> = {
|
|
222
226
|
readonly [K in LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
|
|
@@ -11,16 +11,37 @@ const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
|
11
11
|
export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
|
|
12
12
|
/**
|
|
13
13
|
* The refusal a rate-limited call answers with: a 429 envelope carrying the
|
|
14
|
-
* framework code
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* framework code, whatever message a policy or an API wrote, and a
|
|
15
|
+
* Retry-After header. The engine throws it; the mock runtime's failure
|
|
16
|
+
* injection throws the same one, so an injected rate limit is
|
|
17
|
+
* indistinguishable from a real one.
|
|
18
|
+
*
|
|
19
|
+
* The code goes on after the message, so a message that carries one anyway
|
|
20
|
+
* (a plain-JS caller, a cast) cannot replace it: a rate limit is never a code
|
|
21
|
+
* an endpoint declares, and the pipeline would refuse to send one it did not.
|
|
18
22
|
*/
|
|
19
23
|
export const rateLimitRefusal = (detail, retryAfterSeconds, message) => new LambderApiRefusal(detail, {
|
|
20
|
-
|
|
24
|
+
refusal: message ? { type: message.type, ...(message.title !== undefined ? { title: message.title } : {}), content: message.content, code: LAMBDER_REFUSAL_CODES.rateLimited } : DEFAULT_RATE_LIMIT_REFUSAL,
|
|
21
25
|
statusCode: 429,
|
|
22
26
|
headers: { "Retry-After": String(Math.max(1, Math.floor(retryAfterSeconds))) },
|
|
23
27
|
});
|
|
28
|
+
/**
|
|
29
|
+
* A rate-limit message carries no code of its own and no data: its code is
|
|
30
|
+
* always the framework's, which rateLimitRefusal sets, and a code there would
|
|
31
|
+
* read as one the app chose for clients to branch on when it never reaches
|
|
32
|
+
* them.
|
|
33
|
+
*/
|
|
34
|
+
const assertRateLimitMessage = (where, message) => {
|
|
35
|
+
if (message === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const { code, data } = message;
|
|
38
|
+
if (code !== undefined) {
|
|
39
|
+
throw new Error(`Lambder: ${where} sets a refusal code. A rate-limit refusal is always "${LAMBDER_REFUSAL_CODES.rateLimited}", so an endpoint never has to declare it; write the type, title and content only.`);
|
|
40
|
+
}
|
|
41
|
+
if (data !== undefined) {
|
|
42
|
+
throw new Error(`Lambder: ${where} sets refusal data. A rate-limit refusal carries none.`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
24
45
|
/**
|
|
25
46
|
* A key builder bound to a context type, the counterpart of
|
|
26
47
|
* lambderGuardBuilder. The server's lambderRateLimitKey() is this bound to
|
|
@@ -143,6 +164,7 @@ export class LambderApiRateLimitsEngine {
|
|
|
143
164
|
throw new Error(`Lambder: rate-limit policy "${name}" sets chargeAt, which only a policy keyed by a { apiInput?, handler } key takes: per "ip" and per "session" each run at one fixed place, and a policy without per is charged by the code that names it.`);
|
|
144
165
|
}
|
|
145
166
|
}
|
|
167
|
+
assertRateLimitMessage(`rate-limit policy "${name}"`, policy.refusal);
|
|
146
168
|
const budget = policy.budget;
|
|
147
169
|
if (budget !== undefined && budget !== "perApi" && budget !== "perPolicy") {
|
|
148
170
|
throw new Error(`Lambder: rate-limit policy "${name}" has budget "${String(budget)}"; use "perApi" (default: each referencing API counts separately) or "perPolicy" (one counter shared by every referencing API).`);
|
|
@@ -191,6 +213,8 @@ export class LambderApiRateLimitsEngine {
|
|
|
191
213
|
}
|
|
192
214
|
if (override)
|
|
193
215
|
assertWindowLimits(`API "${apiName}" override of rate-limit policy "${name}"`, override);
|
|
216
|
+
if (override)
|
|
217
|
+
assertRateLimitMessage(`API "${apiName}" override of rate-limit policy "${name}"`, override.refusal);
|
|
194
218
|
if (override && hasWindowOverride(override) && !RATE_LIMIT_WINDOW_KEYS.some((key) => (override[key] ?? policy[key]))) {
|
|
195
219
|
throw new Error(`Lambder: API "${apiName}" overrides rate-limit policy "${name}" down to no enforced window, which limits nothing. ` +
|
|
196
220
|
`A policy is required to declare a window; an override may not take the last one away.`);
|
|
@@ -225,7 +249,7 @@ export class LambderApiRateLimitsEngine {
|
|
|
225
249
|
const limits = windowsOf(policy, override);
|
|
226
250
|
const exceeded = await this.countAttempt(name, trackerKeyOf(name, policy, apiName, key), limits, `API "${apiName}"`);
|
|
227
251
|
if (exceeded) {
|
|
228
|
-
throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, this.retryAfterOf(exceeded), override?.
|
|
252
|
+
throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, this.retryAfterOf(exceeded), override?.refusal ?? policy.refusal);
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
255
|
}
|
|
@@ -252,7 +276,7 @@ export class LambderApiRateLimitsEngine {
|
|
|
252
276
|
const retryAfterSeconds = this.retryAfterOf(exceeded);
|
|
253
277
|
return {
|
|
254
278
|
checkResult: { ...exceeded, retryAfterSeconds },
|
|
255
|
-
refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.
|
|
279
|
+
refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.refusal),
|
|
256
280
|
};
|
|
257
281
|
}
|
|
258
282
|
/**
|