lambder 9.0.2 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +29 -14
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client/LambderUploadRunner.d.ts +5 -5
  23. package/dist/client/LambderUploadRunner.js +25 -16
  24. package/dist/client.d.ts +6 -5
  25. package/dist/client.js +2 -1
  26. package/dist/core/Lambder.d.ts +121 -23
  27. package/dist/core/Lambder.js +105 -51
  28. package/dist/core/LambderContext.d.ts +1 -1
  29. package/dist/core/LambderContext.js +5 -0
  30. package/dist/core/LambderCreateOptions.d.ts +58 -15
  31. package/dist/core/LambderCreateOptions.js +10 -0
  32. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  33. package/dist/core/LambderPolicyBuilders.js +10 -8
  34. package/dist/core/LambderResolver.d.ts +1 -1
  35. package/dist/core/LambderResolver.js +1 -1
  36. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  37. package/dist/core/LambderResponseBuilder.js +12 -11
  38. package/dist/index.d.ts +13 -9
  39. package/dist/index.js +6 -2
  40. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  41. package/dist/invoke/LambderInvokeCaller.js +13 -12
  42. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  43. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  44. package/dist/mock/LambderMockApp.d.ts +86 -24
  45. package/dist/mock/LambderMockApp.js +108 -16
  46. package/dist/mock/LambderMockCallRecorder.js +6 -3
  47. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  48. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  49. package/dist/mock/LambderMockFailureInjector.js +18 -4
  50. package/dist/mock/LambderMockTypes.d.ts +34 -11
  51. package/dist/mock.d.ts +4 -3
  52. package/dist/mock.js +4 -3
  53. package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
  54. package/dist/shared/contracts/LambderUploadBucket.js +2 -2
  55. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  56. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  57. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  58. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  59. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  60. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  61. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  62. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  63. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  64. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  65. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  66. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  67. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  68. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  69. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  70. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  71. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  72. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  73. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
  74. package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
  75. package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
  76. package/dist/shared/wire/LambderUploadSchemas.js +18 -7
  77. package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
  78. package/dist/stores/LambderMemoryUploadBucket.js +60 -12
  79. package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
  80. package/dist/stores/LambderS3UploadBucket.js +51 -4
  81. package/dist/testing/LambderTestApp.d.ts +1 -1
  82. package/dist/testing.d.ts +1 -1
  83. package/dist/testing.js +1 -1
  84. 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) => 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
- errorMessage: {
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
- errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content },
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
- errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidIdempotencyKey, content },
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
- errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, content: "This request is already being processed." },
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. The output
8
- * side's counterpart of LambderApiValidationRefusal, with the API it happened
9
- * in, for a crash reporter.
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. The output
8
- * side's counterpart of LambderApiValidationRefusal, with the API it happened
9
- * in, for a crash reporter.
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 ("zodError" in failure) {
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, and a session ended while the handler held it
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, and a session ended while the handler held it
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
- answer = refusalAnswer(err, this.apiVersion, ctx.logList);
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, type LambderAppRefusalMessage } from "../shared/wire/LambderApiRefusal.js";
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 (a policy's own message inherits it unless it sets a more
20
- * specific one) and a Retry-After header. The engine throws it; the mock
21
- * runtime's failure injection throws the same one, so an injected rate
22
- * limit is indistinguishable from a real one.
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?: LambderAppRefusalMessage) => LambderApiRefusal;
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
- /** Envelope errorMessage for refused requests; inherits code "lambder/rate-limited" unless it sets its own. Default: a warning saying too many requests. */
110
- errorMessage?: LambderAppRefusalMessage;
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 errorMessage). For a limit whose key
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, "errorMessage"> : 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 (a policy's own message inherits it unless it sets a more
15
- * specific one) and a Retry-After header. The engine throws it; the mock
16
- * runtime's failure injection throws the same one, so an injected rate
17
- * limit is indistinguishable from a real one.
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
- errorMessage: message ? { code: LAMBDER_REFUSAL_CODES.rateLimited, ...message } : DEFAULT_RATE_LIMIT_REFUSAL,
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?.errorMessage ?? policy.errorMessage);
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.errorMessage),
279
+ refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.refusal),
256
280
  };
257
281
  }
258
282
  /**