lambder 4.0.1 → 4.2.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.
@@ -10,19 +10,22 @@ import { isLambderApiError } from "../shared/LambderApiError.js";
10
10
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
12
12
  /**
13
- * Main Lambder class for building type-safe serverless APIs
13
+ * Main Lambder class for building type-safe serverless APIs. Create
14
+ * instances with initLambder<SessionData>().create({...}) (see below): the
15
+ * whole configuration, including the typed policy layer, is given at
16
+ * construction, and only registration (routes, apis, hooks, use) chains.
14
17
  *
15
18
  * @typeParam TSessionData - Type of session data stored in DynamoDB
16
19
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
17
- * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
18
- * @typeParam _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
19
- * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
20
+ * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
21
+ * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
22
+ * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
20
23
  *
21
24
  * @example
22
25
  * ```typescript
23
26
  * interface SessionData { userId: string; role: string; }
24
27
  *
25
- * const lambder = new Lambder<SessionData>({ apiPath: '/api' })
28
+ * const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
26
29
  * .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
27
30
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
28
31
  * ```
@@ -73,26 +76,33 @@ export default class Lambder {
73
76
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
74
77
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
75
78
  };
76
- }
77
- enableCors(config) {
78
- this.corsConfig = config === true ? {} : (config === false ? null : config);
79
- return this;
80
- }
81
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
82
- this.lambderSessionManager = new LambderSessionManager({
83
- tableName, tableRegion,
84
- partitionKey: partitionKey ?? "pk",
85
- sortKey: sortKey ?? "sk",
86
- sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
87
- dataRefresh,
88
- });
89
- this.sessionCookieOptions = cookie ?? {};
90
- return this;
91
- }
92
- setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
93
- this.sessionTokenCookieKey = sessionTokenCookieKey;
94
- this.sessionCsrfCookieKey = sessionCsrfCookieKey;
95
- return this;
79
+ if (options.cors !== undefined && options.cors !== false) {
80
+ this.corsConfig = options.cors === true ? {} : options.cors;
81
+ }
82
+ if (options.session) {
83
+ const session = options.session;
84
+ this.lambderSessionManager = new LambderSessionManager({
85
+ tableName: session.tableName,
86
+ tableRegion: session.tableRegion,
87
+ partitionKey: session.partitionKey ?? "pk",
88
+ sortKey: session.sortKey ?? "sk",
89
+ sessionSalt: session.sessionSalt,
90
+ enableSlidingExpiration: session.enableSlidingExpiration,
91
+ slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
92
+ dataRefresh: session.dataRefresh,
93
+ });
94
+ this.sessionCookieOptions = session.cookie ?? {};
95
+ if (session.tokenCookieKey)
96
+ this.sessionTokenCookieKey = session.tokenCookieKey;
97
+ if (session.csrfCookieKey)
98
+ this.sessionCsrfCookieKey = session.csrfCookieKey;
99
+ }
100
+ if (options.rateLimits)
101
+ this.getOrCreatePolicyEngine().setRateLimits(options.rateLimits);
102
+ if (options.guards)
103
+ this.getOrCreatePolicyEngine().addGuards(options.guards);
104
+ if (options.idempotency)
105
+ this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
96
106
  }
97
107
  setRouteFallbackHandler(routeFallbackHandler) {
98
108
  this.routeFallbackHandler = routeFallbackHandler;
@@ -162,56 +172,24 @@ export default class Lambder {
162
172
  }
163
173
  return response;
164
174
  }
165
- // ---------------------------------------------------------------------
166
- // Declarative API policies (rate limits, guards, idempotency)
167
- // ---------------------------------------------------------------------
168
- /**
169
- * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
170
- * instance plus named policies, each declaring its windows and what one
171
- * counter tracks (`per`: "ip", "session", or a custom key function).
172
- * APIs then reference policies by name via the `rateLimit` option; the
173
- * returned type narrows so only declared names are accepted, and
174
- * policies keyed per "session" are only referable from addSessionApi.
175
- * Callable once; call it before the API registrations that use it.
176
- */
177
- enableApiRateLimits(config) {
178
- this.getOrCreatePolicyEngine().setRateLimits(config);
179
- return this;
180
- }
181
- /**
182
- * Wire declarative idempotency: your LambderDdbIdempotency instance plus
183
- * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
184
- * the option is a type error until this is called. Requests carrying a
185
- * client `idempotencyKey` (sent by LambderCaller) claim an
186
- * identity+api+key scope atomically: concurrent duplicates refuse with
187
- * 409, replays of a completed request return the stored response, and a
188
- * crashed original releases its claim. Callable once.
189
- */
190
- enableApiIdempotency(config) {
191
- this.getOrCreatePolicyEngine().setIdempotency(config);
192
- return this;
193
- }
194
- /**
195
- * Define named guards that APIs reference (typed) via the `guards`
196
- * option. Each guard is built with lambderGuard(): its input mode
197
- * (apiInput slice of the API's own payload, a separate client-sent
198
- * guardInput, or none), an optional `session: true` requirement, an
199
- * optional parameter APIs pass in their declaration (`guards: { name:
200
- * param }`), and an optional return value that lands typed on the
201
- * handler's ctx.guardData[name]. Guards run before input validation, in
202
- * the order the API declares them; a handler refuses by throwing
203
- * (typically refuse()). Callable multiple times so domain modules can
204
- * contribute their own; names must not collide.
205
- */
206
- defineApiGuards(guards) {
207
- this.getOrCreatePolicyEngine().addGuards(guards);
208
- return this;
209
- }
210
175
  getOrCreatePolicyEngine() {
176
+ // Late-bound: the handler may be set after creation, so the engine
177
+ // asks at request time rather than capturing it here.
211
178
  if (!this.apiPolicyEngine)
212
- this.apiPolicyEngine = new LambderApiPolicyEngine();
179
+ this.apiPolicyEngine = new LambderApiPolicyEngine((ctx, resolver, zodError) => this.inputValidationRefusal(ctx, resolver, zodError));
213
180
  return this.apiPolicyEngine;
214
181
  }
182
+ /**
183
+ * The response for a rejected input: the app's
184
+ * setApiInputValidationErrorHandler when set, otherwise the standard 422
185
+ * body. The API's own schema and every preflight slice (guard inputs,
186
+ * rate-limit keys) answer through here, so one failure has one shape.
187
+ */
188
+ async inputValidationRefusal(ctx, resolver, zodError) {
189
+ if (this.apiInputValidationErrorHandler)
190
+ return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
191
+ return resolver.json({ error: "Input validation failed", zodError }, { statusCode: 422 });
192
+ }
215
193
  /** Registration-time checks shared by addApi/addSessionApi. */
216
194
  assertApiRegistration(name, mode, options) {
217
195
  if (this.registeredApiNames.has(name)) {
@@ -222,7 +200,7 @@ export default class Lambder {
222
200
  if (!usesPolicies)
223
201
  return;
224
202
  if (!this.apiPolicyEngine) {
225
- throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of enableApiRateLimits()/defineApiGuards()/enableApiIdempotency() was called first.`);
203
+ throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of rateLimits/guards/idempotency was configured at creation.`);
226
204
  }
227
205
  this.apiPolicyEngine.assertRegistration(name, mode, options);
228
206
  }
@@ -268,12 +246,8 @@ export default class Lambder {
268
246
  if (this.apiPolicyEngine)
269
247
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
270
248
  const inputResult = schema.input.safeParse(ctx.apiPayload);
271
- if (!inputResult.success) {
272
- if (this.apiInputValidationErrorHandler) {
273
- return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
274
- }
275
- return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
276
- }
249
+ if (!inputResult.success)
250
+ return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
277
251
  ctx.apiPayload = inputResult.data;
278
252
  const run = async () => await handler(ctx, resolver);
279
253
  if (this.apiPolicyEngine && schema.idempotency)
@@ -300,12 +274,8 @@ export default class Lambder {
300
274
  if (this.apiPolicyEngine)
301
275
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
302
276
  const inputResult = schema.input.safeParse(ctx.apiPayload);
303
- if (!inputResult.success) {
304
- if (this.apiInputValidationErrorHandler) {
305
- return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
306
- }
307
- return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
308
- }
277
+ if (!inputResult.success)
278
+ return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
309
279
  ctx.apiPayload = inputResult.data;
310
280
  const run = async () => await handler(ctx, resolver);
311
281
  if (this.apiPolicyEngine && schema.idempotency)
@@ -345,7 +315,7 @@ export default class Lambder {
345
315
  }
346
316
  getSessionController(ctx) {
347
317
  if (!this.lambderSessionManager)
348
- throw new Error("Session is not enabled. Use lambder.enableDdbSession(...) to enable.");
318
+ throw new Error("Session is not enabled. Configure the session option at creation.");
349
319
  return new LambderSessionController({
350
320
  lambderSessionManager: this.lambderSessionManager,
351
321
  sessionTokenCookieKey: this.sessionTokenCookieKey,
@@ -376,7 +346,10 @@ export default class Lambder {
376
346
  ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
377
347
  ...(err.notAuthorized ? { notAuthorized: true } : {}),
378
348
  ...(err.sessionExpired ? { sessionExpired: true } : {}),
379
- }, err.statusCode !== undefined ? { statusCode: err.statusCode } : undefined);
349
+ }, {
350
+ ...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
351
+ ...(err.headers ? { headers: err.headers } : {}),
352
+ });
380
353
  }
381
354
  getHandler() {
382
355
  return ((event, context) => Lambder.isHttpEvent(event)
@@ -450,7 +423,7 @@ export default class Lambder {
450
423
  if (isAPI) {
451
424
  if (this.apiFallbackHandler)
452
425
  return await this.apiFallbackHandler(ctx, resolver);
453
- return resolver.api(null, { errorMessage: "API not found." });
426
+ return resolver.api(null, { errorMessage: { type: "warning", content: "API not found." } });
454
427
  }
455
428
  if (this.publicFilesHandler) {
456
429
  const fileResponse = await this.publicFilesHandler.handle(ctx);
@@ -582,6 +555,46 @@ export default class Lambder {
582
555
  }
583
556
  }
584
557
  }
558
+ /**
559
+ * The canonical way to create an instance: fix the session data type first,
560
+ * then create with the full configuration in one declaration; the policy,
561
+ * guard, and idempotency types are INFERRED from the options, so the
562
+ * instance is born fully typed and `typeof lambderApp` is the annotation
563
+ * type for api modules. No enable/define chain exists, so there are no
564
+ * ordering rules and nothing can be half-configured.
565
+ *
566
+ * ```typescript
567
+ * // app.ts (imports no api modules, so modules can import the type back)
568
+ * export const lambderApp = initLambder<SessionData>().create({
569
+ * apiPath: "/api",
570
+ * session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
571
+ * rateLimits: { limiter, policies },
572
+ * guards,
573
+ * idempotency: { store },
574
+ * });
575
+ * export type AppLambder = typeof lambderApp;
576
+ *
577
+ * // orders.ts
578
+ * export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
579
+ *
580
+ * // index.ts: registration only
581
+ * const lambder = lambderApp.addHook(...).use(orderApi)...;
582
+ * export const handler = lambder.getHandler();
583
+ * ```
584
+ *
585
+ * Why curried (`initLambder<S>().create(...)` rather than
586
+ * `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
587
+ * call, so explicitly passing the session data type to the constructor
588
+ * would silently WIDEN the inferred policy and guard types to their {}
589
+ * defaults. Fixing the session type in the first call lets the second call
590
+ * infer everything else from the options. `new Lambder(options)` remains
591
+ * for untyped or session-data-free instances.
592
+ */
593
+ export const initLambder = () => ({
594
+ create(options) {
595
+ return new Lambder(options);
596
+ },
597
+ });
585
598
  /** Rebuild the query string from the API Gateway event for redirects. */
586
599
  const buildQueryString = (ctx) => {
587
600
  if (isV2HttpEvent(ctx.event)) {
@@ -6,6 +6,12 @@ export type LambderCorsConfig = {
6
6
  credentials?: boolean;
7
7
  methods?: string[];
8
8
  allowHeaders?: string[];
9
+ /**
10
+ * Response headers a cross-origin browser caller may read. Default:
11
+ * ["Retry-After"], so rate-limit refusals stay readable (it is not on the
12
+ * CORS safelist, and a hidden header reads as null, not as an error).
13
+ */
14
+ exposeHeaders?: string[];
9
15
  maxAge?: number;
10
16
  };
11
17
  /** Mutate the response with the CORS headers the config allows for this request. */
@@ -27,4 +27,9 @@ export const applyCorsHeaders = (config, ctx, response, isPreflight) => {
27
27
  if (config.maxAge !== undefined)
28
28
  response.setHeader("Access-Control-Max-Age", String(config.maxAge));
29
29
  }
30
+ else {
31
+ const exposeHeaders = config.exposeHeaders ?? ["Retry-After"];
32
+ if (exposeHeaders.length)
33
+ response.setHeader("Access-Control-Expose-Headers", exposeHeaders.join(", "));
34
+ }
30
35
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import Lambder from './core/Lambder.js';
2
2
  export default Lambder;
3
+ export { initLambder } from './core/Lambder.js';
3
4
  export { default as LambderCaller } from "./client/LambderCaller.js";
4
5
  export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
5
6
  export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
@@ -13,7 +14,7 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
13
14
  export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
14
15
  export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
15
16
  export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
16
- export type { LambderRouteMatcher, LambderCorsConfig, LambderConstructorOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, LambderApp, } from "./core/Lambder.js";
17
+ export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
17
18
  export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
18
19
  export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
19
20
  export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
@@ -22,13 +23,13 @@ export { LambderSessionDataRefreshError, LambderSessionReadError } from "./sessi
22
23
  export { LambderDdbCache } from "./stores/LambderDdbCache.js";
23
24
  export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
24
25
  export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
25
- export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy, LambderRateLimitExceededMap, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
26
+ export type { LambderDdbRateLimiterOptions, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
26
27
  export { LambderDdbIdempotency } from "./stores/LambderDdbIdempotency.js";
27
28
  export type { LambderDdbIdempotencyOptions, LambderIdempotencyBeginResult, LambderIdempotencyDoneRecord, } from "./stores/LambderDdbIdempotency.js";
28
29
  export { lambderGuard } from "./policies/LambderApiGuards.js";
29
30
  export type { LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderAllowedGuardNames, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardsOptionValue, LambderGuardDataOf, LambderGuardInputsOf, } from "./policies/LambderApiGuards.js";
30
31
  export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
31
- export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, } from "./policies/LambderApiRateLimits.js";
32
+ export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderRateLimitOverride, LambderRateLimitOption, LambderRateLimitOptionValue, } from "./policies/LambderApiRateLimits.js";
32
33
  export type { LambderApiIdempotencyConfig } from "./policies/LambderApiIdempotency.js";
33
34
  export { createLambderI18n } from "./shared/LambderI18n.js";
34
35
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import Lambder from './core/Lambder.js';
2
2
  export default Lambder;
3
+ export { initLambder } from './core/Lambder.js';
3
4
  export { default as LambderCaller } from "./client/LambderCaller.js";
4
5
  // Typed API refusals (isomorphic: shared code may throw them from anywhere)
5
6
  export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
@@ -1,6 +1,7 @@
1
1
  import type { z } from "zod";
2
2
  import type { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
3
3
  import type LambderResolver from "../core/LambderResolver.js";
4
+ import type { LambderResponse } from "../core/LambderResponse.js";
4
5
  /**
5
6
  * A named guard, run before the API's own input validation. Three input
6
7
  * modes:
@@ -30,9 +31,10 @@ import type LambderResolver from "../core/LambderResolver.js";
30
31
  * the API handler's context as `ctx.guardData[guardName]`, fully typed.
31
32
  * Guards that return nothing never appear in guardData.
32
33
  *
33
- * A validation failure answers the standard 422 shape, and the handler
34
- * refuses by throwing (typically refuse()/LambderApiError). Build with
35
- * lambderGuard() so the handler's payload/ctx/param types line up.
34
+ * A validation failure answers like the API's own input validation (the
35
+ * app's setApiInputValidationErrorHandler when set, else the standard 422),
36
+ * and the handler refuses by throwing (typically refuse()/LambderApiError).
37
+ * Build with lambderGuard() so the handler's payload/ctx/param types line up.
36
38
  */
37
39
  export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny, TParam = any, TOutput = any> = {
38
40
  apiInput: TInput;
@@ -198,20 +200,29 @@ type GuardInputsEntries<TGuards, TOpt> = {
198
200
  export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
199
201
  /** The guards option's runtime shape: a name, ordered names, or a name-to-param map. */
200
202
  export type LambderGuardsOptionValue = string | readonly string[] | Readonly<Record<string, unknown>>;
203
+ /**
204
+ * How a rejected input answers. Lambder binds this to its own decision
205
+ * (setApiInputValidationErrorHandler when set, else the standard 422 body),
206
+ * so the API's schema and every preflight slice refuse with one shape.
207
+ */
208
+ export type LambderInputValidationRefusal = (ctx: LambderRenderContext, resolver: LambderResolver, zodError: z.ZodError) => Promise<LambderResponse>;
201
209
  /**
202
210
  * Validate a preflight input slice (an apiInput slice of the raw payload, or
203
211
  * a guardInput value from the raw guardInputs map). Runs before the API's
204
- * own validation; failures answer the same 422 shape as regular input
205
- * validation. Shared with the rate-limit engine's apiInput-keyed policies.
212
+ * own validation; a failure throws the response `onInvalid` decides, the
213
+ * same one regular input validation answers. Shared with the rate-limit
214
+ * engine's apiInput-keyed policies.
206
215
  */
207
- export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, resolver: LambderResolver) => unknown;
216
+ export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, ctx: LambderRenderContext, resolver: LambderResolver, onInvalid: LambderInputValidationRefusal) => Promise<unknown>;
208
217
  /**
209
218
  * Runtime side of the guards subsystem: holds the defined guards, asserts
210
219
  * API registrations against them at startup, and executes an API's declared
211
220
  * guards during preflight. Composed into LambderApiPolicyEngine.
212
221
  */
213
222
  export declare class LambderApiGuardsEngine {
223
+ private readonly onInvalidInput;
214
224
  private guards;
225
+ constructor(onInvalidInput: LambderInputValidationRefusal);
215
226
  addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
216
227
  /** Startup validation of one API registration's guards option. */
217
228
  assertRegistration(apiName: string, mode: "public" | "session", guardsOption?: LambderGuardsOptionValue): void;
@@ -14,14 +14,14 @@ const toGuardEntries = (value) => {
14
14
  /**
15
15
  * Validate a preflight input slice (an apiInput slice of the raw payload, or
16
16
  * a guardInput value from the raw guardInputs map). Runs before the API's
17
- * own validation; failures answer the same 422 shape as regular input
18
- * validation. Shared with the rate-limit engine's apiInput-keyed policies.
17
+ * own validation; a failure throws the response `onInvalid` decides, the
18
+ * same one regular input validation answers. Shared with the rate-limit
19
+ * engine's apiInput-keyed policies.
19
20
  */
20
- export const parsePreflightSlice = (input, value, resolver) => {
21
+ export const parsePreflightSlice = async (input, value, ctx, resolver, onInvalid) => {
21
22
  const parsed = input.safeParse(value);
22
- if (!parsed.success) {
23
- throw resolver.json({ error: "Input validation failed", zodError: parsed.error }, { statusCode: 422 });
24
- }
23
+ if (!parsed.success)
24
+ throw await onInvalid(ctx, resolver, parsed.error);
25
25
  return parsed.data;
26
26
  };
27
27
  /**
@@ -30,7 +30,11 @@ export const parsePreflightSlice = (input, value, resolver) => {
30
30
  * guards during preflight. Composed into LambderApiPolicyEngine.
31
31
  */
32
32
  export class LambderApiGuardsEngine {
33
+ onInvalidInput;
33
34
  guards = {};
35
+ constructor(onInvalidInput) {
36
+ this.onInvalidInput = onInvalidInput;
37
+ }
34
38
  addGuards(guards) {
35
39
  for (const [name, guardDef] of Object.entries(guards)) {
36
40
  if (this.guards[name])
@@ -47,7 +51,7 @@ export class LambderApiGuardsEngine {
47
51
  for (const { name } of toGuardEntries(guardsOption)) {
48
52
  const guardDef = this.guards[name];
49
53
  if (!guardDef) {
50
- throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Define it via defineApiGuards() before registering the API.`);
54
+ throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
51
55
  }
52
56
  if (guardDef.session && mode !== "session") {
53
57
  throw new Error(`Lambder: API "${apiName}" uses guard "${name}" (session: true), which requires addSessionApi.`);
@@ -63,10 +67,10 @@ export class LambderApiGuardsEngine {
63
67
  const post = ctx.post;
64
68
  let payload;
65
69
  if (guardDef.apiInput) {
66
- payload = parsePreflightSlice(guardDef.apiInput, post?.payload, resolver);
70
+ payload = await parsePreflightSlice(guardDef.apiInput, post?.payload, ctx, resolver, this.onInvalidInput);
67
71
  }
68
72
  else if (guardDef.guardInput) {
69
- payload = parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], resolver);
73
+ payload = await parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], ctx, resolver, this.onInvalidInput);
70
74
  }
71
75
  // A guard's return value becomes the handler's typed
72
76
  // ctx.guardData[name]; check-only guards return undefined.
@@ -19,7 +19,7 @@ export declare class LambderApiIdempotencyEngine {
19
19
  private defaultTtlSeconds;
20
20
  private failOpen;
21
21
  configure(config: LambderApiIdempotencyConfig): void;
22
- /** True once enableApiIdempotency() ran; registration asserts check it. */
22
+ /** True once the idempotency option was configured; registration asserts check it. */
23
23
  get isConfigured(): boolean;
24
24
  /**
25
25
  * The request's idempotencyKey: null when absent, the key when valid, a
@@ -20,12 +20,12 @@ export class LambderApiIdempotencyEngine {
20
20
  failOpen = true;
21
21
  configure(config) {
22
22
  if (this.store)
23
- throw new Error("Lambder: enableApiIdempotency() was already called.");
23
+ throw new Error("Lambder: idempotency was already configured.");
24
24
  this.store = config.store;
25
25
  this.defaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
26
26
  this.failOpen = config.failOpen ?? true;
27
27
  }
28
- /** True once enableApiIdempotency() ran; registration asserts check it. */
28
+ /** True once the idempotency option was configured; registration asserts check it. */
29
29
  get isConfigured() { return this.store !== null; }
30
30
  /**
31
31
  * The request's idempotencyKey: null when absent, the key when valid, a
@@ -37,7 +37,11 @@ export class LambderApiIdempotencyEngine {
37
37
  if (rawKey === undefined || rawKey === null)
38
38
  return null;
39
39
  if (typeof rawKey !== "string" || rawKey.length < IDEMPOTENCY_MIN_KEY_LENGTH || rawKey.length > IDEMPOTENCY_MAX_KEY_LENGTH) {
40
- throw new LambderApiError(`Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`, { statusCode: 400 });
40
+ const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
41
+ throw new LambderApiError(content, {
42
+ statusCode: 400,
43
+ errorMessage: { type: "error", content },
44
+ });
41
45
  }
42
46
  return rawKey;
43
47
  }
@@ -112,7 +116,7 @@ export class LambderApiIdempotencyEngine {
112
116
  if (begun.state === "pending") {
113
117
  throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
114
118
  statusCode: 409,
115
- errorMessage: "This request is already being processed.",
119
+ errorMessage: { type: "warning", content: "This request is already being processed." },
116
120
  });
117
121
  }
118
122
  if (begun.state === "done") {
@@ -1,12 +1,12 @@
1
1
  import type { LambderRenderContext } from "../core/LambderContext.js";
2
2
  import type LambderResolver from "../core/LambderResolver.js";
3
3
  import type { LambderResponse } from "../core/LambderResponse.js";
4
- import { type LambderApiGuard, type LambderGuardsOptionValue } from "./LambderApiGuards.js";
5
- import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
4
+ import { type LambderApiGuard, type LambderGuardsOptionValue, type LambderInputValidationRefusal } from "./LambderApiGuards.js";
5
+ import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitOptionValue } from "./LambderApiRateLimits.js";
6
6
  import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
7
7
  /** The declarative options one API registration may carry. */
8
8
  type LambderApiPolicyOptions = {
9
- rateLimit?: string | readonly string[];
9
+ rateLimit?: LambderRateLimitOptionValue;
10
10
  guards?: LambderGuardsOptionValue;
11
11
  idempotency?: unknown;
12
12
  };
@@ -16,13 +16,15 @@ type LambderApiPolicyOptions = {
16
16
  * ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
17
17
  * registrations against them at startup, and executes them around handlers
18
18
  * at request time. Internal to Lambder; apps interact through
19
- * enableApiRateLimits(), enableApiIdempotency(), defineApiGuards() and the
19
+ * the create() options (rateLimits, guards, idempotency) and the
20
20
  * per-API options.
21
21
  */
22
22
  export declare class LambderApiPolicyEngine {
23
23
  private rateLimits;
24
24
  private guards;
25
25
  private idempotency;
26
+ /** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
27
+ constructor(onInvalidInput: LambderInputValidationRefusal);
26
28
  setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
27
29
  addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
28
30
  setIdempotency(config: LambderApiIdempotencyConfig): void;
@@ -7,13 +7,18 @@ import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
7
7
  * ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
8
8
  * registrations against them at startup, and executes them around handlers
9
9
  * at request time. Internal to Lambder; apps interact through
10
- * enableApiRateLimits(), enableApiIdempotency(), defineApiGuards() and the
10
+ * the create() options (rateLimits, guards, idempotency) and the
11
11
  * per-API options.
12
12
  */
13
13
  export class LambderApiPolicyEngine {
14
- rateLimits = new LambderApiRateLimitsEngine();
15
- guards = new LambderApiGuardsEngine();
14
+ rateLimits;
15
+ guards;
16
16
  idempotency = new LambderApiIdempotencyEngine();
17
+ /** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
18
+ constructor(onInvalidInput) {
19
+ this.rateLimits = new LambderApiRateLimitsEngine(onInvalidInput);
20
+ this.guards = new LambderApiGuardsEngine(onInvalidInput);
21
+ }
17
22
  setRateLimits(config) {
18
23
  this.rateLimits.configure(config);
19
24
  }
@@ -28,7 +33,7 @@ export class LambderApiPolicyEngine {
28
33
  this.rateLimits.assertRegistration(apiName, mode, options.rateLimit);
29
34
  this.guards.assertRegistration(apiName, mode, options.guards);
30
35
  if (options.idempotency !== undefined && !this.idempotency.isConfigured) {
31
- throw new Error(`Lambder: API "${apiName}" declares idempotency but enableApiIdempotency() was not called first.`);
36
+ throw new Error(`Lambder: API "${apiName}" declares idempotency but no idempotency store was configured at creation.`);
32
37
  }
33
38
  }
34
39
  /** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */