lambder 8.1.2 → 9.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 (86) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +23 -31
  3. package/dist/api/LambderApiCallContext.d.ts +31 -1
  4. package/dist/api/LambderApiCallContext.js +8 -0
  5. package/dist/api/LambderApiDefinition.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +3 -4
  8. package/dist/api/LambderApiGuards.d.ts +2 -17
  9. package/dist/api/LambderApiIdempotency.js +5 -7
  10. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  11. package/dist/build/generatedTables.d.ts +72 -0
  12. package/dist/build/generatedTables.js +99 -0
  13. package/dist/build/writeApiGuardParams.d.ts +60 -0
  14. package/dist/build/writeApiGuardParams.js +85 -0
  15. package/dist/build/writeApiOptions.d.ts +68 -0
  16. package/dist/build/writeApiOptions.js +102 -0
  17. package/dist/build.d.ts +10 -4
  18. package/dist/build.js +7 -4
  19. package/dist/client/LambderCaller.d.ts +0 -4
  20. package/dist/client/LambderCaller.js +1 -9
  21. package/dist/client/LambderUploadRunner.d.ts +7 -7
  22. package/dist/client/LambderUploadRunner.js +12 -21
  23. package/dist/client.d.ts +7 -0
  24. package/dist/client.js +11 -0
  25. package/dist/core/Lambder.d.ts +71 -12
  26. package/dist/core/Lambder.js +116 -38
  27. package/dist/core/LambderContext.d.ts +9 -6
  28. package/dist/core/LambderContext.js +2 -1
  29. package/dist/core/LambderResolver.d.ts +6 -12
  30. package/dist/core/LambderResolver.js +2 -14
  31. package/dist/core/LambderResponseBuilder.d.ts +15 -70
  32. package/dist/core/LambderResponseBuilder.js +15 -99
  33. package/dist/index.d.ts +16 -3
  34. package/dist/index.js +14 -1
  35. package/dist/invoke/LambderInvokeCaller.js +3 -4
  36. package/dist/mock/LambderMockApp.d.ts +34 -17
  37. package/dist/mock/LambderMockApp.js +69 -24
  38. package/dist/mock/LambderMockCreateOptions.d.ts +70 -7
  39. package/dist/mock/LambderMockTypes.d.ts +31 -21
  40. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  41. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  42. package/dist/mock.d.ts +3 -0
  43. package/dist/mock.js +3 -0
  44. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  45. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  46. package/dist/session/LambderSessionCrypto.js +6 -16
  47. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  48. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  49. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  50. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  51. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  52. package/dist/shared/util/LambderBase64.d.ts +14 -0
  53. package/dist/shared/util/LambderBase64.js +17 -0
  54. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  55. package/dist/shared/util/LambderSignedClaims.js +109 -0
  56. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  57. package/dist/shared/util/LambderTextDigest.js +30 -5
  58. package/dist/shared/util/LambderTypeUtilities.d.ts +18 -0
  59. package/dist/shared/util/assertPlainData.d.ts +9 -0
  60. package/dist/shared/util/assertPlainData.js +41 -0
  61. package/dist/shared/wire/LambderAnswerHeaders.d.ts +3 -2
  62. package/dist/shared/wire/LambderAnswerHeaders.js +3 -2
  63. package/dist/shared/wire/LambderApiContract.d.ts +9 -14
  64. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  65. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  66. package/dist/shared/wire/LambderApiRefusal.d.ts +3 -4
  67. package/dist/shared/wire/LambderApiRefusal.js +3 -4
  68. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  69. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  70. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  71. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  72. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  73. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  74. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  75. package/dist/testing/LambderConformanceRunner.js +21 -0
  76. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  77. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  78. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  79. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  80. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  81. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  82. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  83. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  84. package/dist/testing.d.ts +14 -0
  85. package/dist/testing.js +12 -0
  86. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,214 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [9.0.1] - 2026-09-27
13
+
14
+ A major that gives an API handler one shape. It takes its context and returns
15
+ its output; it says no with `refuse()`; it writes headers, cookies and log
16
+ entries through the context. The response builder stays with routes, hooks,
17
+ fallbacks and error handlers, which build HTTP responses. An API is typed data
18
+ in and typed data out, and a handler that has no response builder cannot
19
+ answer any other way, so the two ways a handler used to refuse (throwing, or
20
+ answering null beside a reason) are one, and the untyped side channels are
21
+ gone. A server handler and its mock twin now read alike.
22
+
23
+ ### Changed (breaking)
24
+
25
+ - **An API handler returns its output.** `addApi` and `addSessionApi` take
26
+ `async (ctx) => output` instead of `async (ctx, res) => res.api(output)`.
27
+ The return is checked against the output schema's input form and parsed
28
+ through the schema before it is sent, as `res.api()`'s payload was:
29
+ undeclared fields are stripped, defaults filled, transforms run once, and
30
+ an output the schema rejects is answered as a crash
31
+ (`LambderApiOutputValidationError`). A literal in a returned object keeps
32
+ its type (`{ status: "open" }` is checked as `"open"`, not `string`), which
33
+ is why the handler's return is its own `const` type parameter; arrays in a
34
+ returned literal are read as readonly, which is all an answer needs.
35
+ - To move: drop the second parameter and return what was passed to
36
+ `res.api()`. An answer that was `res.api(null, { errorMessage })`,
37
+ `{ notAuthorized }` or `{ sessionExpired }` becomes `refuse(content, {
38
+ notAuthorized, sessionExpired, code, type })`. A handler that answered
39
+ null where the output does not allow null no longer compiles; it refuses
40
+ instead, or the output becomes nullable.
41
+ - **A refusal is never stored for replay.** Only an answer (a returned
42
+ output) is stored under an idempotency key; a refusal is thrown, the claim
43
+ is released, and a retry runs the handler again, which decides afresh. A
44
+ corrected request after a refusal goes through under the same key instead
45
+ of being refused as a reused key.
46
+ - **Response headers and cookies are written through the context.**
47
+ `ctx.setResponseHeader(key, value)`, `ctx.addResponseHeader(key, value)`,
48
+ `ctx.setCookie(name, value, options?)` and `ctx.clearCookie(name,
49
+ options?)` (the type `LambderResponseTools`) are on every context: routes,
50
+ API handlers, hooks, and mock handlers alike. `res.setHeader`,
51
+ `res.addHeader`, `res.setCookie` and `res.clearCookie` are removed. The
52
+ writers say "Response" because `ctx.header(name)` reads a request header.
53
+ - **The log channel is `ctx.logList`.** `res.logToApiResponse(entry)` is
54
+ removed; push the entry onto `ctx.logList`, which the mock's context has
55
+ always had.
56
+ - **Answer compression is declared per API.** `addApi` and `addSessionApi`
57
+ take `compress?: boolean | "auto"` beside the other options: "auto" (the
58
+ default) compresses for an accepting caller when the body is large enough,
59
+ false never (an answer carrying base64 bytes), true always. It covers
60
+ refusals and replayed answers too, and is a transport setting of the
61
+ server, not part of the contract. `res.apiBinary()` and the per-answer
62
+ response options of an API handler (status code, compression, cache
63
+ headers) are removed; a header goes through `ctx.setResponseHeader`.
64
+ - **`res.api()` writes an envelope by hand, for code outside an API
65
+ handler.** A hook, the input validation handler or a global error handler
66
+ answering an API call still uses it; the payload goes out as given. The
67
+ typed overloads and `LambderResolver`'s output type parameter are removed,
68
+ with the types `LambderResolverApiMethod` and `LambderApiNullAnswerConfig`.
69
+ A binary or file answer is a route's job.
70
+
71
+ ### Removed
72
+
73
+ - **The `message` envelope channel.** The untyped `message` field of the
74
+ envelope and of `LambderApiResponseConfig`, `LambderCaller`'s
75
+ `messageHandler` option and per-call override, and the mock's
76
+ `ctx.envelope`. What a success says belongs in the output schema, where it
77
+ is typed and parsed.
78
+
79
+ ### Added
80
+
81
+ - `LambderResponseTools`, the type of the four response writers every
82
+ context carries, exported from `lambder`.
83
+
84
+ ## [8.3.1] - 2026-09-27
85
+
86
+ Six additions, each a thing an app otherwise writes for itself once per kind
87
+ of token, secret, retry loop, copied declaration or store: signed claims
88
+ tokens, one-shot secrets over a store that settles their races, the declared
89
+ API options as generated files of plain data, a backoff timer, the digest and
90
+ random secret behind stored secrets, and the store conformance suites for a
91
+ store an app writes over its own database. Nothing changes on the wire, and
92
+ every existing option keeps its meaning.
93
+
94
+ ### Added
95
+
96
+ - **`writeApiOptions` in `lambder/build`: the declared options as a generated
97
+ file.** The contract carries every API's `guards`, `rateLimit` and
98
+ `idempotency` options as types; code that decides something at runtime with
99
+ them (a mock restating the server's policies, a test walking the public
100
+ surface, a screen asking which permission an endpoint needs) copied them by
101
+ hand and held the copies honest with tests that read the source.
102
+ `writeApiOptions({ module, exportName, file, check })` writes them once, as
103
+ three `as const` tables of plain data (`apiOptions`, `rateLimitPolicies`,
104
+ `guardDeclarations`) from the new `lambder.apiOptionEntries()`, sorted by
105
+ name, and `check: true` fails a stale file naming what moved per table.
106
+ Nothing in the file is code: a guard parameter that is not plain data fails
107
+ the write by API name, a policy keyed by a handler is written as `per:
108
+ "custom"` and no more, and a guard's schema as its input mode alone.
109
+ - Readers in `lambder/client`, typed to the tables' literals:
110
+ `LambderApisWithGuard`, `LambderApisGuardedBy`, `LambderApisWithMode`,
111
+ `LambderGuardParamOf` and `apiGuardParam(apiOptions, name, guard)`.
112
+ - `writeApiGuardParams({ module, exportName, guard, file, check })` writes
113
+ one guard's parameters beside it, as an `as const` table (`guardParams`)
114
+ of the APIs that declare the guard and what each gives it, with nothing
115
+ else about any API and no import: the least a browser gating a screen on
116
+ that guard needs, where importing `apiOptions` as a value would ship
117
+ every endpoint name and every guard's parameter, reasons included.
118
+ - `lambderMockPoliciesFrom(rateLimitPolicies, { keys })` in `lambder/mock`
119
+ rebuilds the policy configs `create()` takes from the table, requiring a
120
+ key handler for exactly the custom-keyed policies; `create()` takes a
121
+ `guardDeclarations` option that holds each mock guard to the server
122
+ guard's input mode and session requirement (`LambderMockGuardShapeOf`);
123
+ and an `apiOptions` option, the table itself, from which every entry's
124
+ guards, rate limit and idempotency are read. Given it, an entry is its
125
+ handler alone, a restated option is a compile error and a throw, the
126
+ table must cover the contract under each endpoint's mode
127
+ (`LambderMockApiOptionsCover`), and a `restNotMocked` answer runs under
128
+ the mode the table gives the name, so an unmocked session endpoint reads
129
+ the session first.
130
+ - The entry types `LambderApiOptionEntries`, `LambderApiOptionEntry`,
131
+ `LambderRateLimitPolicyEntry` and `LambderGuardDeclarationEntry` live in
132
+ `shared/wire`, with `LambderGuardRunAt`, `LambderRateLimitBudget` and
133
+ `LambderRateLimitChargeAt`, which moved down there unchanged.
134
+
135
+ See [the options as a generated file](./docs/apis.md#the-options-as-a-generated-file).
136
+
137
+ - **`LambderSignedClaims`: signed tokens that are their own record.** One
138
+ instance per kind of token, built once with the secret, a version and the
139
+ zod schema of its claims; `sign(claims)` writes `<version>.<base64url
140
+ claims>.<base64url HMAC-SHA256>`, `verify(token, { now? })` answers the
141
+ claims or null for a forged, foreign, malformed, refused or expired token
142
+ alike. An optional `exp` claim in epoch seconds is judged on every verify,
143
+ against an injectable clock. WebCrypto only, exported from `lambder` and
144
+ `lambder/client` for the edge runtimes and shared backend packages that
145
+ verify where the secret is at hand. Beside it, `keyedDigest(secret,
146
+ value)`, the HMAC-SHA256 as base64url a stored secret rests as;
147
+ `randomSecret(bytes?)`; and `constantTimeEquals`.
148
+
149
+ - **`LambderOneShotSecrets`: codes and tokens handed out once and taken back
150
+ once.** The code emailed to an address, the link in an activation mail, the
151
+ code texted before a document opens, the code read out to pair a device:
152
+ one life (minted, sent, stored as a digest, tried against, spent), written
153
+ once, over a `LambderOneShotSecretStore` that settles its races in six
154
+ methods. An app declares its kinds (a `code` of an alphabet and length with
155
+ a ceiling on tries, or a `token` redeemed by value: random bytes, or an
156
+ alphabet's characters for one somebody types) and names, per secret,
157
+ the scope it proves; `issue(kind, scope, { cooldownSeconds?, meta? })`
158
+ answers the plaintext exactly once, `redeem(kind, scope, candidate)` and
159
+ `redeemToken(kind, candidate)` answer `accepted`, `wrong` (with the tries
160
+ left), `expired`, `exhausted` or `none`, and `retire(scope)` ends what the
161
+ scope holds. One record is live per scope; a cooldown is a condition on
162
+ the issuing write; a token's digest is claimed by one scope at a time in
163
+ that same write, and a secret drawn onto a digest another scope holds is
164
+ drawn again (up to five times), so two scopes that drew the same short
165
+ code never redeem each other's; a try is counted in the write that reads
166
+ the digest; a redemption is a conditional consume.
167
+ `LambderDdbOneShotSecretStore` keeps a code as one item under `OTS#` in
168
+ the policy table and a token as two, written in one transaction, and sends
169
+ a write DynamoDB refused for a concurrent transaction on its item again, up
170
+ to three times; `LambderMemoryOneShotSecretStore` keeps the same in a map, and
171
+ `lambderOneShotSecretStoreConformance` holds both, and an app's own store,
172
+ to one set of rules. The class sits in a new `src/secrets/` layer beside
173
+ `session/`.
174
+
175
+ - **`LambderBackoffTimer`: waiting longer after each failure, once.** One
176
+ pending wait at a time: `retry(run)` and `wait(signal?)` climb a jittered
177
+ ladder (`baseMs` the shortest wait, `maxMs` the longest, `factor`,
178
+ `jitter`), `after(ms, run)` waits off
179
+ it, `reset()` and `cancel()`; `wait` rejects with the signal's reason on
180
+ abort and with an Error when dropped, so an await on it always settles.
181
+ Exported from `lambder` and `lambder/client`.
182
+
183
+ See [Secrets and retries](./docs/secrets.md).
184
+
185
+ - **Store conformance suites in `lambder/testing`.** The rules each store
186
+ interface promises its engine were asserted in Lambder's own test suite,
187
+ where only Lambder's stores could meet them; a store an app writes over its
188
+ own database had nothing to hold it to the same rules. They are now
189
+ exported, one suite per interface: `lambderSessionStoreConformance`,
190
+ `lambderIdempotencyStoreConformance`, `lambderRateLimiterConformance` and
191
+ `lambderOneShotSecretStoreConformance`. Each takes the runner's own `it`
192
+ and `expect` (any jest-style `expect`; Lambder imports no runner) and a
193
+ `create` that builds a store for one case, handed the case's clock as
194
+ `now`. The one-shot suite also takes what a store over existing rows needs
195
+ in place of its defaults: two `scopes` it can hold, the kind it keeps for
196
+ each shape it holds (`kinds: { code?, token? }`, which decides whether the
197
+ cases about tries or the ones about digests run), the `meta`, and the
198
+ `lifetimeSeconds` it derives an expiry from. Among its rules: two issues
199
+ racing for one scope leave exactly one live record, two scopes racing for
200
+ one token digest leave it with exactly one, and `attempt` and `consume`
201
+ name a record by its scope and id together, so a record of another scope
202
+ is never the one named. Lambder's memory and DynamoDB stores run through
203
+ the same suites.
204
+
205
+ See [A store of your own](./docs/testing.md#a-store-of-your-own).
206
+
207
+ ### Changed
208
+
209
+ - **`LambderUploadRunner` waits on a `LambderBackoffTimer` between tries at
210
+ storage.** The ladder is the timer's: each wait is `baseDelayMs` plus a
211
+ random share of a ceiling that starts at `baseDelayMs` and doubles per
212
+ failed attempt, the whole never past `maxDelayMs`. The first wait is
213
+ unchanged (between the base and twice it); a later one may be shorter than
214
+ before, since the ceiling now counts from the base rather than from twice
215
+ it.
216
+ - **The session crypto shares its HMAC and its constant-time comparison** with
217
+ the new module through `shared/util/LambderTextDigest.ts` rather than
218
+ keeping copies of its own. Behaviour is unchanged.
219
+
12
220
  ## [8.1.2] - 2026-09-26
13
221
 
14
222
  ### Fixed
package/README.md CHANGED
@@ -17,15 +17,17 @@ const lambder = initLambder<SessionData>().create({
17
17
  }).addApi("getCompany", {
18
18
  input: z.object({ slug: z.string() }),
19
19
  output: z.object({ id: z.string(), name: z.string() }),
20
- }, async ({ apiPayload }, res) => res.api(await loadCompany(apiPayload.slug)));
20
+ }, async ({ apiPayload }) => await loadCompany(apiPayload.slug));
21
21
 
22
22
  export type ApiContractType = typeof lambder.ApiContract;
23
23
  export const handler = lambder.getHandler();
24
24
  ```
25
25
 
26
- Registration chains onto the creation call: every `addApi` returns an instance
27
- carrying the contract so far, so the whole backend is one declaration and
28
- `lambder.ApiContract` is the accumulated type.
26
+ A handler returns its output, which is parsed through the output schema before
27
+ it is sent, and says no by throwing `refuse()`. Registration chains onto the
28
+ creation call: every `addApi` returns an instance carrying the contract so
29
+ far, so the whole backend is one declaration and `lambder.ApiContract` is the
30
+ accumulated type.
29
31
 
30
32
  The frontend imports that contract type and gets autocomplete, typed payloads
31
33
  and typed results with no hand-written client:
@@ -113,10 +115,10 @@ The package ships five entry points; pick by where the code runs:
113
115
  | Entry | Runs in | Carries |
114
116
  | --- | --- | --- |
115
117
  | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus the API core's building blocks and everything from `lambder/client` except the two request-compression helpers, `compressPayloadGzip` and `isRequestCompressionAvailable`, which stay on the client entry where a payload is compressed |
116
- | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
118
+ | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `LambderUploadRunner`, `LambderBackoffTimer`, `LambderSignedClaims`, `html`/`xml` tagged templates, `createLambderI18n` |
117
119
  | `lambder/mock` | Browser and Node, in development and tests | `LambderMockApp`, the mock runtime: your typed contract served from mock handlers over the real API pipeline and memory stores |
118
- | `lambder/testing` | Node, in tests | `lambderTestApp`: your real instance under test in this process, memory stores put under it in place, simulated browsers with typed callers in front of it, and the outcome assertions |
119
- | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiContract`: the contract as plain types a client compiles instead of the server |
120
+ | `lambder/testing` | Node, in tests | `lambderTestApp`: your real instance under test in this process, memory stores put under it in place, simulated browsers with typed callers in front of it, and the outcome assertions; the store conformance suites, to hold a store you write to the rules Lambder's own meet |
121
+ | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server |
120
122
 
121
123
  Frontends and shared isomorphic packages should import from `lambder/client`
122
124
  only; the entry's module graph contains no AWS SDK, Node built-ins, or server
@@ -126,14 +128,15 @@ tree-shaking.
126
128
  Source layout mirrors this: `src/api/` (the isomorphic API core: request,
127
129
  answer, envelope, pipeline, and the declarative policies the pipeline runs),
128
130
  `src/core/` (the Lambda server adapter: routes, files, hooks, finalization),
129
- `src/session/` (the session manager, controller and crypto), `src/stores/`
131
+ `src/session/` (the session manager, controller and crypto), `src/secrets/`
132
+ (one-shot secrets over their store), `src/stores/`
130
133
  (every store and file-source implementation, DynamoDB and in-memory alike,
131
134
  and the helpers the two caches share), `src/client/`,
132
135
  `src/invoke/` (the lambda-to-lambda caller and the in-process handler
133
- transport), `src/mock/` (the mock runtime), `src/testing/` (the test app),
136
+ transport), `src/mock/` (the mock runtime), `src/testing/` (the test app and the store conformance suites),
134
137
  `src/build/` (what a generator script runs at build time), and `src/shared/`
135
138
  (isomorphic modules every entry re-exports, grouped into `wire/` for the
136
- format both sides speak, `contracts/` for the five store and source
139
+ format both sides speak, `contracts/` for the six store and source
137
140
  interfaces, `transport/` for the caller-to-server seam, and `util/` for
138
141
  helpers).
139
142
  Directories are layers and imports only ever point down;
@@ -152,11 +155,11 @@ guide that matches what you are building. The full index lives in
152
155
  | [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
153
156
  | [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, crash reporting, and non-HTTP invocations |
154
157
  | [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
155
- | [Responses](./docs/responses.md) | The render context, resolver methods, cookies, compression, ETag and the size cap |
158
+ | [Responses](./docs/responses.md) | The render context and its response tools (headers, cookies, log entries), the response builder routes and hooks use, compression, ETag and the size cap |
156
159
  | [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
157
160
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
158
161
  | [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
159
- | [Testing](./docs/testing.md) | `lambderTestApp`: the real instance under test with no HTTP and no AWS, visitors, sign-in without a login endpoint, outcome assertions, crashes, time |
162
+ | [Testing](./docs/testing.md) | `lambderTestApp`: the real instance under test with no HTTP and no AWS, visitors, sign-in without a login endpoint, outcome assertions, crashes, time, store conformance suites |
160
163
  | [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
161
164
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
162
165
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
@@ -184,25 +187,14 @@ framework:
184
187
  ## Versioning and changes
185
188
 
186
189
  Released versions and what each one changed are in
187
- [CHANGELOG.md](./CHANGELOG.md). The current major is v8, which came out of a
188
- review of 7.3.1: session writes that cannot undo a logout, API calls that must
189
- be JSON, output schemas applied at runtime, rate limits that count IPv6 callers
190
- by their /64 and custom keys after the guards, idempotency keys bound to the
191
- request they were first sent with, and the same behavior on every gateway and
192
- in the mock. Every break and what to do about it is in the 8.0.2 entry. The
193
- compiler finds most of them. Fourteen it cannot are named there: hand-built
194
- calls without a JSON Content-Type, hand-built answers without `apiVersion`,
195
- handlers whose payload does not match their output schema, code that decoded
196
- `ctx.path` itself, string routes that match case-sensitively, compression
197
- behind a REST API, two more IAM actions (`UpdateItem` on the session table,
198
- `GetItem` on the rate-limit table), custom-key limits charged after the
199
- guards, idempotency keys bound to the request they were first sent with,
200
- `errorMessage` always being an object, `credentials: true` needing named
201
- origins, template slots and `html` interpolations refused or checked in
202
- more attribute positions, `refreshSessionData()` throwing where it answered
203
- null, and `z.ZodType<T>` annotations leaving a field unchecked. Every live
204
- session is signed out once by the
205
- upgrade. An app still on v6 goes through the 7.0.0 entry first.
190
+ [CHANGELOG.md](./CHANGELOG.md). The current major is v9, which gives an API
191
+ handler one shape: it takes its context and returns its output, says no with
192
+ `refuse()`, and writes headers, cookies and log entries through the context,
193
+ while the response builder stays with routes, hooks and error handlers. Every
194
+ break and what to do about it is in the 9.0.1 entry, and the compiler finds
195
+ most of them. An app still on v7 goes through the 8.0.2 entry first, which
196
+ names the breaks of v8 the compiler cannot find, and one on v6 through the
197
+ 7.0.0 entry before that.
206
198
 
207
199
  ## Contributing
208
200
 
@@ -1,5 +1,6 @@
1
1
  import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
2
2
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
3
+ import { type LambderCookieOptions, type LambderClearCookieOptions } from "../shared/wire/LambderCookie.js";
3
4
  /**
4
5
  * The context the API core needs from whoever runs it. The server's render
5
6
  * context and the mock runtime's handler context both extend it; the
@@ -12,7 +13,7 @@ import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
12
13
  * - `responseHeaders` collects headers written during the call, applied
13
14
  * onto the answer by the pipeline.
14
15
  * - `logList` collects entries for the envelope's logList channel
15
- * (`res.logToApiResponse` on the server).
16
+ * (`ctx.logList.push(entry)` from a handler).
16
17
  */
17
18
  export type LambderApiCallContext<TSessionData = any> = {
18
19
  session: LambderSessionRecord<TSessionData> | null;
@@ -22,6 +23,35 @@ export type LambderApiCallContext<TSessionData = any> = {
22
23
  };
23
24
  /** A fresh call context: no session, no guard data, nothing pending. */
24
25
  export declare const createApiCallContext: <TSessionData = any>() => LambderApiCallContext<TSessionData>;
26
+ /**
27
+ * What a handler writes onto its answer beside the body: headers and cookies,
28
+ * collected on `responseHeaders` and applied to whatever answer the request
29
+ * ends with. On every context a handler receives, the server's and the
30
+ * mock's alike, since an API handler returns its output and has no response
31
+ * builder to write them on.
32
+ */
33
+ export type LambderResponseTools = {
34
+ /** Replaces a response header. */
35
+ setResponseHeader(key: string, value: string | string[]): void;
36
+ /** Appends a response header value (repeatable for one key). */
37
+ addResponseHeader(key: string, value: string): void;
38
+ /**
39
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
40
+ * the request host. Defaults: Path=/, SameSite=Lax, Secure, not HttpOnly,
41
+ * browser-session lifetime.
42
+ */
43
+ setCookie(name: string, value: string, options?: LambderCookieOptions): void;
44
+ /**
45
+ * Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and
46
+ * `path` it was set with: a cookie's identity is (name, domain, path), and
47
+ * a deletion under another scope deletes nothing.
48
+ */
49
+ clearCookie(name: string, options?: LambderClearCookieOptions): void;
50
+ };
51
+ /** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
52
+ export declare const responseToolsOf: (ctx: {
53
+ responseHeaders: LambderAnswerHeaders;
54
+ }, host: string) => LambderResponseTools;
25
55
  /**
26
56
  * Binds an adapter's tools onto one call context: `getters` run when read
27
57
  * (`ctx.sessionController` is built over the object it was read from),
@@ -1,4 +1,5 @@
1
1
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
2
+ import { serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
2
3
  /** A fresh call context: no session, no guard data, nothing pending. */
3
4
  export const createApiCallContext = () => ({
4
5
  session: null,
@@ -10,6 +11,13 @@ export const createApiCallContext = () => ({
10
11
  responseHeaders: new LambderAnswerHeaders(),
11
12
  logList: [],
12
13
  });
14
+ /** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
15
+ export const responseToolsOf = (ctx, host) => ({
16
+ setResponseHeader: (key, value) => { ctx.responseHeaders.set(key, value); },
17
+ addResponseHeader: (key, value) => { ctx.responseHeaders.add(key, value); },
18
+ setCookie: (name, value, options) => { ctx.responseHeaders.add("Set-Cookie", serializeCookie(name, value, options, host)); },
19
+ clearCookie: (name, options) => { ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(name, options, host)); },
20
+ });
13
21
  /**
14
22
  * Binds an adapter's tools onto one call context: `getters` run when read
15
23
  * (`ctx.sessionController` is built over the object it was read from),
@@ -8,8 +8,8 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
8
8
  * because the mock has none; when input is present, validation runs and the
9
9
  * handler sees the parsed payload. Output is part of the endpoint's
10
10
  * signature (apiSignatureOf), which is what a client's build is checked
11
- * against; the server's resolver also parses every payload a handler
12
- * answers through it before it is sent.
11
+ * against; the server also parses every output a handler returns through it
12
+ * before it is sent.
13
13
  */
14
14
  export type LambderApiDefinition = {
15
15
  name: string;
@@ -13,7 +13,7 @@ export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
13
13
  * plain success is `{ apiVersion, payload }` and nothing else; an empty
14
14
  * logList is omitted.
15
15
  */
16
- export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
16
+ export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
17
17
  /** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
18
18
  export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
19
19
  statusCode?: number;
@@ -4,8 +4,8 @@ import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
4
4
  * The one place the API envelope is written, and the one mapping from each
5
5
  * kind of protocol outcome onto an answer: a success, a thrown refusal, a
6
6
  * rejected input, an unknown name, a missing session, a stale client, a
7
- * malformed compressed payload, and the last-resort crash. The server's
8
- * res.api(), the mock runtime and the pipeline all build through these
7
+ * malformed compressed payload, and the last-resort crash. The server's API
8
+ * path and res.api(), the mock runtime and the pipeline all build through these
9
9
  * functions, so server and mock cannot drift on a single byte of the wire
10
10
  * format. Pure: no Node built-ins, no response classes.
11
11
  */
@@ -15,7 +15,7 @@ export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
15
15
  * plain success is `{ apiVersion, payload }` and nothing else; an empty
16
16
  * logList is omitted.
17
17
  */
18
- export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, } = {}) => ({
18
+ export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, } = {}) => ({
19
19
  apiVersion: apiVersion ?? null,
20
20
  payload,
21
21
  ...(versionExpired ? { versionExpired } : {}),
@@ -26,7 +26,6 @@ export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionE
26
26
  // flags above are booleans, where false and absent mean the same. An
27
27
  // errorMessage goes out as a message object whatever form it was written
28
28
  // in, so every reader meets one shape.
29
- ...(message !== undefined ? { message } : {}),
30
29
  ...(errorMessage !== undefined ? { errorMessage: refusalMessageOf(errorMessage) } : {}),
31
30
  ...(crash !== undefined ? { crash } : {}),
32
31
  ...(logList?.length ? { logList } : {}),
@@ -37,22 +37,8 @@ type LambderGuardAnswerCheck<TOutput> = [
37
37
  type LambderGuardOf<TOutput, TGuard> = LambderGuardAnswerCheck<TOutput> extends LambderGuardMustNotAnswer ? LambderGuardMustNotAnswer : TGuard;
38
38
  /** One guard handler: the adapter's context, the validated input slice (undefined in the no-input mode), and the per-API parameter. */
39
39
  type LambderGuardHandler<TCtx, TPayload, TParam, TOutput> = (ctx: TCtx, payload: TPayload, param: TParam) => TOutput | Promise<TOutput>;
40
- /**
41
- * When a guard runs, relative to the API's input validation.
42
- *
43
- * - "beforeInputValidation" (default): an unauthorized caller learns nothing
44
- * about the input, and no async refinement in the schema runs for it.
45
- * - "afterInputValidation": for a guard that spends something on the
46
- * request, such as a single-use captcha token, which a request refused for
47
- * a mistyped field would otherwise waste. The API's input schema then runs
48
- * for callers this guard would refuse, so keep lookups (an "email is free"
49
- * refinement) out of it, in the handler.
50
- *
51
- * Guards run in their declared order within each, and the limits keyed by
52
- * caller data are charged after both unless their policy says otherwise
53
- * (LambderRateLimitChargeAt).
54
- */
55
- export type LambderGuardRunAt = "beforeInputValidation" | "afterInputValidation";
40
+ import type { LambderGuardRunAt } from "../shared/wire/LambderApiOptionEntries.js";
41
+ export type { LambderGuardRunAt };
56
42
  /** Where in the call a guard runs: see LambderGuardRunAt. */
57
43
  export type LambderGuardPlacement = {
58
44
  /** Default: "beforeInputValidation". */
@@ -331,4 +317,3 @@ export declare class LambderApiGuardsEngine {
331
317
  */
332
318
  run(request: LambderApiRequest, ctx: LambderApiCallContext, guardsOption: LambderGuardsOptionValue | undefined, trace: LambderApiCallTrace, runAt: LambderGuardRunAt): Promise<void>;
333
319
  }
334
- export {};
@@ -386,13 +386,11 @@ export class LambderApiIdempotencyEngine {
386
386
  }
387
387
  // A crash or a thrown refusal (LambderApiRefusal, refuse())
388
388
  // releases the claim so a retry retries. The rule is deliberate:
389
- // ANSWERS are stored and replayed, returned refusal envelopes
390
- // included; EXCEPTIONS are not, so a thrown refusal re-executes
391
- // on retry and the handler decides afresh. (On the server,
392
- // res.die.api() is an answer: the adapter catches it before it
393
- // reaches here.) The handler's own error is what gets rethrown,
394
- // since a cleanup error in its place would hide why the call
395
- // failed.
389
+ // ANSWERS (the output a handler returned) are stored and
390
+ // replayed; EXCEPTIONS are not, so a thrown refusal re-executes
391
+ // on retry and the handler decides afresh. The handler's own
392
+ // error is what gets rethrown, since a cleanup error in its place
393
+ // would hide why the call failed.
396
394
  try {
397
395
  await store.abandon(scopeKey, ownerToken);
398
396
  }
@@ -78,34 +78,8 @@ export type LambderRateLimitKeyBuilder<TCtx> = {
78
78
  export declare const lambderRateLimitKeyBuilder: <TCtx>() => LambderRateLimitKeyBuilder<TCtx>;
79
79
  /** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
80
80
  export type LambderRateLimitPer<TCtx = any> = "ip" | "session" | LambderRateLimitKeyFn<any, TCtx>;
81
- /**
82
- * What one budget spans:
83
- *
84
- * - "perApi" (default): every API referencing the policy gets its own
85
- * counter, so the windows are a per-API ceiling (three APIs referencing a
86
- * 60/min policy allow one subject 180/min in total). An API may tune the
87
- * windows in its declaration: `rateLimit: { name: { perMin: 20 } }`.
88
- * - "perPolicy": every API referencing the policy shares ONE counter, so the
89
- * windows are one combined budget (e.g. one per-email allowance across
90
- * send, register, and reset). The policy IS the group: to give user APIs
91
- * and report APIs separate shared budgets, declare two policies.
92
- */
93
- export type LambderRateLimitBudget = "perApi" | "perPolicy";
94
- /**
95
- * When a custom-keyed policy is charged, relative to the guards and the input
96
- * schema.
97
- *
98
- * - "afterGuards" (default): after every guard and the input schema passed.
99
- * The key is a value the caller chose (an email in the payload), so a
100
- * caller who never passes a captcha guard cannot spend a victim's budget
101
- * and lock them out of reset, register and send-code.
102
- * - "beforeGuards": before the guards and the input schema, so an attempt
103
- * they refuse is counted too. For a limit on guessing a secret a guard or
104
- * the schema checks (a one-time code checked by a guard, keyed per email):
105
- * charged after them, a wrong guess is refused before it is ever counted.
106
- * Pair it with an IP limit, since anyone may spend this budget.
107
- */
108
- export type LambderRateLimitChargeAt = "beforeGuards" | "afterGuards";
81
+ import type { LambderRateLimitBudget, LambderRateLimitChargeAt } from "../shared/wire/LambderApiOptionEntries.js";
82
+ export type { LambderRateLimitBudget, LambderRateLimitChargeAt };
109
83
  /**
110
84
  * A named rate-limit policy: fixed windows, the key one counter tracks, and
111
85
  * what one budget spans.
@@ -357,4 +331,3 @@ export declare class LambderApiRateLimitsEngine {
357
331
  */
358
332
  private requestKeyOf;
359
333
  }
360
- export {};
@@ -0,0 +1,72 @@
1
+ import type { LambderApiOptionEntries } from "../shared/wire/LambderApiOptionEntries.js";
2
+ import { type LambderModuleLocation } from "./moduleLocation.js";
3
+ /** What the generators read the options from: a Lambder instance, or anything else that reports them the same way. */
4
+ export type LambderApiOptionsSource = {
5
+ apiOptionEntries(): LambderApiOptionEntries;
6
+ };
7
+ /** Which names of one table moved: entries that changed, entries the file did not have, entries the file had and the instance no longer reports. */
8
+ export type LambderNameChanges = {
9
+ changed: string[];
10
+ added: string[];
11
+ removed: string[];
12
+ };
13
+ /** Imports the module, finds the instance under its export, and answers what it reports. `generator` names the caller in the errors. */
14
+ export declare const loadApiOptionEntries: (location: {
15
+ module: LambderModuleLocation;
16
+ exportName?: string;
17
+ }, generator: string) => Promise<LambderApiOptionEntries>;
18
+ /** One table of a generated file, read back as data, or null for a file that does not hold it as written. */
19
+ export declare const readGeneratedTable: (contents: string, exportName: string) => Record<string, unknown> | null;
20
+ /** How one table's entries differ from the ones a file held, by name, compared as canonical JSON so key order is not a change. */
21
+ export declare const nameChangesOf: (current: Record<string, unknown>, previous: Record<string, unknown>) => LambderNameChanges;
22
+ /** The opening comment of a generated file, one `//` line per line of the header. */
23
+ export declare const headerLinesOf: (header: string) => string[];
24
+ /**
25
+ * One table as a statement: its doc comment, the `// prettier-ignore` that
26
+ * keeps a formatter off the JSON a check reads back, and the table itself,
27
+ * `as const` with whatever `satisfies` clause the caller gives.
28
+ */
29
+ export declare const tableStatementLines: (table: {
30
+ name: string;
31
+ doc: string;
32
+ value: unknown;
33
+ satisfies?: string;
34
+ semicolon: string;
35
+ }) => string[];
36
+ /**
37
+ * The end of a generator's run. With `check`, it answers whether the file
38
+ * holds what the instance reports and writes nothing. Otherwise it writes
39
+ * the file unless it already holds these tables, however it is formatted, so
40
+ * a watcher or an incremental build sees no change where there is none (a
41
+ * change of header or semicolons shows the next time a table changes).
42
+ * Either way the lines say what moved.
43
+ */
44
+ export declare const settleGeneratedFile: (run: {
45
+ /** The file as the caller named it, for the lines. */
46
+ name: string;
47
+ /** The absolute path. */
48
+ file: string;
49
+ check: boolean | undefined;
50
+ /** The file's text before this run, or null when there was none. */
51
+ previousText: string | null;
52
+ /** Whether that text held the tables as written; false for a file rewritten by hand. */
53
+ readBack: boolean;
54
+ /** Each table the file holds, under its exported name: how many entries it has now, and which of them moved. */
55
+ tables: readonly {
56
+ name: string;
57
+ count: number;
58
+ changes: LambderNameChanges;
59
+ }[];
60
+ /** What the tables hold, for the lines: "6 APIs, 4 policies, 5 guards". */
61
+ held: string;
62
+ /** What the file holds when it is as written, for the line when it is not: "the three tables". */
63
+ tablesNoun: string;
64
+ /** The line when nothing moved: "no options changed". */
65
+ unmovedLine: string;
66
+ /** The file's contents, rendered only when it is written. */
67
+ render: () => string;
68
+ }) => {
69
+ ok: boolean;
70
+ written: boolean;
71
+ lines: string[];
72
+ };