lambder 3.7.1 → 3.8.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/Readme.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
4
 
5
- **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1), typed translations with `createLambderI18n` (3.2), and in 3.5: typed API refusals with `LambderApiError`, caller outcomes/timeouts with `apiOutcome()`, plus declarative per-API rate limits, guards, and idempotency; 3.7 makes guards and custom rate-limit keys payload-sliced ({ input, handler }): the slice is validated pre-run, typed in the handler, and force-merged into the contract input.
5
+ **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1), typed translations with `createLambderI18n` (3.2), and in 3.5: typed API refusals with `LambderApiError`, caller outcomes/timeouts with `apiOutcome()`, plus declarative per-API rate limits, guards, and idempotency; 3.8 gives guards and custom rate-limit keys two typed modes: apiInput (checks a slice of the API's own payload; declarable only where the schema carries those fields) and guardInput (a separate client-sent guardInputs channel the contract makes mandatory at the call site).
6
6
 
7
7
  ## Features
8
8
 
@@ -478,11 +478,11 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
478
478
  writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
479
479
  codePerEmail: {
480
480
  perMin: 3,
481
- // The key declares the payload slice it needs: validated before
482
- // it runs, handed to the handler typed, and merged into the
483
- // contract input of every API referencing this policy.
481
+ // apiInput key: derives from the API's OWN payload. Validated
482
+ // before it runs, typed in the handler, and the policy is only
483
+ // referable from APIs whose input schema carries `email`.
484
484
  per: lambderRateLimitKey({
485
- input: z.object({ email: z.string() }),
485
+ apiInput: z.object({ email: z.string() }),
486
486
  handler: (_ctx, { email }) => email.trim().toLowerCase(),
487
487
  }),
488
488
  errorMessage: { type: "warning", content: "Too many attempts for this address." },
@@ -496,14 +496,15 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
496
496
  defaultTtlSeconds: 24 * 3600,
497
497
  failOpen: true, // DynamoDB down => execute without dedupe instead of failing
498
498
  })
499
- // 3. Named guards: { input?, handler } definitions run before input
500
- // validation and refuse by throwing. A guard's `input` slice is
501
- // validated against the raw payload, handed to the handler typed, and
502
- // merged into the contract input of every API declaring the guard, so
503
- // forgetting to send captchaToken is a compile error at the call site.
499
+ // 3. Named guards, two modes. apiInput checks a slice of the API's own
500
+ // payload (the schema keeps the field; the guard is declarable only
501
+ // where the payload type passes both). guardInput is the guard's OWN
502
+ // value, sent separately by the caller via options.guardInputs and
503
+ // made mandatory by the contract, so forgetting it is a compile error
504
+ // at the call site. Both are validated pre-run and typed in the handler.
504
505
  .defineApiGuards({
505
506
  captcha: lambderGuard({
506
- input: z.object({ captchaToken: z.string() }),
507
+ guardInput: z.object({ captchaToken: z.string() }),
507
508
  handler: async (ctx, { captchaToken }) => {
508
509
  if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
509
510
  },
@@ -511,9 +512,10 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
511
512
  });
512
513
 
513
514
  lambder.addApi("public.resetPassword", {
514
- // captchaToken is NOT declared here: the guard contributes it to the
515
- // contract, the guard validates and consumes it, and the handler never
516
- // sees it.
515
+ // captchaToken is NOT declared here: it travels in the separate
516
+ // guardInputs channel, so the guard validates and consumes it and the
517
+ // handler never sees it. `email` IS declared: the codePerEmail key runs
518
+ // in apiInput mode against the API's own payload.
517
519
  input: z.object({ email: z.string().email() }),
518
520
  output: z.object({ ok: z.boolean() }),
519
521
  rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
@@ -636,6 +638,7 @@ Also available:
636
638
 
637
639
  - **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
638
640
  - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
641
+ - **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs.
639
642
  - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success.
640
643
 
641
644
  ### Benefits
package/dist/Lambder.d.ts CHANGED
@@ -8,7 +8,7 @@ import { type LambderCorsConfig } from "./LambderCors.js";
8
8
  import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
9
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
10
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
- import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuard, type LambderGuardPayloadMap, type LambderGuardsRequirement, type LambderPoliciesRequirement, type LambderMergedInput, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
11
+ import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuard, type LambderGuardMetaMap, type LambderAllowedGuardNames, type LambderAllowedPolicyNames, type LambderGuardInputsOf } from "./LambderApiPolicies.js";
12
12
  import type { MergeContract } from "./LambderApiContract.js";
13
13
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
14
14
  export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
@@ -201,16 +201,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
201
201
  enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
202
202
  /**
203
203
  * Define named guards that APIs reference (typed) via the `guards`
204
- * option. Each guard is a { input?, handler } definition (build with
205
- * lambderGuard()): the input slice is validated against the raw payload
206
- * before the handler runs, the handler receives it typed, and the
207
- * requirement merges into the contract input of every API declaring the
208
- * guard. Guards run before input validation, in the order the API
209
- * declares them; a handler refuses by throwing (typically refuse()).
210
- * Callable multiple times so domain modules can contribute their own;
211
- * names must not collide.
204
+ * option. Each guard is built with lambderGuard() in one of two modes:
205
+ * apiInput (checks a slice of the API's own payload; declarable only on
206
+ * APIs whose input schema carries those fields, so the payload type
207
+ * passes both the API input and the guard's apiInput) or guardInput (the
208
+ * client sends the guard's value separately via options.guardInputs, and
209
+ * the contract forces it at the call site). Guards run before input
210
+ * validation, in the order the API declares them; a handler refuses by
211
+ * throwing (typically refuse()). Callable multiple times so domain
212
+ * modules can contribute their own; names must not collide.
212
213
  */
213
- defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardPayloadMap<TGuards>, _TIdempotencyEnabled>;
214
+ defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
214
215
  private getOrCreatePolicyEngine;
215
216
  /** Registration-time checks shared by addApi/addSessionApi. */
216
217
  private assertApiRegistration;
@@ -219,7 +220,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
219
220
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
220
221
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
221
222
  use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
222
- addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderPublicRateLimitNames<_TRateLimitPolicies> | readonly LambderPublicRateLimitNames<_TRateLimitPolicies>[] = never, const TGuardsOpt extends Extract<keyof _TGuards, string> | readonly Extract<keyof _TGuards, string>[] = never>(name: TName, schema: {
223
+ addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
223
224
  input: TInput;
224
225
  output: TOutput;
225
226
  } & {
@@ -231,8 +232,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
231
232
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
232
233
  ttlSeconds?: number;
233
234
  }) : never;
234
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, LambderMergedInput<z.infer<TInput>, LambderPoliciesRequirement<_TRateLimitPolicies, TRateOpt>, LambderGuardsRequirement<_TGuards, TGuardsOpt>>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
235
- addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends Extract<keyof _TRateLimitPolicies, string> | readonly Extract<keyof _TRateLimitPolicies, string>[] = never, const TGuardsOpt extends Extract<keyof _TGuards, string> | readonly Extract<keyof _TGuards, string>[] = never>(name: TName, schema: {
235
+ }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
236
+ addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
236
237
  input: TInput;
237
238
  output: TOutput;
238
239
  } & {
@@ -244,7 +245,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
244
245
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
245
246
  ttlSeconds?: number;
246
247
  }) : never;
247
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, LambderMergedInput<z.infer<TInput>, LambderPoliciesRequirement<_TRateLimitPolicies, TRateOpt>, LambderGuardsRequirement<_TGuards, TGuardsOpt>>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
248
+ }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
248
249
  /**
249
250
  * Fetch the session or short-circuit the request: API calls get the
250
251
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
package/dist/Lambder.js CHANGED
@@ -193,14 +193,15 @@ export default class Lambder {
193
193
  }
194
194
  /**
195
195
  * Define named guards that APIs reference (typed) via the `guards`
196
- * option. Each guard is a { input?, handler } definition (build with
197
- * lambderGuard()): the input slice is validated against the raw payload
198
- * before the handler runs, the handler receives it typed, and the
199
- * requirement merges into the contract input of every API declaring the
200
- * guard. Guards run before input validation, in the order the API
201
- * declares them; a handler refuses by throwing (typically refuse()).
202
- * Callable multiple times so domain modules can contribute their own;
203
- * names must not collide.
196
+ * option. Each guard is built with lambderGuard() in one of two modes:
197
+ * apiInput (checks a slice of the API's own payload; declarable only on
198
+ * APIs whose input schema carries those fields, so the payload type
199
+ * passes both the API input and the guard's apiInput) or guardInput (the
200
+ * client sends the guard's value separately via options.guardInputs, and
201
+ * the contract forces it at the call site). Guards run before input
202
+ * validation, in the order the API declares them; a handler refuses by
203
+ * throwing (typically refuse()). Callable multiple times so domain
204
+ * modules can contribute their own; names must not collide.
204
205
  */
205
206
  defineApiGuards(guards) {
206
207
  this.getOrCreatePolicyEngine().addGuards(guards);
@@ -9,13 +9,19 @@
9
9
  export type ApiContractShape = Record<string, {
10
10
  input: any;
11
11
  output: any;
12
+ /** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
13
+ guardInputs?: any;
12
14
  }>;
13
15
  /**
14
16
  * Helper type for merging new API into existing contract during chaining
15
17
  */
16
- export type MergeContract<Old, Name extends string, In, Out> = Old & {
17
- [K in Name]: {
18
+ export type MergeContract<Old, Name extends string, In, Out, GuardInputs = never> = Old & {
19
+ [K in Name]: [GuardInputs] extends [never] ? {
18
20
  input: In;
19
21
  output: Out;
22
+ } : {
23
+ input: In;
24
+ output: Out;
25
+ guardInputs: GuardInputs;
20
26
  };
21
27
  };
@@ -5,36 +5,37 @@ import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "./LambderDdb
5
5
  import type { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
6
6
  import { LambderResponse } from "./LambderResponse.js";
7
7
  /**
8
- * A custom rate-limit key: `input` names the payload fields the key needs.
9
- * The slice is validated against the raw payload before `handler` runs (a
10
- * failure answers the standard 422 validation shape), and the requirement is
11
- * merged into the contract input of every API that references the policy, so
12
- * clients are forced by the compiler to send those fields. Build with
13
- * lambderRateLimitKey() so the handler's payload type follows `input`.
8
+ * A custom rate-limit key. `apiInput` names the fields of the API's OWN
9
+ * payload the key derives from: the slice is validated against the raw
10
+ * payload before `handler` runs (failures answer the standard 422 validation
11
+ * shape) and the handler receives it typed. Referencing the policy from an
12
+ * API whose input schema does not carry those fields is a compile error, so
13
+ * the API's schema stays the single owner of the field. Build with
14
+ * lambderRateLimitKey() so the handler's payload type follows `apiInput`.
14
15
  */
15
16
  export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
16
- input: TInput;
17
+ apiInput: TInput;
17
18
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
18
19
  } | {
19
- input?: undefined;
20
+ apiInput?: undefined;
20
21
  handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
21
22
  };
22
23
  /**
23
- * Builder that ties the handler's payload type to the `input` schema inside
24
- * one literal. Returns the exact union member (not the union), so requirement
25
- * extraction can see the `input` type.
24
+ * Builder that ties the handler's payload type to the `apiInput` schema
25
+ * inside one literal. Returns the exact union member so type extraction can
26
+ * see the schema.
26
27
  */
27
28
  export declare function lambderRateLimitKey<TInput extends z.ZodTypeAny>(key: {
28
- input: TInput;
29
+ apiInput: TInput;
29
30
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
30
31
  }): {
31
- input: TInput;
32
+ apiInput: TInput;
32
33
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
33
34
  };
34
35
  export declare function lambderRateLimitKey(key: {
35
36
  handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
36
37
  }): {
37
- input?: undefined;
38
+ apiInput?: undefined;
38
39
  handler: (ctx: LambderRenderContext, payload: undefined) => string | Promise<string>;
39
40
  };
40
41
  /** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
@@ -60,72 +61,106 @@ export type LambderApiIdempotencyConfig = {
60
61
  failOpen?: boolean;
61
62
  };
62
63
  /**
63
- * A named guard, run before the API's own input validation. `input` names the
64
- * payload fields the guard requires: the slice is validated against the raw
65
- * payload before `handler` runs (a failure answers the standard 422
66
- * validation shape), the handler receives it typed, and the requirement is
67
- * merged into the contract input of every API that declares the guard, so
68
- * clients are forced by the compiler to send those fields. The handler
69
- * refuses by throwing (typically refuse()/LambderApiError). Build with
70
- * lambderGuard() so the handler's payload type follows `input`.
64
+ * A named guard, run before the API's own input validation. Two modes:
65
+ *
66
+ * - `apiInput`: the guard checks fields of the API's OWN payload. The slice
67
+ * is validated against the raw payload before `handler` runs and handed to
68
+ * it typed. The API's input schema stays the owner of those fields:
69
+ * declaring the guard on an API whose schema does not carry them is a
70
+ * compile error.
71
+ * - `guardInput`: the guard has its own value the client sends SEPARATELY,
72
+ * outside the API payload, via the caller's options.guardInputs[name].
73
+ * The requirement lands on the API's contract (`guardInputs`), so the
74
+ * typed caller refuses to compile a call that does not send it. The API
75
+ * payload and handler never see the value.
76
+ *
77
+ * Either way a validation failure answers the standard 422 shape, and the
78
+ * handler refuses by throwing (typically refuse()/LambderApiError). Build
79
+ * with lambderGuard() so the handler's payload type follows the schema.
71
80
  */
72
81
  export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
73
- input: TInput;
82
+ apiInput: TInput;
83
+ guardInput?: undefined;
74
84
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
75
85
  } | {
76
- input?: undefined;
86
+ guardInput: TInput;
87
+ apiInput?: undefined;
88
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
89
+ } | {
90
+ apiInput?: undefined;
91
+ guardInput?: undefined;
77
92
  handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
78
93
  };
79
94
  /**
80
- * Builder that ties the handler's payload type to the `input` schema inside
81
- * one literal. Returns the exact union member (not the union), so requirement
82
- * extraction can see the `input` type.
95
+ * Builder that ties the handler's payload type to the schema inside one
96
+ * literal. Returns the exact union member so mode/type extraction works.
83
97
  */
84
98
  export declare function lambderGuard<TInput extends z.ZodTypeAny>(guard: {
85
- input: TInput;
99
+ apiInput: TInput;
100
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
101
+ }): {
102
+ apiInput: TInput;
103
+ guardInput?: undefined;
104
+ handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
105
+ };
106
+ export declare function lambderGuard<TInput extends z.ZodTypeAny>(guard: {
107
+ guardInput: TInput;
86
108
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
87
109
  }): {
88
- input: TInput;
110
+ guardInput: TInput;
111
+ apiInput?: undefined;
89
112
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>, res: LambderResolver) => void | Promise<void>;
90
113
  };
91
114
  export declare function lambderGuard(guard: {
92
115
  handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
93
116
  }): {
94
- input?: undefined;
117
+ apiInput?: undefined;
118
+ guardInput?: undefined;
95
119
  handler: (ctx: LambderRenderContext, payload: undefined, res: LambderResolver) => void | Promise<void>;
96
120
  };
97
- /** Names of policies usable on public APIs: everything not keyed per "session". */
98
- export type LambderPublicRateLimitNames<TPolicies> = {
121
+ /** Per-guard metadata carried on the Lambder instance: mode plus payload type. */
122
+ export type LambderGuardMeta<G> = G extends {
123
+ apiInput: infer S extends z.ZodTypeAny;
124
+ } ? {
125
+ apiInput: z.output<S>;
126
+ } : G extends {
127
+ guardInput: infer S extends z.ZodTypeAny;
128
+ } ? {
129
+ guardInput: z.output<S>;
130
+ } : {};
131
+ export type LambderGuardMetaMap<TGuards> = {
132
+ [K in keyof TGuards]: LambderGuardMeta<TGuards[K]>;
133
+ };
134
+ type NamesIn<TOpt> = TOpt extends readonly (infer N extends string)[] ? N : TOpt extends string ? TOpt : never;
135
+ /** Guard names an API may declare: apiInput-mode guards only when the API's payload carries their fields. */
136
+ export type LambderAllowedGuardNames<TGuards, TPayload> = {
137
+ [K in keyof TGuards]: TGuards[K] extends {
138
+ apiInput: infer R;
139
+ } ? (TPayload extends R ? K : never) : K;
140
+ }[keyof TGuards] & string;
141
+ /**
142
+ * Policy names an API may reference: session-keyed policies only on session
143
+ * APIs, and apiInput-keyed policies only when the API's payload carries the
144
+ * key's fields.
145
+ */
146
+ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession extends boolean> = {
99
147
  [K in keyof TPolicies]: TPolicies[K] extends {
100
148
  per: "session";
101
- } ? never : K;
149
+ } ? (TIncludeSession extends true ? K : never) : TPolicies[K] extends {
150
+ per: {
151
+ apiInput: infer S extends z.ZodTypeAny;
152
+ };
153
+ } ? (TPayload extends z.output<S> ? K : never) : K;
102
154
  }[keyof TPolicies] & string;
103
- /** Payload fields a guard requires; {} when it declares no input. */
104
- export type LambderGuardPayload<G> = G extends {
105
- input: infer S extends z.ZodTypeAny;
106
- } ? z.output<S> : {};
107
- /** Guard name to required-payload map, accumulated on the Lambder instance by defineApiGuards. */
108
- export type LambderGuardPayloadMap<TGuards> = {
109
- [K in keyof TGuards]: LambderGuardPayload<TGuards[K]>;
155
+ type GuardInputsEntries<TGuards, TOpt> = {
156
+ [K in Extract<NamesIn<TOpt>, keyof TGuards> as TGuards[K] extends {
157
+ guardInput: any;
158
+ } ? K : never]: TGuards[K] extends {
159
+ guardInput: infer V;
160
+ } ? V : never;
110
161
  };
111
- /** Payload fields a policy's custom key requires; {} for "ip"/"session" or keys with no input. */
112
- export type LambderPolicyPayload<P> = P extends {
113
- per: {
114
- input: infer S extends z.ZodTypeAny;
115
- };
116
- } ? z.output<S> : {};
117
- type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
118
- type NamesIn<TOpt> = TOpt extends readonly (infer N extends string)[] ? N : TOpt extends string ? TOpt : never;
119
- /** Intersection of the payload requirements of the referenced guards; never when none are declared. */
120
- export type LambderGuardsRequirement<TGuardPayloads, TOpt> = [
121
- NamesIn<TOpt>
122
- ] extends [never] ? never : UnionToIntersection<TGuardPayloads[Extract<NamesIn<TOpt>, keyof TGuardPayloads>]>;
123
- /** Intersection of the payload requirements of the referenced rate-limit policies; never when none are declared. */
124
- export type LambderPoliciesRequirement<TPolicies, TOpt> = [
125
- NamesIn<TOpt>
126
- ] extends [never] ? never : UnionToIntersection<LambderPolicyPayload<TPolicies[Extract<NamesIn<TOpt>, keyof TPolicies>]>>;
127
- /** Contract-input merge: the API's own input plus everything its rate limits and guards force clients to send. */
128
- export type LambderMergedInput<TIn, TReqA, TReqB> = ([TReqA] extends [never] ? TIn : TIn & TReqA) extends infer TMid ? ([TReqB] extends [never] ? TMid : TMid & TReqB) : never;
162
+ /** The guardInputs map an API's contract requires clients to send; never when no declared guard uses guardInput mode. */
163
+ export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
129
164
  /**
130
165
  * Runtime side of the declarative API options: holds what the enable/define
131
166
  * calls declared, asserts registrations against it at startup, and executes
@@ -155,10 +190,10 @@ export declare class LambderApiPolicyEngine {
155
190
  guards?: string | readonly string[];
156
191
  }): Promise<void>;
157
192
  /**
158
- * Validate a preflight input slice against the raw payload. Runs before
159
- * the API's own validation, so guard/key requirements hold even when the
160
- * API schema does not declare (and would strip) those fields. Failures
161
- * answer the same 422 shape as regular input validation.
193
+ * Validate a preflight input slice (an apiInput slice of the raw payload,
194
+ * or a guardInput value from the raw guardInputs map). Runs before the
195
+ * API's own validation; failures answer the same 422 shape as regular
196
+ * input validation.
162
197
  */
163
198
  private parseSlice;
164
199
  private resolveRateLimitKey;
@@ -28,7 +28,7 @@ export class LambderApiPolicyEngine {
28
28
  for (const [name, policy] of Object.entries(config.policies)) {
29
29
  const per = policy.per;
30
30
  if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
31
- throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { input?, handler } key.`);
31
+ throw new Error(`Lambder: rate-limit policy "${name}" needs per: "ip", "session", or a { apiInput?, handler } key.`);
32
32
  }
33
33
  if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
34
34
  throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
@@ -43,6 +43,8 @@ export class LambderApiPolicyEngine {
43
43
  throw new Error(`Lambder: guard "${name}" is already defined.`);
44
44
  if (typeof guardDef?.handler !== "function")
45
45
  throw new Error(`Lambder: guard "${name}" has no handler function.`);
46
+ if (guardDef.apiInput && guardDef.guardInput)
47
+ throw new Error(`Lambder: guard "${name}" declares both apiInput and guardInput; pick one.`);
46
48
  this.guards[name] = guardDef;
47
49
  }
48
50
  }
@@ -92,20 +94,25 @@ export class LambderApiPolicyEngine {
92
94
  const guardDef = this.guards[name];
93
95
  if (!guardDef)
94
96
  throw new Error(`Lambder: guard "${name}" is not configured.`);
95
- const payload = this.parseSlice(guardDef.input, ctx, resolver);
97
+ const post = ctx.post;
98
+ let payload;
99
+ if (guardDef.apiInput) {
100
+ payload = this.parseSlice(guardDef.apiInput, post?.payload, resolver);
101
+ }
102
+ else if (guardDef.guardInput) {
103
+ payload = this.parseSlice(guardDef.guardInput, post?.guardInputs?.[name], resolver);
104
+ }
96
105
  await guardDef.handler(ctx, payload, resolver);
97
106
  }
98
107
  }
99
108
  /**
100
- * Validate a preflight input slice against the raw payload. Runs before
101
- * the API's own validation, so guard/key requirements hold even when the
102
- * API schema does not declare (and would strip) those fields. Failures
103
- * answer the same 422 shape as regular input validation.
109
+ * Validate a preflight input slice (an apiInput slice of the raw payload,
110
+ * or a guardInput value from the raw guardInputs map). Runs before the
111
+ * API's own validation; failures answer the same 422 shape as regular
112
+ * input validation.
104
113
  */
105
- parseSlice(input, ctx, resolver) {
106
- if (!input)
107
- return undefined;
108
- const parsed = input.safeParse(ctx.post?.payload);
114
+ parseSlice(input, value, resolver) {
115
+ const parsed = input.safeParse(value);
109
116
  if (!parsed.success) {
110
117
  throw resolver.json({ error: "Input validation failed", zodError: parsed.error }, { statusCode: 422 });
111
118
  }
@@ -120,7 +127,9 @@ export class LambderApiPolicyEngine {
120
127
  throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
121
128
  return `session:${sessionKey}`;
122
129
  }
123
- const payload = this.parseSlice(per.input, ctx, resolver);
130
+ const payload = per.apiInput
131
+ ? this.parseSlice(per.apiInput, ctx.post?.payload, resolver)
132
+ : undefined;
124
133
  return `custom:${await per.handler(ctx, payload)}`;
125
134
  }
126
135
  /**
@@ -1,6 +1,18 @@
1
1
  import { LambderApiResponse } from './LambderResponseBuilder';
2
2
  import type { ApiContractShape } from './LambderApiContract';
3
3
  import type { z } from "zod";
4
+ type IsAny<T> = 0 extends (1 & T) ? true : false;
5
+ type GuardInputsOf<TEntry> = TEntry extends {
6
+ guardInputs: infer G;
7
+ } ? G : never;
8
+ /**
9
+ * The options argument: optional normally, REQUIRED (with guardInputs) when
10
+ * the API's contract declares guardInput-mode guards, so forgetting to send
11
+ * a guard's value is a compile error at the call site.
12
+ */
13
+ type CallOptionsArg<TContract, TApiName> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [options: LambderCallOptions & {
14
+ guardInputs: GuardInputsOf<TContract[TApiName]>;
15
+ }] : [options?: LambderCallOptions];
4
16
  type VoidFunction = () => void | Promise<void>;
5
17
  type FetchTracker = {
6
18
  apiName: string;
@@ -55,6 +67,12 @@ export type LambderCallOptions = {
55
67
  timeoutMs?: number;
56
68
  /** External abort signal, combined with the timeout when both are set. */
57
69
  signal?: AbortSignal;
70
+ /**
71
+ * Values for the API's guardInput-mode guards, keyed by guard name; sent
72
+ * beside the payload and consumed by the guards before validation. The
73
+ * typed contract makes this REQUIRED for APIs that declare such guards.
74
+ */
75
+ guardInputs?: Record<string, unknown>;
58
76
  /**
59
77
  * Replay-protection key for APIs declared idempotent on the server.
60
78
  * Generate once per logical operation (e.g. crypto.randomUUID() when the
@@ -127,14 +145,14 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
127
145
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
128
146
  * instead of collapsing every failure to null. Never throws.
129
147
  */
130
- apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<LambderApiOutcome<TOutput>>;
148
+ apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiOutcome<TOutput>>;
131
149
  /**
132
150
  * Legacy shape: the parsed envelope on success (and on structured
133
151
  * errorMessage refusals, which carry an envelope), null on every other
134
152
  * failure. Prefer apiOutcome() when the call site needs to know why.
135
153
  */
136
- apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<LambderApiResponse<TOutput> | null | undefined>;
154
+ apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiResponse<TOutput> | null | undefined>;
137
155
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
138
- api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<TOutput | null | undefined>;
156
+ api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<TOutput | null | undefined>;
139
157
  }
140
158
  export {};
@@ -149,6 +149,7 @@ export default class LambderCaller {
149
149
  headers: { 'Content-Type': 'application/json', ...(headers || {}) },
150
150
  body: JSON.stringify({
151
151
  apiName, version, token, siteHost, payload,
152
+ ...(options?.guardInputs !== undefined ? { guardInputs: options.guardInputs } : {}),
152
153
  ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
153
154
  }),
154
155
  ...(signal ? { signal } : {}),
@@ -280,8 +281,8 @@ export default class LambderCaller {
280
281
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
281
282
  * instead of collapsing every failure to null. Never throws.
282
283
  */
283
- async apiOutcome(apiName, payload, options) {
284
- return await this.dispatch(apiName, payload, options);
284
+ async apiOutcome(apiName, payload, ...rest) {
285
+ return await this.dispatch(apiName, payload, rest[0]);
285
286
  }
286
287
  ;
287
288
  /**
@@ -289,16 +290,18 @@ export default class LambderCaller {
289
290
  * errorMessage refusals, which carry an envelope), null on every other
290
291
  * failure. Prefer apiOutcome() when the call site needs to know why.
291
292
  */
292
- async apiRaw(apiName, payload, options) {
293
- const outcome = await this.dispatch(apiName, payload, options);
293
+ async apiRaw(apiName, payload, ...rest) {
294
+ const outcome = await this.dispatch(apiName, payload, rest[0]);
294
295
  if (outcome.ok)
295
296
  return outcome.response;
296
297
  return outcome.reason === 'errorMessage' ? outcome.response : null;
297
298
  }
298
299
  ;
299
300
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
300
- async api(apiName, payload, options) {
301
- const result = await this.apiRaw(apiName, payload, options);
302
- return result?.payload;
301
+ async api(apiName, payload, ...rest) {
302
+ const outcome = await this.dispatch(apiName, payload, rest[0]);
303
+ if (outcome.ok)
304
+ return outcome.response?.payload;
305
+ return outcome.reason === 'errorMessage' ? outcome.response?.payload : undefined;
303
306
  }
304
307
  }
package/dist/index.d.ts CHANGED
@@ -28,7 +28,7 @@ export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy, LambderRateL
28
28
  export { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
29
29
  export type { LambderDdbIdempotencyOptions, LambderIdempotencyBeginResult, } from "./LambderDdbIdempotency.js";
30
30
  export { lambderGuard, lambderRateLimitKey } from "./LambderApiPolicies.js";
31
- export type { LambderApiGuard, LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderApiIdempotencyConfig, LambderPublicRateLimitNames, LambderGuardPayload, LambderGuardPayloadMap, LambderPolicyPayload, LambderGuardsRequirement, LambderPoliciesRequirement, LambderMergedInput, } from "./LambderApiPolicies.js";
31
+ export type { LambderApiGuard, LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderApiIdempotencyConfig, LambderGuardMeta, LambderGuardMetaMap, LambderAllowedGuardNames, LambderAllowedPolicyNames, LambderGuardInputsOf, } from "./LambderApiPolicies.js";
32
32
  export { createLambderI18n } from "./LambderI18n.js";
33
33
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./LambderI18n.js";
34
34
  export { type ApiContractShape, } from "./LambderApiContract.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.7.1",
3
+ "version": "3.8.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",