lambder 4.8.1 → 4.9.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,6 +2,11 @@
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 4.9:**
6
+
7
+ - **Mandatory authorization on public APIs**: `requirePublicApiGuards: true` at creation makes `guards` a required field of every `addApi`, the same way `requireSessionApiGuards` does for session APIs, at the type level and at registration. Public APIs are open by default and that stays the default; what turning it on buys is that a public endpoint's openness becomes a written decision rather than an omission. The ones anybody may call declare a named no-op guard carrying the reason (`guards: { open: "Static strings already in the bundle." }`), the ones that authorize their caller some other way (a signature, a device secret, a one-shot token) name where that happens, and one grep over the guard names then lists every public door and why it is open. The two flags are independent, so an app can require either or both.
8
+ - **An empty guards option is refused**: `guards: {}` and `guards: []` were inhabited by the option type and passed the require\*ApiGuards field check while normalizing to zero entries, so a declaration that authorized nothing satisfied a requirement that exists to make authorization explicit. Both are now compile errors (every form of the option is non-empty by construction) and a registration error for a plain-JS caller, whichever flag is on or off. Requiring the chosen key also rejects `guards: { theGuard: undefined }`, which an optional property accepted and which reached the guard's handler with an undefined param.
9
+
5
10
  **New in 4.8:**
6
11
 
7
12
  - **Grouped cache keys**: `LambderDdbCache` keys may be a `{ pk, sk }` pair instead of a string, which stores related entries in one partition: `{ pk: "division:ist-34", sk: "1700:1800" }` keeps every cached window of one division together. `deletePartition(pk)` then drops the whole group without knowing which sort keys exist, and `listSortKeys(pk, { prefix, limit })` reads back what is currently cached under it. The group invalidation a cache of derived, per-entity values needs, in place of remembering every key ever written or waiting out the TTL. Reads stay one request, and the memory layer, single-flight and fill lease stay per entry. Only the `pk` part is hashed, so the sort key is queryable; a caller's `#` is escaped rather than refused (`~`→`~0`, `#`→`~1`). Plain string keys keep their exact item layout, so a live table needs no migration and both forms can share a partition.
@@ -729,7 +734,7 @@ lambder.addApi("public.resetPassword", {
729
734
  input: z.object({ email: z.string().email() }),
730
735
  output: z.object({ ok: z.boolean() }),
731
736
  rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
732
- guards: "captcha", // one name, a list of names, or a { name: param } map
737
+ guards: "captcha", // one name, a non-empty list of names, or a non-empty { name: param } map
733
738
  }, handler);
734
739
 
735
740
  lambder.addSessionApi("secure.order.create", {
@@ -766,6 +771,28 @@ const lambder = initLambder<SessionData>().create({
766
771
  lambder.addSessionApi("secure.order.create", { input, output, guards: { orgPermission: "ORDERS.CREATE" } }, handler);
767
772
  lambder.addSessionApi("secure.me.logOut", { input, output, guards: "sessionOnly" }, handler);
768
773
  lambder.addSessionApi("secure.report.list", { input, output }, handler); // compile error: which guard?
774
+ lambder.addSessionApi("secure.report.list", { input, output, guards: {} }, handler); // compile error: {} declares no guard
775
+ ```
776
+
777
+ **The same for public APIs (`requirePublicApiGuards`)**: public APIs are open by default, and that remains the default. An app whose public surface has grown past a handful of endpoints can turn `requirePublicApiGuards: true` on to make each one's openness a written decision instead of an omission. Not every public endpoint has a control that can be hoisted into a guard (an endpoint that checks a password *is* the check), so the vocabulary an app declares here is usually a real guard for what is a genuine precondition, plus named no-op guards for the rest. The two flags are independent; either or both may be on.
778
+
779
+ ```typescript
780
+ const lambder = initLambder<SessionData>().create({
781
+ apiPath: "/api",
782
+ guards: {
783
+ deviceToken: lambderGuard({ apiInput: z.object({ deviceToken: z.string().min(20) }), handler: (_c, { deviceToken }) => requireDevice(deviceToken) }),
784
+ // Anyone may call, and the param records why: `grep "open:"` lists every public door.
785
+ open: lambderGuard({ handler: (_c, _p, _r, _reason: string) => {} }),
786
+ // This endpoint establishes identity; the proof is the handler's own work.
787
+ credentialFlow: lambderGuard({ handler: () => {} }),
788
+ },
789
+ requirePublicApiGuards: true,
790
+ });
791
+
792
+ lambder.addApi("public.device.report", { input, output, guards: "deviceToken" }, handler);
793
+ lambder.addApi("public.translations", { input, output, guards: { open: "Static strings already in the bundle." } }, handler);
794
+ lambder.addApi("public.login", { input, output, guards: "credentialFlow" }, handler);
795
+ lambder.addApi("public.search", { input, output }, handler); // compile error: open to anyone, or authorized how?
769
796
  ```
770
797
 
771
798
  For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
@@ -21,7 +21,7 @@ type MaybePromise<T> = T | Promise<T>;
21
21
  type Path = `/${string}`;
22
22
  type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
23
23
  type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
24
- type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any>) => void | Promise<void>;
24
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any, any>) => void | Promise<void>;
25
25
  /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
26
26
  type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
27
27
  type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
@@ -157,19 +157,38 @@ export type LambderCreateOptions<TSessionData = any> = {
157
157
  * from. Default: false.
158
158
  */
159
159
  requireSessionApiGuards?: boolean;
160
+ /**
161
+ * The same for public APIs: every addApi must declare `guards`, at the
162
+ * type level and at registration.
163
+ *
164
+ * Public APIs are open by default and that is the right default, so this
165
+ * is off unless an app decides otherwise. What it buys an app that turns
166
+ * it on is that a public endpoint's openness becomes a written decision
167
+ * rather than an omission: the ones anybody may call declare a named no-op
168
+ * guard carrying the reason, and the ones that authorize their caller some
169
+ * other way (a signature, a device secret, a one-shot token) name where
170
+ * that happens. One grep over the guard names then lists every public
171
+ * door and why it is open, which is the review question a growing public
172
+ * surface makes expensive to answer any other way. Needs a guards map to
173
+ * pick from. Default: false.
174
+ */
175
+ requirePublicApiGuards?: boolean;
160
176
  /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
161
177
  idempotency?: LambderApiIdempotencyConfig;
162
178
  };
163
179
  /**
164
- * The `guards` field of a session API's options: optional by default,
165
- * required once create() received requireSessionApiGuards, so that an
166
- * authorization declaration cannot be forgotten at the type level.
180
+ * The `guards` field of an API's options: optional by default, required once
181
+ * create() received the require*ApiGuards flag for that kind of API, so that
182
+ * an authorization declaration cannot be forgotten at the type level.
183
+ *
184
+ * One type for both kinds: the requirement is the same shape either way, and
185
+ * only which flag switches it on differs.
167
186
  */
168
- type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
169
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Required on this instance (requireSessionApiGuards): an API the session alone authorizes declares the named no-op session guard. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
187
+ type LambderRequirableGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
188
+ /** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Required on this instance: an API that needs no authorization declares a named no-op guard, so every opt-out is explicit and one grep lists them all. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
170
189
  guards: TGuardsOpt;
171
190
  } : {
172
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
191
+ /** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
173
192
  guards?: TGuardsOpt;
174
193
  };
175
194
  /**
@@ -184,6 +203,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
184
203
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
185
204
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
186
205
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
206
+ * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
187
207
  *
188
208
  * @example
189
209
  * ```typescript
@@ -194,7 +214,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
194
214
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
195
215
  * ```
196
216
  */
197
- export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false> {
217
+ export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false, _TPublicGuardsRequired extends boolean = false> {
198
218
  apiPath: string;
199
219
  apiVersion: null | string;
200
220
  /** The instance's file reader (source + caches), or null without the files option. */
@@ -228,6 +248,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
228
248
  private finalizeOptions;
229
249
  private maxRequestPayloadBytes;
230
250
  private requireSessionApiGuards;
251
+ private requirePublicApiGuards;
231
252
  private lambderSessionManager?;
232
253
  private sessionCookieOptions;
233
254
  private sessionTokenCookieKey;
@@ -275,20 +296,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
275
296
  addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
276
297
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
277
298
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
278
- use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
299
+ use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
279
300
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
280
301
  input: TInput;
281
302
  output: TOutput;
282
303
  } & {
283
304
  /** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
284
305
  rateLimit?: TRateOpt;
285
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
286
- guards?: TGuardsOpt;
287
306
  /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
288
307
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
289
308
  ttlSeconds?: number;
290
309
  }) : never;
291
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
310
+ } & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
292
311
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
293
312
  input: TInput;
294
313
  output: TOutput;
@@ -299,7 +318,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
299
318
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
300
319
  ttlSeconds?: number;
301
320
  }) : never;
302
- } & LambderSessionGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
321
+ } & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
303
322
  /**
304
323
  * Fetch the session or short-circuit the request: API calls get the
305
324
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -382,5 +401,5 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
382
401
  export declare const initLambder: <TSessionData = any>() => {
383
402
  create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
384
403
  policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
385
- } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false>;
404
+ } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false, TOptions["requirePublicApiGuards"] extends true ? true : false>;
386
405
  };
@@ -24,6 +24,7 @@ import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPaylo
24
24
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
25
25
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
26
26
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
27
+ * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
27
28
  *
28
29
  * @example
29
30
  * ```typescript
@@ -68,6 +69,7 @@ export default class Lambder {
68
69
  finalizeOptions;
69
70
  maxRequestPayloadBytes;
70
71
  requireSessionApiGuards;
72
+ requirePublicApiGuards;
71
73
  lambderSessionManager;
72
74
  sessionCookieOptions = {};
73
75
  sessionTokenCookieKey = "LMDRSESSIONTKID";
@@ -116,8 +118,10 @@ export default class Lambder {
116
118
  if (options.idempotency)
117
119
  this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
118
120
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
119
- if (this.requireSessionApiGuards && !options.guards) {
120
- throw new Error("Lambder: requireSessionApiGuards needs a guards map at creation for session APIs to declare from.");
121
+ this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
122
+ const requireFlag = this.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
123
+ if ((this.requireSessionApiGuards || this.requirePublicApiGuards) && !options.guards) {
124
+ throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
121
125
  }
122
126
  }
123
127
  setRouteFallbackHandler(routeFallbackHandler) {
@@ -215,9 +219,13 @@ export default class Lambder {
215
219
  throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
216
220
  }
217
221
  this.registeredApiNames.add(name);
218
- if (mode === "session" && this.requireSessionApiGuards && options.guards === undefined) {
219
- throw new Error(`Lambder: session API "${name}" declares no guards, and requireSessionApiGuards is on. ` +
220
- `Declare the guard that authorizes it, or the named no-op guard that marks the session itself as the whole authorization.`);
222
+ const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
223
+ if (guardsRequired && options.guards === undefined) {
224
+ const optOut = mode === "session"
225
+ ? "the named no-op guard that marks the session itself as the whole authorization"
226
+ : "the named no-op guard that records why anyone may call it";
227
+ throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
228
+ `Declare the guard that authorizes it, or ${optOut}.`);
221
229
  }
222
230
  const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
223
231
  if (!usesPolicies)
@@ -164,18 +164,42 @@ export type LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession extend
164
164
  param: undefined;
165
165
  } ? K & string : never;
166
166
  }[LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards];
167
- /**
168
- * The per-API `guards` option: one paramless guard name, an ordered list of
169
- * paramless names, or an object map that can carry each guard's param
170
- * (`true` enables a paramless guard). Map entries run in insertion order.
171
- */
172
- export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[] | {
167
+ /** The map form's full shape: every declarable guard name, each carrying its own param type. */
168
+ type LambderGuardsMap<TGuards, TPayload, TIncludeSession extends boolean> = {
173
169
  readonly [K in LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards]?: TGuards[K] extends {
174
170
  param: undefined;
175
171
  } ? true : TGuards[K] extends {
176
172
  param: infer P;
177
173
  } ? P : true;
178
174
  };
175
+ /**
176
+ * The map form with AT LEAST ONE entry: the union, over every declarable
177
+ * name, of "this one required and the rest optional".
178
+ *
179
+ * An all-optional map is inhabited by `{}`, which would let `guards: {}`
180
+ * satisfy requireSessionApiGuards / requirePublicApiGuards at the type level
181
+ * while declaring no guard at all: the option is present, so the required-field
182
+ * check passes, and it normalizes to zero entries, so nothing runs. Requiring
183
+ * the chosen key also rejects `{ theGuard: undefined }`, which an optional
184
+ * property accepts and which would otherwise reach the guard's handler with an
185
+ * undefined param.
186
+ */
187
+ type LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession extends boolean, TMap = LambderGuardsMap<TGuards, TPayload, TIncludeSession>> = {
188
+ [K in keyof TMap]-?: Required<Pick<TMap, K>> & Omit<TMap, K>;
189
+ }[keyof TMap];
190
+ /**
191
+ * The per-API `guards` option: one paramless guard name, a non-empty ordered
192
+ * list of paramless names, or a non-empty object map that can carry each
193
+ * guard's param (`true` enables a paramless guard). Map entries run in
194
+ * insertion order.
195
+ *
196
+ * Every form is non-empty by construction, so declaring the option is always
197
+ * declaring a guard. See LambderNonEmptyGuardsMap.
198
+ */
199
+ export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly [
200
+ LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>,
201
+ ...LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[]
202
+ ] | LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession>;
179
203
  /**
180
204
  * The typed ctx.guardData an API's handler sees: declared guards that return
181
205
  * a value, keyed by name. Check-only (void) guards never appear.
@@ -48,7 +48,18 @@ export class LambderApiGuardsEngine {
48
48
  }
49
49
  /** Startup validation of one API registration's guards option. */
50
50
  assertRegistration(apiName, mode, guardsOption) {
51
- for (const { name } of toGuardEntries(guardsOption)) {
51
+ const entries = toGuardEntries(guardsOption);
52
+ // The runtime half of LambderNonEmptyGuardsMap. `guards: {}` and
53
+ // `guards: []` are present-but-empty: they satisfy the require*ApiGuards
54
+ // field check while running nothing, which is the one shape that turns a
55
+ // mandatory authorization declaration back into an optional one. The type
56
+ // rejects both; a plain-JS caller, a cast, or a spread that happened to
57
+ // produce an empty object lands here instead.
58
+ if (guardsOption !== undefined && entries.length === 0) {
59
+ throw new Error(`Lambder: API "${apiName}" declares an empty guards option, which authorizes nothing. ` +
60
+ `Name the guard that authorizes it, or omit the option entirely.`);
61
+ }
62
+ for (const { name } of entries) {
52
63
  const guardDef = this.guards[name];
53
64
  if (!guardDef) {
54
65
  throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.8.1",
3
+ "version": "4.9.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",