lambder 3.8.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 (62) hide show
  1. package/Readme.md +79 -14
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
  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 -20
  8. package/dist/{Lambder.js → core/Lambder.js} +28 -13
  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/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -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/LambderApiPolicies.d.ts +0 -212
  43. package/dist/LambderApiPolicies.js +0 -228
  44. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  45. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  46. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  47. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  48. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  49. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  50. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  51. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  52. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  53. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  54. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  55. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  56. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  57. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  58. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  59. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  60. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  61. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  62. /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.8 gives guards and custom rate-limit keys two typed modes: apiInput (checks a slice of the API's own payload; declarable only where the schema carries those fields) and guardInput (a separate client-sent guardInputs channel the contract makes mandatory at the call site).
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,6 +507,10 @@ 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,
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",
481
514
  // apiInput key: derives from the API's OWN payload. Validated
482
515
  // before it runs, typed in the handler, and the policy is only
483
516
  // referable from APIs whose input schema carries `email`.
@@ -496,12 +529,16 @@ 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, two modes. apiInput checks a slice of the API's own
532
+ // 3. Named guards. Input modes: apiInput checks a slice of the API's own
500
533
  // payload (the schema keeps the field; the guard is declarable only
501
- // where the payload type passes both). guardInput is the guard's OWN
534
+ // where the payload type passes both); guardInput is the guard's OWN
502
535
  // value, sent separately by the caller via options.guardInputs and
503
536
  // made mandatory by the contract, so forgetting it is a compile error
504
- // at the call site. Both are validated pre-run and typed in the handler.
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].
505
542
  .defineApiGuards({
506
543
  captcha: lambderGuard({
507
544
  guardInput: z.object({ captchaToken: z.string() }),
@@ -509,6 +546,17 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
509
546
  if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
510
547
  },
511
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
+ }),
512
560
  });
513
561
 
514
562
  lambder.addApi("public.resetPassword", {
@@ -519,20 +567,37 @@ lambder.addApi("public.resetPassword", {
519
567
  input: z.object({ email: z.string().email() }),
520
568
  output: z.object({ ok: z.boolean() }),
521
569
  rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
522
- guards: "captcha",
570
+ guards: "captcha", // one name, a list of names, or a { name: param } map
523
571
  }, handler);
524
572
 
525
573
  lambder.addSessionApi("secure.order.create", {
526
574
  input: OrderSchema,
527
575
  output: OrderResultSchema,
528
576
  rateLimit: "writePerUser",
577
+ guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
529
578
  idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
530
- }, 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(...);
531
596
  ```
532
597
 
533
- 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.
534
599
 
535
- **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.
536
601
 
537
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).
538
603
 
@@ -583,12 +648,12 @@ cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
583
648
 
584
649
  ## Frontend Usage with LambderCaller
585
650
 
586
- 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.
587
652
 
588
653
  ### Basic Setup with Type Safety
589
654
 
590
655
  ```typescript
591
- import { LambderCaller } from "lambder";
656
+ import { LambderCaller } from "lambder/client";
592
657
  import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
593
658
 
594
659
  const lambderCaller = new LambderCaller<ApiContractType>({
@@ -637,9 +702,9 @@ Every configured handler still fires on the matching failure, so global UX (toas
637
702
  Also available:
638
703
 
639
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.
640
- - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
705
+ - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
641
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.
642
- - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success.
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.
643
708
 
644
709
  ### Benefits
645
710
 
@@ -657,7 +722,7 @@ Also available:
657
722
  LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
658
723
 
659
724
  ```typescript
660
- import { LambderMSW } from 'lambder';
725
+ import { LambderMSW } from 'lambder/testing';
661
726
  import { setupServer } from 'msw/node';
662
727
  import type { ApiContractType } from './backend/handler';
663
728
 
@@ -1,5 +1,5 @@
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
4
  type IsAny<T> = 0 extends (1 & T) ? true : false;
5
5
  type GuardInputsOf<TEntry> = TEntry extends {
@@ -36,6 +36,13 @@ type FetchEndEventHandler = (params: {
36
36
  type ErrorHandler = (err: Error) => void | Promise<void>;
37
37
  type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
38
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
+ };
39
46
  export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
40
47
  /**
41
48
  * Discriminated result of an API call: `ok: true` carries the payload, every
@@ -75,10 +82,12 @@ export type LambderCallOptions = {
75
82
  guardInputs?: Record<string, unknown>;
76
83
  /**
77
84
  * Replay-protection key for APIs declared idempotent on the server.
78
- * Generate once per logical operation (e.g. crypto.randomUUID() when the
79
- * form opens) and send the same key on retries: duplicates of an
80
- * in-flight request refuse, and repeats of a completed one replay its
81
- * 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.
82
91
  */
83
92
  idempotencyKey?: string;
84
93
  versionExpiredHandler?: VoidFunction;
@@ -129,6 +138,21 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
129
138
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
130
139
  });
131
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;
132
156
  /**
133
157
  * Generate an idempotency key for one logical operation. Create it when
134
158
  * the operation begins (a form opens, a draft starts), send the same key
@@ -146,12 +170,6 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
146
170
  * instead of collapsing every failure to null. Never throws.
147
171
  */
148
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>>;
149
- /**
150
- * Legacy shape: the parsed envelope on success (and on structured
151
- * errorMessage refusals, which carry an envelope), null on every other
152
- * failure. Prefer apiOutcome() when the call site needs to know why.
153
- */
154
- apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiResponse<TOutput> | null | undefined>;
155
173
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
156
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>;
157
175
  }
@@ -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
@@ -285,18 +306,6 @@ export default class LambderCaller {
285
306
  return await this.dispatch(apiName, payload, rest[0]);
286
307
  }
287
308
  ;
288
- /**
289
- * Legacy shape: the parsed envelope on success (and on structured
290
- * errorMessage refusals, which carry an envelope), null on every other
291
- * failure. Prefer apiOutcome() when the call site needs to know why.
292
- */
293
- async apiRaw(apiName, payload, ...rest) {
294
- const outcome = await this.dispatch(apiName, payload, rest[0]);
295
- if (outcome.ok)
296
- return outcome.response;
297
- return outcome.reason === 'errorMessage' ? outcome.response : null;
298
- }
299
- ;
300
309
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
301
310
  async api(apiName, payload, ...rest) {
302
311
  const outcome = await this.dispatch(apiName, payload, rest[0]);
@@ -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 LambderGuardMetaMap, type LambderAllowedGuardNames, type LambderAllowedPolicyNames, type LambderGuardInputsOf } 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,17 +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 built with lambderGuard() in one of two modes:
205
- * apiInput (checks a slice of the API's own payload; declarable only on
206
- * APIs whose input schema carries those fields, so the payload type
207
- * passes both the API input and the guard's apiInput) or guardInput (the
208
- * client sends the guard's value separately via options.guardInputs, and
209
- * the contract forces it at the call site). Guards run before input
210
- * validation, in the order the API declares them; a handler refuses by
211
- * throwing (typically refuse()). Callable multiple times so domain
212
- * modules can contribute their own; names must not collide.
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.
213
215
  */
214
- defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
216
+ defineApiGuards<TGuards extends Record<string, LambderApiGuard<any, any, any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
215
217
  private getOrCreatePolicyEngine;
216
218
  /** Registration-time checks shared by addApi/addSessionApi. */
217
219
  private assertApiRegistration;
@@ -220,32 +222,32 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
220
222
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
221
223
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
222
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>;
223
- addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
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: {
224
226
  input: TInput;
225
227
  output: TOutput;
226
228
  } & {
227
229
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
228
230
  rateLimit?: TRateOpt;
229
- /** 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. */
230
232
  guards?: TGuardsOpt;
231
233
  /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
232
234
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
233
235
  ttlSeconds?: number;
234
236
  }) : never;
235
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
236
- addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
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: {
237
239
  input: TInput;
238
240
  output: TOutput;
239
241
  } & {
240
242
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
241
243
  rateLimit?: TRateOpt;
242
- /** 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. */
243
245
  guards?: TGuardsOpt;
244
246
  /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
245
247
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
246
248
  ttlSeconds?: number;
247
249
  }) : never;
248
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
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>;
249
251
  /**
250
252
  * Fetch the session or short-circuit the request: API calls get the
251
253
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -290,3 +292,26 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
290
292
  private resolveRequest;
291
293
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
292
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>;
@@ -3,11 +3,11 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
3
  import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
4
4
  import { compileRouteMatcher } from "./LambderRouting.js";
5
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
- import LambderSessionManager from "./LambderSessionManager.js";
7
- import LambderSessionController from "./LambderSessionController.js";
6
+ import LambderSessionManager from "../session/LambderSessionManager.js";
7
+ import LambderSessionController from "../session/LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
- import { isLambderApiError } from "./LambderApiError.js";
10
- import { LambderApiPolicyEngine, } from "./LambderApiPolicies.js";
9
+ import { isLambderApiError } from "../shared/LambderApiError.js";
10
+ import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
12
12
  /**
13
13
  * Main Lambder class for building type-safe serverless APIs
@@ -193,15 +193,15 @@ export default class Lambder {
193
193
  }
194
194
  /**
195
195
  * Define named guards that APIs reference (typed) via the `guards`
196
- * option. Each guard is built with lambderGuard() in one of two modes:
197
- * apiInput (checks a slice of the API's own payload; declarable only on
198
- * APIs whose input schema carries those fields, so the payload type
199
- * passes both the API input and the guard's apiInput) or guardInput (the
200
- * client sends the guard's value separately via options.guardInputs, and
201
- * the contract forces it at the call site). Guards run before input
202
- * validation, in the order the API declares them; a handler refuses by
203
- * throwing (typically refuse()). Callable multiple times so domain
204
- * modules can contribute their own; names must not collide.
196
+ * option. Each guard is built with lambderGuard(): its input mode
197
+ * (apiInput slice of the API's own payload, a separate client-sent
198
+ * guardInput, or none), an optional `session: true` requirement, an
199
+ * optional parameter APIs pass in their declaration (`guards: { name:
200
+ * param }`), and an optional return value that lands typed on the
201
+ * handler's ctx.guardData[name]. Guards run before input validation, in
202
+ * the order the API declares them; a handler refuses by throwing
203
+ * (typically refuse()). Callable multiple times so domain modules can
204
+ * contribute their own; names must not collide.
205
205
  */
206
206
  defineApiGuards(guards) {
207
207
  this.getOrCreatePolicyEngine().addGuards(guards);
@@ -257,6 +257,14 @@ export default class Lambder {
257
257
  this.actionList.push({
258
258
  match: (ctx) => ctx.apiName === name ? {} : false,
259
259
  actionFn: async (ctx, resolver) => {
260
+ // Replay fast path first: a completed idempotent request must
261
+ // answer its stored response without burning rate-limit quota
262
+ // or re-running guards (no handler executes either way).
263
+ if (this.apiPolicyEngine && schema.idempotency) {
264
+ const replay = await this.apiPolicyEngine.findReplay(name, ctx);
265
+ if (replay)
266
+ return replay;
267
+ }
260
268
  if (this.apiPolicyEngine)
261
269
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
262
270
  const inputResult = schema.input.safeParse(ctx.apiPayload);
@@ -282,6 +290,13 @@ export default class Lambder {
282
290
  match: (ctx) => ctx.apiName === name ? {} : false,
283
291
  actionFn: async (ctx, resolver) => {
284
292
  await this.requireSession(ctx, resolver);
293
+ // Replay fast path (after the session fetch: the replay scope
294
+ // is keyed per session): see addApi.
295
+ if (this.apiPolicyEngine && schema.idempotency) {
296
+ const replay = await this.apiPolicyEngine.findReplay(name, ctx);
297
+ if (replay)
298
+ return replay;
299
+ }
285
300
  if (this.apiPolicyEngine)
286
301
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
287
302
  const inputResult = schema.input.safeParse(ctx.apiPayload);
@@ -1,10 +1,10 @@
1
1
  import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyEventHeaders, Context } from "aws-lambda";
2
- import type { LambderSessionContext } from "./LambderSessionManager.js";
2
+ import type { LambderSessionContext } from "../session/LambderSessionManager.js";
3
3
  import type { LambderHttpEventFormat } from "./LambderResponse.js";
4
4
  export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
5
5
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
6
6
  export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxyEventV2;
7
- export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>> = {
7
+ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = {
8
8
  host: string;
9
9
  path: string;
10
10
  pathParams: TPathParams;
@@ -15,6 +15,12 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
15
15
  session: null;
16
16
  apiName: string | null;
17
17
  apiPayload: TApiPayload;
18
+ /**
19
+ * Outputs of this API's guards, keyed by guard name. Only guards the API
20
+ * declares AND that return a value appear (typed via the declarative
21
+ * guards option); void guards never do.
22
+ */
23
+ guardData: TGuardData;
18
24
  headers: APIGatewayProxyEventHeaders;
19
25
  /** Decoded request body, exactly as received (e.g. for webhook signature verification). */
20
26
  rawBody: string;
@@ -39,7 +45,7 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
39
45
  logToApiResponseAccumulator: any[];
40
46
  };
41
47
  };
42
- export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>> = Omit<LambderRenderContext<TApiPayload, TPathParams>, 'session'> & {
48
+ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData>, 'session'> & {
43
49
  session: LambderSessionContext<SessionData>;
44
50
  };
45
51
  export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
@@ -77,6 +77,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
77
77
  get, post, cookie, event,
78
78
  session: null,
79
79
  apiName, apiPayload,
80
+ guardData: {},
80
81
  headers, rawBody, ip, header,
81
82
  lambdaContext,
82
83
  _otherInternal: {
@@ -1,5 +1,5 @@
1
1
  import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "./node-polyfills.js";
2
+ import { getFS, getPath } from "../shared/node-polyfills.js";
3
3
  import { LambderResponse } from "./LambderResponse.js";
4
4
  // Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
5
5
  // 8+ hash chars containing at least one digit, before the extension.
@@ -1,4 +1,4 @@
1
- import { getZlib, getCrypto } from "./node-polyfills.js";
1
+ import { getZlib, getCrypto } from "../shared/node-polyfills.js";
2
2
  export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
3
3
  /**
4
4
  * Intermediate response object returned by all response builder methods and by