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.
package/Readme.md CHANGED
@@ -2,15 +2,23 @@
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.2:**
6
+
7
+ - **Rate-limit budgets are explicit**: every policy declares `budget: "perApi"` (each referencing API gets its own counter, so the numbers are a per-API ceiling) or `budget: "perPolicy"` (one counter shared by every API referencing the policy). There is no default, so a declaration always says what its numbers span; the policy is the group, and two separate shared budgets are two policies.
8
+ - **Per-API tuning**: the `rateLimit` option gained a map form like guards, `rateLimit: { lookupPerIp: { perMin: 20 } }`, which merges window overrides over a perApi policy's own (a tighter burst keeps the policy's daily cap). Overriding the windows of a perPolicy policy is a startup error; `errorMessage` is overridable on either.
9
+ - **Retry-After**: a 429 carries the exceeded window's reset as a `Retry-After` header (CORS exposes it by default via the new `exposeHeaders` option), `LambderCaller` failure outcomes surface it as `retryAfterSeconds`, `LambderDdbRateLimiter.isRateLimited()` answers `false | { window, limit, resetAt }`, and `LambderApiError`/`refuse()` accept `headers`.
10
+ - **One refusal shape**: every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` (`{ type, content }`), and a policy's `errorMessage` is typed as one, so an `errorMessageHandler` reading `.content` works everywhere.
11
+ - **One validation path**: preflight slices (guard `apiInput`/`guardInput`, rate-limit `apiInput` keys) answer through `setApiInputValidationErrorHandler` exactly like the API's own schema.
12
+
5
13
  **New in v4:**
6
14
 
7
15
  - **Declarative auth as guards**: guards take per-API params (`guards: { orgPermission: "SOME.PERMISSION" }`), can require a session (`session: true`, compile-checked), and RETURN typed values that land on the handler's `ctx.guardData[name]`. Together with the apiInput/guardInput input modes, permission checks and device auth become registration-time declarations instead of per-handler boilerplate.
8
- - **Hardened policy layer**: rate-limit policies can share one counter across APIs (`scope: "policy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
16
+ - **Hardened policy layer**: rate-limit policies can share one counter across APIs (now `budget: "perPolicy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
9
17
  - **Secrets hashed at rest**: session records store only sha256 hashes of the bearer secrets, so a session-table read yields no usable cookies; `LambderSessionReadError` keeps a DynamoDB blip from reading as a logout.
10
18
  - **Three package entry points**: `lambder` (server), `lambder/client` (browser-safe by construction: no AWS SDK, no Node built-ins), `lambder/testing` (`LambderMSW`); sources organized into core/policies/session/stores/client/shared.
11
- - **Quality of life**: the `LambderApp` alias for annotating api modules, `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, fail-open rate limiting logs its passes.
19
+ - **Configuration at creation**: `initLambder<SessionData>().create({...})` takes the WHOLE configuration (serving options, session, cors, rate limits, guards, idempotency) in one declaration; the enable/define chain methods are gone, so nothing can be half-configured or wired in the wrong order, and api modules annotate with `typeof lambderApp` derived from the real instance. Plus `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, and fail-open rate limiting logs its passes.
12
20
 
13
- **Breaking in v4** (from 3.x): session records are reshaped (hashes at rest; live sessions invalidate once on upgrade, clients just re-login) and the manager-level `createSession`/`regenerateSession` return `LambderCreatedSession` (`{ session, sessionToken, csrfToken }`; the controller API is unchanged); `LambderMSW` moved from the root entry to `lambder/testing`; `LambderCaller.apiRaw()` is removed (use `apiOutcome()`, whose failure outcomes carry the envelope on `response`); the `multiValueHeaders` alias on `res.raw()` is removed (use `headers`); `LambderDdbIdempotency.complete()` answers `"stored" | "too-large" | "lost"`; idempotency keys must be 16-200 chars.
21
+ **Breaking in v4** (from 3.x): configuration moved entirely to creation, removing `enableCors`, `enableDdbSession`, `setSessionCookieKey`, `enableApiRateLimits`, `enableApiIdempotency`, and `defineApiGuards` in favor of the `cors`/`session`/`rateLimits`/`guards`/`idempotency` options of `initLambder().create({...})`; session records are reshaped (hashes at rest; live sessions invalidate once on upgrade, clients just re-login) and the manager-level `createSession`/`regenerateSession` return `LambderCreatedSession` (`{ session, sessionToken, csrfToken }`; the controller API is unchanged); `LambderMSW` moved from the root entry to `lambder/testing`; `LambderCaller.apiRaw()` is removed (use `apiOutcome()`, whose failure outcomes carry the envelope on `response`); the `multiValueHeaders` alias on `res.raw()` is removed (use `headers`); `LambderDdbIdempotency.complete()` answers `"stored" | "too-large" | "lost"`; idempotency keys must be 16-200 chars.
14
22
 
15
23
  v3 (public file serving, `addAction()`, gzip + ETag, thrown responses, `LambderTemplatingEngine`, `html`/`xml` tags, payload v2 support, `LambderDdbCache`, `createLambderI18n`, `LambderApiError`/`refuse()`, `apiOutcome()`, the declarative policy foundations) is documented in the git history.
16
24
 
@@ -63,25 +71,32 @@ Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (dec
63
71
 
64
72
  ### Basic Setup
65
73
 
74
+ The whole configuration is given at creation, in one declaration; only
75
+ registration (routes, apis, hooks, `use()`) chains afterwards. `initLambder`
76
+ is curried so the session data type is fixed first and everything else
77
+ (policy names, guard metadata) is INFERRED from the options; TypeScript type
78
+ arguments are all-or-nothing per call, so a plain `new Lambder<SessionData>({...})`
79
+ would silently widen the inferred policy types, which is why the curried
80
+ creator is the canonical entry.
81
+
66
82
  ```typescript
67
- import Lambder from 'lambder';
83
+ import { initLambder } from 'lambder';
68
84
  import { z } from 'zod';
69
85
  import * as path from 'path';
70
86
 
71
- const lambder = new Lambder({
87
+ interface SessionData { userId: string; }
88
+
89
+ const lambder = initLambder<SessionData>().create({
72
90
  apiPath: "/api",
73
91
  publicPath: path.resolve(`./public`),
74
- });
75
-
76
- // Enable session and CORS
77
- lambder
78
- .enableDdbSession({
92
+ session: {
79
93
  tableName: "website-session",
80
94
  tableRegion: "us-east-1",
81
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING"
82
- })
95
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
96
+ },
83
97
  // true allows any origin; or configure: { origins: ["https://app.example.com"], credentials: true }
84
- .enableCors(true);
98
+ cors: true,
99
+ });
85
100
 
86
101
  // Define type-safe APIs with Zod schemas
87
102
  lambder
@@ -277,18 +292,21 @@ lambder
277
292
 
278
293
  ### Session Management
279
294
 
280
- Enable DynamoDB-based sessions with `enableDdbSession()`. Optional configuration:
295
+ Enable DynamoDB-based sessions with the `session` option at creation:
281
296
 
282
297
  ```typescript
283
- lambder
284
- .enableDdbSession({
298
+ const lambder = initLambder<SessionData>().create({
299
+ apiPath: "/api",
300
+ session: {
285
301
  tableName: "website-session",
286
302
  tableRegion: "us-east-1",
287
303
  sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
288
- enableSlidingExpiration: true // Optional: extend session on each access
289
- })
290
- // Optionally customize session cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
291
- .setSessionCookieKey("MY_SESSION_TOKEN", "MY_CSRF_TOKEN");
304
+ enableSlidingExpiration: true, // Optional: extend session on each access
305
+ // Optionally customize cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
306
+ tokenCookieKey: "MY_SESSION_TOKEN",
307
+ csrfCookieKey: "MY_CSRF_TOKEN",
308
+ },
309
+ });
292
310
  ```
293
311
 
294
312
  #### DynamoDB Session Table Structure
@@ -308,16 +326,18 @@ The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt
308
326
  Session data often caches values derived from external state: roles, permissions, feature flags. Opt in to `dataRefresh` to give that data a shelf life. Every session read checks it, and once `ttlSeconds` have passed your `refresh` callback rebuilds the data, which is persisted onto the same session record: same tokens, same cookies, the session itself is untouched. Changes to the source of truth then reach every live session within `ttlSeconds`, with no mass session invalidation.
309
327
 
310
328
  ```typescript
311
- lambder.enableDdbSession({
312
- tableName: "website-session",
313
- tableRegion: "us-east-1",
314
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
315
- dataRefresh: {
316
- ttlSeconds: 600, // data is renewed at most every 10 minutes
317
- refresh: async (session) => {
318
- const user = await loadUser(session.data.userId);
319
- if (!user || user.disabled) return null; // null ends the session
320
- return buildSessionData(user);
329
+ const lambder = initLambder<SessionData>().create({
330
+ session: {
331
+ tableName: "website-session",
332
+ tableRegion: "us-east-1",
333
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
334
+ dataRefresh: {
335
+ ttlSeconds: 600, // data is renewed at most every 10 minutes
336
+ refresh: async (session) => {
337
+ const user = await loadUser(session.data.userId);
338
+ if (!user || user.disabled) return null; // null ends the session
339
+ return buildSessionData(user);
340
+ },
321
341
  },
322
342
  },
323
343
  });
@@ -495,22 +515,27 @@ Related: when an API call crashes with no `setGlobalErrorHandler` (or the handle
495
515
  Declare named building blocks once; reference them from API definitions with full type inference (unknown names are compile errors, and everything is re-asserted at registration time for plain-JS safety). Each piece is independent and optional.
496
516
 
497
517
  ```typescript
498
- import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
518
+ import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
499
519
 
500
- const lambder = new Lambder<SessionData>({ apiPath: "/api" })
520
+ const lambder = initLambder<SessionData>().create({
521
+ apiPath: "/api",
501
522
  // 1. Rate limiting: your limiter instance + named policies. Each policy
502
- // declares its windows AND what one counter tracks ("per").
503
- .enableApiRateLimits({
523
+ // declares its windows, what one counter tracks ("per"), and what one
524
+ // budget spans ("budget", required so the numbers are never ambiguous):
525
+ // "perApi" gives every referencing API its own counter (three APIs on a
526
+ // 60/min policy allow one IP 180/min in total), "perPolicy" makes every
527
+ // referencing API share ONE counter. The policy IS the group: separate
528
+ // shared budgets for, say, user APIs and report APIs are two policies.
529
+ rateLimits: {
504
530
  limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
505
531
  policies: {
506
- authPerIp: { perMin: 5, perHour: 30, per: "ip" },
507
- writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
532
+ authPerIp: { perMin: 5, perHour: 30, per: "ip", budget: "perApi" },
533
+ writePerUser: { perMin: 30, per: "session", budget: "perApi" }, // only referable from addSessionApi (also enforced at compile time)
508
534
  codePerEmail: {
509
535
  perMin: 3,
510
- // scope "policy": ONE combined budget across every API that
511
- // references this policy (send + register + reset share the
512
- // 3/min). Default scope "api" gives each API its own counter.
513
- scope: "policy",
536
+ // ONE combined budget across every API that references this
537
+ // policy: send + register + reset share the 3/min.
538
+ budget: "perPolicy",
514
539
  // apiInput key: derives from the API's OWN payload. Validated
515
540
  // before it runs, typed in the handler, and the policy is only
516
541
  // referable from APIs whose input schema carries `email`.
@@ -521,14 +546,14 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
521
546
  errorMessage: { type: "warning", content: "Too many attempts for this address." },
522
547
  },
523
548
  },
524
- })
549
+ },
525
550
  // 2. Idempotency: a store instance + replay defaults. May share the rate
526
551
  // limiter's table (records use an IDEM# key prefix).
527
- .enableApiIdempotency({
552
+ idempotency: {
528
553
  store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
529
554
  defaultTtlSeconds: 24 * 3600,
530
555
  failOpen: true, // DynamoDB down => execute without dedupe instead of failing
531
- })
556
+ },
532
557
  // 3. Named guards. Input modes: apiInput checks a slice of the API's own
533
558
  // payload (the schema keeps the field; the guard is declarable only
534
559
  // where the payload type passes both); guardInput is the guard's OWN
@@ -539,7 +564,7 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
539
564
  // (session: true, declarable only on addSessionApi), take a per-API
540
565
  // PARAM (annotate a 4th handler argument), and RETURN a value that
541
566
  // lands typed on the API handler's ctx.guardData[name].
542
- .defineApiGuards({
567
+ guards: {
543
568
  captcha: lambderGuard({
544
569
  guardInput: z.object({ captchaToken: z.string() }),
545
570
  handler: async (ctx, { captchaToken }) => {
@@ -557,7 +582,8 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
557
582
  handler: (ctx, _payload, _res, permission: PermissionString) =>
558
583
  requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
559
584
  }),
560
- });
585
+ },
586
+ });
561
587
 
562
588
  lambder.addApi("public.resetPassword", {
563
589
  // captchaToken is NOT declared here: it travels in the separate
@@ -566,16 +592,20 @@ lambder.addApi("public.resetPassword", {
566
592
  // in apiInput mode against the API's own payload.
567
593
  input: z.object({ email: z.string().email() }),
568
594
  output: z.object({ ok: z.boolean() }),
569
- rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
595
+ rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
570
596
  guards: "captcha", // one name, a list of names, or a { name: param } map
571
597
  }, handler);
572
598
 
573
599
  lambder.addSessionApi("secure.order.create", {
574
600
  input: OrderSchema,
575
601
  output: OrderResultSchema,
576
- rateLimit: "writePerUser",
602
+ // Map form: tune a perApi policy for this API. Overrides merge over the
603
+ // policy's windows (perMin here, the policy's other windows still apply)
604
+ // and errorMessage is overridable too. Window overrides on a perPolicy
605
+ // policy are a startup error: one shared counter has one set of limits.
606
+ rateLimit: { writePerUser: { perMin: 10 } },
577
607
  guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
578
- idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
608
+ idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
579
609
  }, async (ctx, res) => {
580
610
  const { organizationId } = ctx.guardData.orgPermission; // typed guard output
581
611
  // ...
@@ -584,18 +614,32 @@ lambder.addSessionApi("secure.order.create", {
584
614
 
585
615
  Guard results are typed end to end: the handler's `ctx.guardData` carries exactly the declared guards that return a value, a session guard on a public API is a compile error (and a startup assert), an apiInput guard is declarable only where the API's schema carries its fields, and a parameterized guard's param is typechecked in the declaration.
586
616
 
587
- For api modules split across files, annotate their `lambder` parameter with the `LambderApp` alias instead of hand-writing the instance generics:
617
+ 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):
588
618
 
589
619
  ```typescript
590
- export type AppLambder = LambderApp<SessionData, {
591
- policies: typeof apiRateLimitPolicies; // what enableApiRateLimits({ policies }) receives
592
- guards: typeof apiGuards; // what defineApiGuards(...) receives
593
- idempotency: true; // enableApiIdempotency(...) is called
594
- }>;
620
+ // app.ts: declarations + the fully configured instance
621
+ export const lambderApp = initLambder<SessionData>().create({
622
+ apiPath: "/api",
623
+ session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
624
+ rateLimits: { limiter, policies: apiRateLimitPolicies },
625
+ idempotency: { store: idempotencyStore },
626
+ guards: apiGuards,
627
+ });
628
+ export type AppLambder = typeof lambderApp;
629
+
630
+ // orders.ts: an api module
595
631
  export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
632
+
633
+ // index.ts: registration only (hooks, routes, modules)
634
+ const lambder = lambderApp.addHook(...).use(orderApi)...;
635
+ export const handler = lambder.getHandler();
596
636
  ```
597
637
 
598
- Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
638
+ Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), carrying the standard `LambderRefusalMessage` shape unless a policy names its own `errorMessage`, so the caller's `errorMessageHandler` surfaces them with zero client code. A 429 also carries `Retry-After` (the exceeded fixed window's reset; `LambderCaller` outcomes expose it as `retryAfterSeconds`, and the CORS layer lists it in `Access-Control-Expose-Headers` by default).
639
+
640
+ **Rate limits count attempts, not successes.** Each window is one atomic conditional increment, and a refused request keeps every increment made before the refusal: the smaller windows of the refusing policy, every policy listed before it, and all of them when a later guard or the input validation refuses. There is no compensating decrement (it would give up the conditional-ADD atomicity and add a write per refusal). So order stacked policies by which counter you want charged on refusals: `["authPerIp", "codePerEmail"]` still charges the IP when the per-email cap refuses, which is the abuse-resistant direction.
641
+
642
+ Preflight input slices (guard `apiInput`/`guardInput` values, rate-limit `apiInput` keys) answer a rejection through the same path as the API's own schema: `setApiInputValidationErrorHandler` when set, otherwise the standard 422 body. One failure, one shape, whichever schema rejected it.
599
643
 
600
644
  **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
601
645
 
@@ -60,6 +60,8 @@ export type LambderApiOutcome<T> = {
60
60
  status?: number;
61
61
  /** Envelope errorMessage, when the server provided one. */
62
62
  errorMessage?: any;
63
+ /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it). */
64
+ retryAfterSeconds?: number;
63
65
  /** Underlying Error for network/timeout/server/unknown failures. */
64
66
  error?: Error;
65
67
  /** Zod issue detail for 'validation'. */
@@ -222,6 +222,10 @@ export default class LambderCaller {
222
222
  }
223
223
  return { ok: false, reason: 'validation', status: res.status, zodError };
224
224
  }
225
+ // Retry-After (delta-seconds) rides every refusal that knows its
226
+ // reset time, e.g. a rate limit; absent or unreadable is undefined.
227
+ const retryAfterValue = Number(res.headers.get("retry-after") ?? NaN);
228
+ const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
225
229
  let data;
226
230
  try {
227
231
  data = await res.json();
@@ -248,7 +252,7 @@ export default class LambderCaller {
248
252
  else {
249
253
  await reportError(new Error("Version Expired; Please refresh;"));
250
254
  }
251
- return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
255
+ return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
252
256
  }
253
257
  if (data.sessionExpired) {
254
258
  this.clearSessionCookies();
@@ -258,7 +262,7 @@ export default class LambderCaller {
258
262
  else {
259
263
  await reportError(new Error("Session Expired; Please log in again;"));
260
264
  }
261
- return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
265
+ return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
262
266
  }
263
267
  if (data.notAuthorized) {
264
268
  if (notAuthorizedHandler) {
@@ -267,7 +271,7 @@ export default class LambderCaller {
267
271
  else {
268
272
  await reportError(new Error("Not Authorized;"));
269
273
  }
270
- return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data };
274
+ return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
271
275
  }
272
276
  if (data.message && messageHandler) {
273
277
  await messageHandler(data.message);
@@ -276,7 +280,7 @@ export default class LambderCaller {
276
280
  if (errorMessageHandler) {
277
281
  await errorMessageHandler(data.errorMessage);
278
282
  }
279
- return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data };
283
+ return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
280
284
  }
281
285
  return { ok: true, payload: data.payload, response: data };
282
286
  }
@@ -9,7 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
9
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
10
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
11
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
12
- import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames } from "../policies/LambderApiRateLimits.js";
12
+ import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
13
13
  import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
14
14
  import type { MergeContract } from "../shared/LambderApiContract.js";
15
15
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
@@ -64,7 +64,40 @@ export type LambderHandler = {
64
64
  (event: LambderHttpEvent, context: Context): Promise<LambderHttpResponse>;
65
65
  (event: unknown, context: Context): Promise<unknown>;
66
66
  };
67
- export type LambderConstructorOptions = {
67
+ /** DynamoDB session configuration (the `session` option of create/new). */
68
+ export type LambderSessionOptions<TSessionData = any> = {
69
+ tableName: string;
70
+ tableRegion: string;
71
+ sessionSalt: string;
72
+ enableSlidingExpiration?: boolean;
73
+ /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
74
+ slidingWriteIntervalSeconds?: number;
75
+ /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
76
+ cookie?: LambderSessionCookieOptions;
77
+ /** Session cookie names. Defaults: LMDRSESSIONTKID / LMDRSESSIONCSTK. */
78
+ tokenCookieKey?: string;
79
+ csrfCookieKey?: string;
80
+ partitionKey?: string;
81
+ sortKey?: string;
82
+ /**
83
+ * Opt-in freshness for session.data derived from external state (roles,
84
+ * permissions, feature flags...). Every session read renews data past
85
+ * its ttlSeconds via your refresh callback, persisting in place on the
86
+ * same record: same tokens, same cookies. Return null from refresh to
87
+ * end the session. See LambderSessionDataRefreshConfig for the exact
88
+ * semantics.
89
+ */
90
+ dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
91
+ };
92
+ /**
93
+ * Everything an instance is configured with, in ONE declaration: base
94
+ * serving options plus the type-affecting policy layer (rate limits,
95
+ * guards, idempotency) and session/CORS config. There are no enable/define
96
+ * chain methods; the instance is born fully configured and fully typed
97
+ * (via initLambder), so no ordering rules exist and no partially-configured
98
+ * instance type ever needs a name.
99
+ */
100
+ export type LambderCreateOptions<TSessionData = any> = {
68
101
  publicPath?: string;
69
102
  apiPath?: string;
70
103
  apiVersion?: string;
@@ -76,21 +109,34 @@ export type LambderConstructorOptions = {
76
109
  etag?: boolean;
77
110
  /** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
78
111
  maxResponseBytes?: number;
112
+ /** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
113
+ cors?: boolean | LambderCorsConfig;
114
+ /** DynamoDB-backed sessions; required for addSessionApi/addSessionRoute. */
115
+ session?: LambderSessionOptions<TSessionData>;
116
+ /** Declarative per-API rate limiting: your limiter plus named policies APIs reference (typed) via the `rateLimit` option. */
117
+ rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>;
118
+ /** Named guards APIs reference (typed) via the `guards` option; build each with lambderGuard(). */
119
+ guards?: Record<string, LambderApiGuard<any, any, any>>;
120
+ /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
121
+ idempotency?: LambderApiIdempotencyConfig;
79
122
  };
80
123
  /**
81
- * Main Lambder class for building type-safe serverless APIs
124
+ * Main Lambder class for building type-safe serverless APIs. Create
125
+ * instances with initLambder<SessionData>().create({...}) (see below): the
126
+ * whole configuration, including the typed policy layer, is given at
127
+ * construction, and only registration (routes, apis, hooks, use) chains.
82
128
  *
83
129
  * @typeParam TSessionData - Type of session data stored in DynamoDB
84
130
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
85
- * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
86
- * @typeParam _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
87
- * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
131
+ * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
132
+ * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
133
+ * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
88
134
  *
89
135
  * @example
90
136
  * ```typescript
91
137
  * interface SessionData { userId: string; role: string; }
92
138
  *
93
- * const lambder = new Lambder<SessionData>({ apiPath: '/api' })
139
+ * const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
94
140
  * .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
95
141
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
96
142
  * ```
@@ -130,30 +176,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
130
176
  private sessionCookieOptions;
131
177
  private sessionTokenCookieKey;
132
178
  private sessionCsrfCookieKey;
133
- constructor(options?: LambderConstructorOptions);
134
- enableCors(config: boolean | LambderCorsConfig): this;
135
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
136
- tableName: string;
137
- tableRegion: string;
138
- sessionSalt: string;
139
- enableSlidingExpiration?: boolean;
140
- /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
141
- slidingWriteIntervalSeconds?: number;
142
- /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
143
- cookie?: LambderSessionCookieOptions;
144
- partitionKey?: string;
145
- sortKey?: string;
146
- /**
147
- * Opt-in freshness for session.data derived from external state
148
- * (roles, permissions, feature flags...). Every session read
149
- * renews data past its ttlSeconds via your refresh callback,
150
- * persisting in place on the same record: same tokens, same
151
- * cookies. Return null from refresh to end the session. See
152
- * LambderSessionDataRefreshConfig for the exact semantics.
153
- */
154
- dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
155
- }): this;
156
- setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
179
+ constructor(options?: LambderCreateOptions<TSessionData>);
157
180
  setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
158
181
  setApiFallbackHandler(apiFallbackHandler: FallbackHandlerFunction): this;
159
182
  setApiInputValidationErrorHandler(apiInputValidationErrorHandler: ApiInputValidationErrorHandlerFunction): this;
@@ -181,40 +204,14 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
181
204
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
182
205
  /** Apply the serveIndexHtml gates; null means fall through. */
183
206
  private tryServeIndexHtml;
207
+ private getOrCreatePolicyEngine;
184
208
  /**
185
- * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
186
- * instance plus named policies, each declaring its windows and what one
187
- * counter tracks (`per`: "ip", "session", or a custom key function).
188
- * APIs then reference policies by name via the `rateLimit` option; the
189
- * returned type narrows so only declared names are accepted, and
190
- * policies keyed per "session" are only referable from addSessionApi.
191
- * Callable once; call it before the API registrations that use it.
192
- */
193
- enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuards, _TIdempotencyEnabled>;
194
- /**
195
- * Wire declarative idempotency: your LambderDdbIdempotency instance plus
196
- * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
197
- * the option is a type error until this is called. Requests carrying a
198
- * client `idempotencyKey` (sent by LambderCaller) claim an
199
- * identity+api+key scope atomically: concurrent duplicates refuse with
200
- * 409, replays of a completed request return the stored response, and a
201
- * crashed original releases its claim. Callable once.
202
- */
203
- enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
204
- /**
205
- * Define named guards that APIs reference (typed) via the `guards`
206
- * option. Each guard is built with lambderGuard(): its input mode
207
- * (apiInput slice of the API's own payload, a separate client-sent
208
- * guardInput, or none), an optional `session: true` requirement, an
209
- * optional parameter APIs pass in their declaration (`guards: { name:
210
- * param }`), and an optional return value that lands typed on the
211
- * handler's ctx.guardData[name]. Guards run before input validation, in
212
- * the order the API declares them; a handler refuses by throwing
213
- * (typically refuse()). Callable multiple times so domain modules can
214
- * contribute their own; names must not collide.
209
+ * The response for a rejected input: the app's
210
+ * setApiInputValidationErrorHandler when set, otherwise the standard 422
211
+ * body. The API's own schema and every preflight slice (guard inputs,
212
+ * rate-limit keys) answer through here, so one failure has one shape.
215
213
  */
216
- defineApiGuards<TGuards extends Record<string, LambderApiGuard<any, any, any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
217
- private getOrCreatePolicyEngine;
214
+ private inputValidationRefusal;
218
215
  /** Registration-time checks shared by addApi/addSessionApi. */
219
216
  private assertApiRegistration;
220
217
  addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
@@ -222,28 +219,28 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
222
219
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
223
220
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
224
221
  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>;
225
- 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 LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
222
+ 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: {
226
223
  input: TInput;
227
224
  output: TOutput;
228
225
  } & {
229
- /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
226
+ /** 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. */
230
227
  rateLimit?: TRateOpt;
231
228
  /** 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. */
232
229
  guards?: TGuardsOpt;
233
- /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
230
+ /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
234
231
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
235
232
  ttlSeconds?: number;
236
233
  }) : never;
237
234
  }, 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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
238
- 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 LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
235
+ 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: {
239
236
  input: TInput;
240
237
  output: TOutput;
241
238
  } & {
242
- /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
239
+ /** 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. */
243
240
  rateLimit?: TRateOpt;
244
241
  /** 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. */
245
242
  guards?: TGuardsOpt;
246
- /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
243
+ /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
247
244
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
248
245
  ttlSeconds?: number;
249
246
  }) : never;
@@ -293,25 +290,42 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
293
290
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
294
291
  }
295
292
  /**
296
- * The app's configured Lambder instance type, for annotating the parameter
297
- * of api modules used via lambder.use(...). Name the pieces you wired in
298
- * index.ts and the guard metadata mapping happens for you:
293
+ * The canonical way to create an instance: fix the session data type first,
294
+ * then create with the full configuration in one declaration; the policy,
295
+ * guard, and idempotency types are INFERRED from the options, so the
296
+ * instance is born fully typed and `typeof lambderApp` is the annotation
297
+ * type for api modules. No enable/define chain exists, so there are no
298
+ * ordering rules and nothing can be half-configured.
299
299
  *
300
300
  * ```typescript
301
- * export type AppLambder = LambderApp<SessionData, {
302
- * policies: typeof apiRateLimitPolicies; // enableApiRateLimits({ policies })
303
- * guards: typeof apiGuards; // defineApiGuards(apiGuards)
304
- * idempotency: true; // enableApiIdempotency(...) was called
305
- * }>;
301
+ * // app.ts (imports no api modules, so modules can import the type back)
302
+ * export const lambderApp = initLambder<SessionData>().create({
303
+ * apiPath: "/api",
304
+ * session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
305
+ * rateLimits: { limiter, policies },
306
+ * guards,
307
+ * idempotency: { store },
308
+ * });
309
+ * export type AppLambder = typeof lambderApp;
310
+ *
311
+ * // orders.ts
312
+ * export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
313
+ *
314
+ * // index.ts: registration only
315
+ * const lambder = lambderApp.addHook(...).use(orderApi)...;
316
+ * export const handler = lambder.getHandler();
306
317
  * ```
307
318
  *
308
- * Every field is optional; omit what the app does not wire. The declaration
309
- * is still an assertion about index.ts (registration-time asserts backstop a
310
- * mismatch at cold start), but derive the fields from the same exported
311
- * consts the enable calls receive and the types cannot drift.
319
+ * Why curried (`initLambder<S>().create(...)` rather than
320
+ * `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
321
+ * call, so explicitly passing the session data type to the constructor
322
+ * would silently WIDEN the inferred policy and guard types to their {}
323
+ * defaults. Fixing the session type in the first call lets the second call
324
+ * infer everything else from the options. `new Lambder(options)` remains
325
+ * for untyped or session-data-free instances.
312
326
  */
313
- export type LambderApp<TSessionData, TConfig extends {
314
- policies?: Record<string, LambderApiRateLimitPolicyConfig>;
315
- guards?: Record<string, LambderApiGuard<any, any, any>>;
316
- idempotency?: boolean;
317
- } = {}> = Lambder<TSessionData, {}, TConfig["policies"] extends Record<string, LambderApiRateLimitPolicyConfig> ? TConfig["policies"] : {}, TConfig["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TConfig["guards"]> : {}, TConfig["idempotency"] extends true ? true : false>;
327
+ export declare const initLambder: <TSessionData = any>() => {
328
+ create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
329
+ policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
330
+ } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false>;
331
+ };