lambder 3.7.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/Readme.md +92 -24
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +50 -14
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +29 -17
  4. package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
  5. package/dist/client.d.ts +16 -0
  6. package/dist/client.js +17 -0
  7. package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -19
  8. package/dist/{Lambder.js → core/Lambder.js} +28 -12
  9. package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
  10. package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
  11. package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
  12. package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
  13. package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
  14. package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
  15. package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
  16. package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
  17. package/dist/index.d.ts +36 -35
  18. package/dist/index.js +21 -20
  19. package/dist/policies/LambderApiGuards.d.ts +221 -0
  20. package/dist/policies/LambderApiGuards.js +79 -0
  21. package/dist/policies/LambderApiIdempotency.d.ts +58 -0
  22. package/dist/policies/LambderApiIdempotency.js +215 -0
  23. package/dist/policies/LambderApiPolicies.d.ts +40 -0
  24. package/dist/policies/LambderApiPolicies.js +47 -0
  25. package/dist/policies/LambderApiRateLimits.d.ts +90 -0
  26. package/dist/policies/LambderApiRateLimits.js +77 -0
  27. package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
  28. package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
  29. package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
  30. package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
  31. package/dist/shared/LambderApiContract.d.ts +41 -0
  32. package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
  33. package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
  34. package/dist/stores/LambderDdbCompression.d.ts +3 -0
  35. package/dist/stores/LambderDdbCompression.js +39 -0
  36. package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
  37. package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
  38. package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
  39. package/dist/testing.d.ts +9 -0
  40. package/dist/testing.js +8 -0
  41. package/package.json +19 -1
  42. package/dist/LambderApiContract.d.ts +0 -21
  43. package/dist/LambderApiPolicies.d.ts +0 -177
  44. package/dist/LambderApiPolicies.js +0 -219
  45. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  46. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  47. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  48. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  49. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  50. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  51. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  52. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  53. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  54. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  55. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  56. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  57. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  58. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  59. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  60. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  61. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  62. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  63. /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
package/Readme.md CHANGED
@@ -1,8 +1,18 @@
1
- # Lambder - Serverless NodeJS Web Framework (v3)
1
+ # Lambder - Serverless NodeJS Web Framework (v4)
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 v4:**
6
+
7
+ - **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.
9
+ - **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
+ - **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.
12
+
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.
14
+
15
+ 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.
6
16
 
7
17
  ## Features
8
18
 
@@ -35,6 +45,20 @@ npm install lambder zod
35
45
  yarn add lambder zod
36
46
  ```
37
47
 
48
+ ## Package Entry Points
49
+
50
+ The package ships three entry points; pick by where the code runs:
51
+
52
+ | Entry | Runs in | Carries |
53
+ |-------|---------|---------|
54
+ | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
55
+ | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiError`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
56
+ | `lambder/testing` | Dev and test tooling | `LambderMSW`, the MSW adapter that serves your typed contract from mock handlers |
57
+
58
+ Frontends and shared isomorphic packages should import from `lambder/client` only; the entry's module graph contains no AWS SDK, Node built-ins, or server pipeline, so the browser boundary is structural rather than left to tree-shaking.
59
+
60
+ Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/` (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules both entries re-export).
61
+
38
62
  ## Backend Usage
39
63
 
40
64
  ### Basic Setup
@@ -275,6 +299,10 @@ lambder
275
299
 
276
300
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
277
301
 
302
+ #### How the secrets are stored
303
+
304
+ The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
305
+
278
306
  #### Keeping session data fresh (`dataRefresh`)
279
307
 
280
308
  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.
@@ -300,6 +328,7 @@ Semantics:
300
328
  - The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
301
329
  - Returning `null` deletes the session; the request is answered as session-expired.
302
330
  - Thrown errors fail the request as a `LambderSessionDataRefreshError` and leave the session untouched (they are never mistaken for a logout). Catch inside and return `session.data` to explicitly serve stale instead.
331
+ - Similarly, a DynamoDB failure while READING a session fails the request as a `LambderSessionReadError` instead of reading as "no session": answering session-expired there would make LambderCaller clear the client's cookies, turning an infra blip into a forced logout.
303
332
  - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
304
333
  - Records created before `dataRefresh` was enabled renew on their first read.
305
334
  - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
@@ -478,11 +507,15 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
478
507
  writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
479
508
  codePerEmail: {
480
509
  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.
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",
514
+ // apiInput key: derives from the API's OWN payload. Validated
515
+ // before it runs, typed in the handler, and the policy is only
516
+ // referable from APIs whose input schema carries `email`.
484
517
  per: lambderRateLimitKey({
485
- input: z.object({ email: z.string() }),
518
+ apiInput: z.object({ email: z.string() }),
486
519
  handler: (_ctx, { email }) => email.trim().toLowerCase(),
487
520
  }),
488
521
  errorMessage: { type: "warning", content: "Too many attempts for this address." },
@@ -496,41 +529,75 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
496
529
  defaultTtlSeconds: 24 * 3600,
497
530
  failOpen: true, // DynamoDB down => execute without dedupe instead of failing
498
531
  })
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.
532
+ // 3. Named guards. Input modes: apiInput checks a slice of the API's own
533
+ // payload (the schema keeps the field; the guard is declarable only
534
+ // where the payload type passes both); guardInput is the guard's OWN
535
+ // value, sent separately by the caller via options.guardInputs and
536
+ // made mandatory by the contract, so forgetting it is a compile error
537
+ // at the call site; or neither. Both are validated pre-run and typed
538
+ // in the handler. On top of that a guard may require a session
539
+ // (session: true, declarable only on addSessionApi), take a per-API
540
+ // PARAM (annotate a 4th handler argument), and RETURN a value that
541
+ // lands typed on the API handler's ctx.guardData[name].
504
542
  .defineApiGuards({
505
543
  captcha: lambderGuard({
506
- input: z.object({ captchaToken: z.string() }),
544
+ guardInput: z.object({ captchaToken: z.string() }),
507
545
  handler: async (ctx, { captchaToken }) => {
508
546
  if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
509
547
  },
510
548
  }),
549
+ deviceAuth: lambderGuard({
550
+ apiInput: z.object({ deviceToken: z.string() }),
551
+ // Returns a value: the API handler reads ctx.guardData.deviceAuth.
552
+ handler: async (_ctx, { deviceToken }) => await resolveDeviceOrRefuse(deviceToken),
553
+ }),
554
+ orgPermission: lambderGuard({
555
+ session: true,
556
+ // Parameterized: APIs declare guards: { orgPermission: "SOME.PERMISSION" }.
557
+ handler: (ctx, _payload, _res, permission: PermissionString) =>
558
+ requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
559
+ }),
511
560
  });
512
561
 
513
562
  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.
563
+ // captchaToken is NOT declared here: it travels in the separate
564
+ // guardInputs channel, so the guard validates and consumes it and the
565
+ // handler never sees it. `email` IS declared: the codePerEmail key runs
566
+ // in apiInput mode against the API's own payload.
517
567
  input: z.object({ email: z.string().email() }),
518
568
  output: z.object({ ok: z.boolean() }),
519
569
  rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
520
- guards: "captcha",
570
+ guards: "captcha", // one name, a list of names, or a { name: param } map
521
571
  }, handler);
522
572
 
523
573
  lambder.addSessionApi("secure.order.create", {
524
574
  input: OrderSchema,
525
575
  output: OrderResultSchema,
526
576
  rateLimit: "writePerUser",
577
+ guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
527
578
  idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
528
- }, handler);
579
+ }, async (ctx, res) => {
580
+ const { organizationId } = ctx.guardData.orgPermission; // typed guard output
581
+ // ...
582
+ });
583
+ ```
584
+
585
+ 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
+
587
+ For api modules split across files, annotate their `lambder` parameter with the `LambderApp` alias instead of hand-writing the instance generics:
588
+
589
+ ```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
+ }>;
595
+ export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
529
596
  ```
530
597
 
531
- Request flow per API: session (session APIs) → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
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.
532
599
 
533
- **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation and reuse it on retries. The scope is identity (session key, or IP for public APIs) + API name + key: concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL, and a crashed original releases its claim so a retry actually retries. A response delivered by throwing (`res.die.*`, `throw res.api(...)`) counts as a completion and is stored like a returned one; thrown `LambderApiError` refusals release the claim instead. Responses with status ≥ 500 are never stored. 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.
600
+ **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.
534
601
 
535
602
  Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
536
603
 
@@ -581,12 +648,12 @@ cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
581
648
 
582
649
  ## Frontend Usage with LambderCaller
583
650
 
584
- LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend.
651
+ LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend. Import it from the `lambder/client` entry: everything reachable from there is browser-safe by construction (no AWS SDK, no Node built-ins, no server pipeline), so your bundle can never pick up server code.
585
652
 
586
653
  ### Basic Setup with Type Safety
587
654
 
588
655
  ```typescript
589
- import { LambderCaller } from "lambder";
656
+ import { LambderCaller } from "lambder/client";
590
657
  import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
591
658
 
592
659
  const lambderCaller = new LambderCaller<ApiContractType>({
@@ -635,8 +702,9 @@ Every configured handler still fires on the matching failure, so global UX (toas
635
702
  Also available:
636
703
 
637
704
  - **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
- - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
639
- - **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.
705
+ - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
706
+ - **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.
707
+ - **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. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
640
708
 
641
709
  ### Benefits
642
710
 
@@ -654,7 +722,7 @@ Also available:
654
722
  LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
655
723
 
656
724
  ```typescript
657
- import { LambderMSW } from 'lambder';
725
+ import { LambderMSW } from 'lambder/testing';
658
726
  import { setupServer } from 'msw/node';
659
727
  import type { ApiContractType } from './backend/handler';
660
728
 
@@ -1,6 +1,18 @@
1
- import { LambderApiResponse } from './LambderResponseBuilder';
2
- import type { ApiContractShape } from './LambderApiContract';
1
+ import type { LambderApiResponse } from '../shared/LambderApiContract.js';
2
+ import type { ApiContractShape } from '../shared/LambderApiContract.js';
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;
@@ -24,6 +36,13 @@ type FetchEndEventHandler = (params: {
24
36
  type ErrorHandler = (err: Error) => void | Promise<void>;
25
37
  type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
26
38
  type MessageHandler = (message: any) => void | Promise<void>;
39
+ /** One logical operation's rotating idempotency key: see LambderCaller.createIdempotencyKeyScope(). */
40
+ export type LambderIdempotencyKeyScope = {
41
+ /** The key for the operation currently in progress. */
42
+ readonly current: string;
43
+ /** Call after a confirmed success: the next operation is a new intent. Returns the new key. */
44
+ rotate(): string;
45
+ };
27
46
  export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
28
47
  /**
29
48
  * Discriminated result of an API call: `ok: true` carries the payload, every
@@ -55,12 +74,20 @@ export type LambderCallOptions = {
55
74
  timeoutMs?: number;
56
75
  /** External abort signal, combined with the timeout when both are set. */
57
76
  signal?: AbortSignal;
77
+ /**
78
+ * Values for the API's guardInput-mode guards, keyed by guard name; sent
79
+ * beside the payload and consumed by the guards before validation. The
80
+ * typed contract makes this REQUIRED for APIs that declare such guards.
81
+ */
82
+ guardInputs?: Record<string, unknown>;
58
83
  /**
59
84
  * Replay-protection key for APIs declared idempotent on the server.
60
- * Generate once per logical operation (e.g. crypto.randomUUID() when the
61
- * form opens) and send the same key on retries: duplicates of an
62
- * in-flight request refuse, and repeats of a completed one replay its
63
- * stored response instead of re-executing.
85
+ * Generate once per logical operation with createIdempotencyKey() and
86
+ * send the same key on retries: duplicates of an in-flight request
87
+ * refuse, and repeats of a completed one replay its stored response
88
+ * instead of re-executing. Must be UNGUESSABLE random (it scopes the
89
+ * replay record for logged-out clients) and at least 16 characters; the
90
+ * server refuses shorter keys with a 400.
64
91
  */
65
92
  idempotencyKey?: string;
66
93
  versionExpiredHandler?: VoidFunction;
@@ -111,6 +138,21 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
111
138
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
112
139
  });
113
140
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
141
+ /**
142
+ * A self-rotating idempotency key for a component or form that performs
143
+ * the same logical operation repeatedly. `current` is the key for the
144
+ * operation in progress: send it with every attempt (first try, retry
145
+ * after a failure, double-tap) so the server collapses them. Call
146
+ * `rotate()` after a confirmed success so the next operation is a new
147
+ * intent with its own key.
148
+ *
149
+ * ```typescript
150
+ * const submitKey = LambderCaller.createIdempotencyKeyScope();
151
+ * await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
152
+ * submitKey.rotate();
153
+ * ```
154
+ */
155
+ static createIdempotencyKeyScope(): LambderIdempotencyKeyScope;
114
156
  /**
115
157
  * Generate an idempotency key for one logical operation. Create it when
116
158
  * the operation begins (a form opens, a draft starts), send the same key
@@ -127,14 +169,8 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
127
169
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
128
170
  * instead of collapsing every failure to null. Never throws.
129
171
  */
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>>;
131
- /**
132
- * Legacy shape: the parsed envelope on success (and on structured
133
- * errorMessage refusals, which carry an envelope), null on every other
134
- * failure. Prefer apiOutcome() when the call site needs to know why.
135
- */
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>;
172
+ 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>>;
137
173
  /** 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>;
174
+ 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
175
  }
140
176
  export {};
@@ -39,6 +39,27 @@ export default class LambderCaller {
39
39
  this.sessionTokenCookieKey = sessionTokenCookieKey;
40
40
  this.sessionCsrfCookieKey = sessionCsrfCookieKey;
41
41
  }
42
+ /**
43
+ * A self-rotating idempotency key for a component or form that performs
44
+ * the same logical operation repeatedly. `current` is the key for the
45
+ * operation in progress: send it with every attempt (first try, retry
46
+ * after a failure, double-tap) so the server collapses them. Call
47
+ * `rotate()` after a confirmed success so the next operation is a new
48
+ * intent with its own key.
49
+ *
50
+ * ```typescript
51
+ * const submitKey = LambderCaller.createIdempotencyKeyScope();
52
+ * await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
53
+ * submitKey.rotate();
54
+ * ```
55
+ */
56
+ static createIdempotencyKeyScope() {
57
+ let key = LambderCaller.createIdempotencyKey();
58
+ return {
59
+ get current() { return key; },
60
+ rotate() { key = LambderCaller.createIdempotencyKey(); return key; },
61
+ };
62
+ }
42
63
  /**
43
64
  * Generate an idempotency key for one logical operation. Create it when
44
65
  * the operation begins (a form opens, a draft starts), send the same key
@@ -149,6 +170,7 @@ export default class LambderCaller {
149
170
  headers: { 'Content-Type': 'application/json', ...(headers || {}) },
150
171
  body: JSON.stringify({
151
172
  apiName, version, token, siteHost, payload,
173
+ ...(options?.guardInputs !== undefined ? { guardInputs: options.guardInputs } : {}),
152
174
  ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
153
175
  }),
154
176
  ...(signal ? { signal } : {}),
@@ -280,25 +302,15 @@ export default class LambderCaller {
280
302
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
281
303
  * instead of collapsing every failure to null. Never throws.
282
304
  */
283
- async apiOutcome(apiName, payload, options) {
284
- return await this.dispatch(apiName, payload, options);
285
- }
286
- ;
287
- /**
288
- * Legacy shape: the parsed envelope on success (and on structured
289
- * errorMessage refusals, which carry an envelope), null on every other
290
- * failure. Prefer apiOutcome() when the call site needs to know why.
291
- */
292
- async apiRaw(apiName, payload, options) {
293
- const outcome = await this.dispatch(apiName, payload, options);
294
- if (outcome.ok)
295
- return outcome.response;
296
- return outcome.reason === 'errorMessage' ? outcome.response : null;
305
+ async apiOutcome(apiName, payload, ...rest) {
306
+ return await this.dispatch(apiName, payload, rest[0]);
297
307
  }
298
308
  ;
299
309
  /** 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;
310
+ async api(apiName, payload, ...rest) {
311
+ const outcome = await this.dispatch(apiName, payload, rest[0]);
312
+ if (outcome.ok)
313
+ return outcome.response?.payload;
314
+ return outcome.reason === 'errorMessage' ? outcome.response?.payload : undefined;
303
315
  }
304
316
  }
@@ -1,4 +1,4 @@
1
- import type { ApiContractShape } from './LambderApiContract';
1
+ import type { ApiContractShape } from '../shared/LambderApiContract.js';
2
2
  type RequestHandler = any;
3
3
  /** The parts of the msw module LambderMSW uses: `import { http, HttpResponse } from "msw"`. */
4
4
  export type LambderMswModule = {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Browser entry point (`import ... from "lambder/client"`).
3
+ *
4
+ * Everything reachable from here is browser-safe: no AWS SDK, no Node
5
+ * built-ins, no server pipeline. Frontends and isomorphic shared packages
6
+ * import from this entry so their bundles can never pick up server code;
7
+ * the root entry (`"lambder"`) is the server surface.
8
+ */
9
+ export { default as LambderCaller } from "./client/LambderCaller.js";
10
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
11
+ export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
12
+ export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
+ export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
14
+ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
15
+ export { createLambderI18n } from "./shared/LambderI18n.js";
16
+ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
package/dist/client.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser entry point (`import ... from "lambder/client"`).
3
+ *
4
+ * Everything reachable from here is browser-safe: no AWS SDK, no Node
5
+ * built-ins, no server pipeline. Frontends and isomorphic shared packages
6
+ * import from this entry so their bundles can never pick up server code;
7
+ * the root entry (`"lambder"`) is the server surface.
8
+ */
9
+ // The typed API caller.
10
+ export { default as LambderCaller } from "./client/LambderCaller.js";
11
+ // Typed API refusals (isomorphic: shared code may throw them from anywhere;
12
+ // in the browser they are plain Errors).
13
+ export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
14
+ // Type-safe templating (tagged templates with auto-escaping)
15
+ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
16
+ // Typed translations (standalone, isomorphic)
17
+ export { createLambderI18n } from "./shared/LambderI18n.js";
@@ -5,11 +5,13 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
5
  import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
6
6
  import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
7
  import { type LambderCorsConfig } from "./LambderCors.js";
8
- import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
9
- import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
8
+ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
9
+ import LambderSessionController, { type LambderSessionCookieOptions } from "../session/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";
12
- import type { MergeContract } from "./LambderApiContract.js";
11
+ import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
12
+ import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames } from "../policies/LambderApiRateLimits.js";
13
+ import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
14
+ import type { MergeContract } from "../shared/LambderApiContract.js";
13
15
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
14
16
  export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
15
17
  export type { LambderCorsConfig } from "./LambderCors.js";
@@ -201,16 +203,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
201
203
  enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
202
204
  /**
203
205
  * 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.
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.
212
215
  */
213
- defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardPayloadMap<TGuards>, _TIdempotencyEnabled>;
216
+ defineApiGuards<TGuards extends Record<string, LambderApiGuard<any, any, any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
214
217
  private getOrCreatePolicyEngine;
215
218
  /** Registration-time checks shared by addApi/addSessionApi. */
216
219
  private assertApiRegistration;
@@ -219,32 +222,32 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
219
222
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
220
223
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
221
224
  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: {
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: {
223
226
  input: TInput;
224
227
  output: TOutput;
225
228
  } & {
226
229
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
227
230
  rateLimit?: TRateOpt;
228
- /** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
231
+ /** 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. */
229
232
  guards?: TGuardsOpt;
230
233
  /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
231
234
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
232
235
  ttlSeconds?: number;
233
236
  }) : 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: {
237
+ }, 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: {
236
239
  input: TInput;
237
240
  output: TOutput;
238
241
  } & {
239
242
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
240
243
  rateLimit?: TRateOpt;
241
- /** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
244
+ /** 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. */
242
245
  guards?: TGuardsOpt;
243
246
  /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
244
247
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
245
248
  ttlSeconds?: number;
246
249
  }) : 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>;
250
+ }, 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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
248
251
  /**
249
252
  * Fetch the session or short-circuit the request: API calls get the
250
253
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -289,3 +292,26 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
289
292
  private resolveRequest;
290
293
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
291
294
  }
295
+ /**
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:
299
+ *
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
+ * }>;
306
+ * ```
307
+ *
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.
312
+ */
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>;