lambder 9.0.2 → 10.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 (84) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +29 -14
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client/LambderUploadRunner.d.ts +5 -5
  23. package/dist/client/LambderUploadRunner.js +25 -16
  24. package/dist/client.d.ts +6 -5
  25. package/dist/client.js +2 -1
  26. package/dist/core/Lambder.d.ts +121 -23
  27. package/dist/core/Lambder.js +105 -51
  28. package/dist/core/LambderContext.d.ts +1 -1
  29. package/dist/core/LambderContext.js +5 -0
  30. package/dist/core/LambderCreateOptions.d.ts +58 -15
  31. package/dist/core/LambderCreateOptions.js +10 -0
  32. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  33. package/dist/core/LambderPolicyBuilders.js +10 -8
  34. package/dist/core/LambderResolver.d.ts +1 -1
  35. package/dist/core/LambderResolver.js +1 -1
  36. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  37. package/dist/core/LambderResponseBuilder.js +12 -11
  38. package/dist/index.d.ts +13 -9
  39. package/dist/index.js +6 -2
  40. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  41. package/dist/invoke/LambderInvokeCaller.js +13 -12
  42. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  43. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  44. package/dist/mock/LambderMockApp.d.ts +86 -24
  45. package/dist/mock/LambderMockApp.js +108 -16
  46. package/dist/mock/LambderMockCallRecorder.js +6 -3
  47. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  48. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  49. package/dist/mock/LambderMockFailureInjector.js +18 -4
  50. package/dist/mock/LambderMockTypes.d.ts +34 -11
  51. package/dist/mock.d.ts +4 -3
  52. package/dist/mock.js +4 -3
  53. package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
  54. package/dist/shared/contracts/LambderUploadBucket.js +2 -2
  55. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  56. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  57. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  58. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  59. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  60. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  61. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  62. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  63. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  64. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  65. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  66. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  67. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  68. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  69. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  70. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  71. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  72. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  73. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
  74. package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
  75. package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
  76. package/dist/shared/wire/LambderUploadSchemas.js +18 -7
  77. package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
  78. package/dist/stores/LambderMemoryUploadBucket.js +60 -12
  79. package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
  80. package/dist/stores/LambderS3UploadBucket.js +51 -4
  81. package/dist/testing/LambderTestApp.d.ts +1 -1
  82. package/dist/testing.d.ts +1 -1
  83. package/dist/testing.js +1 -1
  84. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,201 @@ 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
+ ## [10.0.1] - 2026-09-28
13
+
14
+ A major that makes both sides of an API answer exact. A success is only ever
15
+ the handler's parsed output, an object or an array, so a caller's success is
16
+ typed as exactly the endpoint's output and is never null or falsy. A refusal
17
+ names a code the app declared once, with the schema of its data and the
18
+ status and flag it leaves with: the contract carries every code an endpoint
19
+ can refuse with, a caller narrows `refusal.data` on the code, and a refusal
20
+ outside its declaration is a crash rather than an answer, which is what keeps
21
+ the caller's type honest. The envelope field that carries it is now called
22
+ what it is, `refusal`.
23
+
24
+ ### Changed (breaking)
25
+
26
+ - **The envelope's `errorMessage` is `refusal`,** on every surface: the wire
27
+ field, an outcome's `refusal` and its reason `'refusal'` (not
28
+ `'errorMessage'`), the callers' `refusalHandler`, `res.apiRefusal({
29
+ refusal })`, a `LambderApiRefusal`'s `refusal` and the option that sets it,
30
+ a rate-limit policy's and an override's `refusal`, the generated policy
31
+ table's `refusal`, and `LambderIdempotentAttemptOutcome.refusal`. The field
32
+ carries a typed refusal with a code and data, and it may be an "info" or a
33
+ "warning", so "error message" named neither its role nor its type. Lambda's
34
+ own `errorMessage` on a function error is Lambda's and keeps its name.
35
+ - **An API's output is an object or an array.** An output schema whose JSON
36
+ form is anything else (`z.void()`, `z.boolean()`, `z.number()`, a nullable
37
+ or an optional object, `z.date()`) is a compile error on the `output`
38
+ option, and a handler that answers one anyway is a crash
39
+ (`LambderApiOutputValidationError`, which gains that kind). `caller.api()`
40
+ is then truthy exactly on success.
41
+ - To move: an API with nothing to answer declares `output: z.object({})`
42
+ and returns `{}`; one that answered a primitive wraps it
43
+ (`{ count }`); one whose output was nullable answers `{ order: null }`.
44
+ - **A success outcome's payload is exactly the output.**
45
+ `LambderApiSuccessOutcome<T>` is `payload: T`, not `T | null | undefined`,
46
+ and `LambderCaller.api()` returns `T | undefined`, `undefined` on every
47
+ failure, a refusal included (it no longer hands back the refusal
48
+ envelope's payload). `LambderInvokeCaller` no longer turns an absent
49
+ payload into `null`.
50
+ - **A reader takes a success only when its payload is an object or an
51
+ array.** A 2xx envelope with no flag and no refusal whose payload is
52
+ null, a primitive or missing reads as reason `server`: no handler wrote it.
53
+ - **`res.api(payload, config, options)` is `res.apiRefusal(config,
54
+ options)`.** An API call answered from outside its handler (a hook, a
55
+ fallback, the input validation handler, the global error handler) is
56
+ always a refusal: the config carries a `refusal` or one of the
57
+ `versionExpired`, `sessionExpired` and `notAuthorized` flags (a compile
58
+ error and a thrown error otherwise), the payload is null, and its message
59
+ carries a framework code or none, never an app code or data.
60
+ `res.die.api` is `res.die.apiRefusal`, and `LambderApiResponseConfig` is
61
+ `LambderApiRefusalConfig`.
62
+ - To move: `res.api(null, config, options)` becomes `res.apiRefusal(config,
63
+ options)`. A hook that answered an API call with a cached success moves
64
+ the cache into the handler, which answers from it.
65
+ - **The mock's `onInvalidInput` answers `{ config, statusCode? }`,** a
66
+ refusal held to the same rule, with no payload. A mock handler that
67
+ answers anything but an object or an array crashes the call, as the
68
+ server does, instead of answering a null payload.
69
+ - **The envelope is two shapes.** `LambderApiEnvelopeBody<T>` is a
70
+ `LambderApiSuccessEnvelope<T>` (the payload) or a
71
+ `LambderApiRefusalEnvelope` (a null payload beside the flags, the
72
+ refusal and a crash). A failure outcome's `response` is the refusal
73
+ envelope. `buildApiEnvelope` is replaced by `successEnvelope`,
74
+ `refusalEnvelope` and `plainRefusalEnvelope`, and
75
+ `LambderApiEnvelopeConfig` by `LambderRefusalEnvelopeFields`.
76
+ `LambderJsonOutputOf` is removed: an output is `LambderJsonOf` of the
77
+ schema's output.
78
+ - **A coded refusal must be declared, and leaves as declared.** A refusal
79
+ whose code is neither a framework code nor one its endpoint declares, data
80
+ on a refusal whose code declares none, no data on one whose code carries
81
+ data, data its code's schema rejects, or a declared code raised with a
82
+ status or `notAuthorized` flag of its own (its declaration owns them) is
83
+ now a crash (`LambderApiRefusalValidationError`) rather than an answer. The
84
+ same check runs on a refusal a hook throws for an API call, and in the
85
+ mock where it has the generated options.
86
+ - To move: declare every code with `initLambder().declareRefusals()`
87
+ (below), name each in the `refusals` option of the APIs and guards that
88
+ raise it, and move a code's status and `notAuthorized` from its raise
89
+ sites into its declaration.
90
+ - **`create()` takes no `refusals`.** The vocabulary is declared on the init,
91
+ `initLambder<SessionData>().declareRefusals(vocabulary)`, before any guard
92
+ is built, so guards and shared helpers can be typed to it; `create()` on
93
+ that init hands it to the instance. `LambderCreateOptions.refusals` stays
94
+ for the class constructed directly.
95
+ - **`LambderRefusalMessage` is generic over an endpoint's declared codes.**
96
+ Its argument is the contract's map of code to `{ data }` or `{}`
97
+ (`LambderContractRefusalsOf`), not a union of code strings, and the
98
+ message is one arm per code, so `data` narrows on `code`. The message
99
+ shape gains `data`. `LambderAppRefusalMessage` is
100
+ `LambderUncheckedRefusalMessage`: the message before any check, what a
101
+ thrown refusal carries and what a reader gets off a body no server
102
+ vouched for; an app's own message is a declared one.
103
+ - **A rate-limit message carries no code.** A policy's and an override's
104
+ `refusal` is a `LambderRateLimitMessage` (type, title, content); every
105
+ rate-limit refusal is `lambder/rate-limited`. A code or data there is a
106
+ compile error on a policy and a creation or registration error on both.
107
+ - **An endpoint's signature covers its declared refusals,** codes, data
108
+ schemas, statuses and flags, so every endpoint's signature changes once
109
+ with this version, and adding a code to an endpoint or a guard, or
110
+ changing how a code leaves, reloads its callers.
111
+ - **The instance has a ninth and a tenth type parameter,** the refusal
112
+ vocabulary and whether every refusal names a code. Code that names
113
+ `Lambder<...>` with every parameter written out adds two.
114
+ - **An entry of the mock whose `apiOptions` entry declares guards needs
115
+ `guardDeclarations` beside the table,** which holds the codes those guards
116
+ add, and one that can refuse with a declared code needs the vocabulary
117
+ declared on the mock init (`initLambderMock().declareRefusals()`, the same
118
+ object the server's init declares); registering one without them throws,
119
+ as does a table naming a code the vocabulary does not hold.
120
+ - **`LambderContractRefusalsFor` is `LambderWireRefusalsOf`** (it pairs with
121
+ `LambderHandlerRefusalsOf`: the same codes in the handler's form and in
122
+ wire form), and **`handWrittenRefusalEnvelope` is `plainRefusalEnvelope`**
123
+ (what it enforces is a plain refusal message: a framework code or none,
124
+ and no data).
125
+
126
+ ### Added
127
+
128
+ - **Declared refusals.** `initLambder().declareRefusals(vocabulary)` is the
129
+ app's vocabulary: each code once, `{}` or `{ data: schema }` (an object or
130
+ an array, as an output), with the `status` every refusal with it leaves
131
+ with (200 by default, never 422 or a 5xx) and whether it sets
132
+ `notAuthorized`. The init it returns is bound to it: its `guard()` types a
133
+ guard's `ctx.refuse` to the guard's `refusals` and refuses a guard naming a
134
+ code outside the vocabulary as it is built, its `refuse` is typed to the
135
+ whole vocabulary for shared helpers and hooks, and its `create()` hands the
136
+ vocabulary to the instance. `addApi` and `addSessionApi` take `refusals`,
137
+ one code or a non-empty list, and a guard takes `refusals` too; a guard's
138
+ codes join every API that declares it. The handler's context carries
139
+ `ctx.refuse`, typed to those codes with `data` required where a code
140
+ declares it and no status or flag of its own; the free `refuse()` and
141
+ `LambderApiRefusal` take `code` and `data` from a shared helper. A declared
142
+ code's data is parsed through its schema before it is sent, and the
143
+ refusal leaves with its declaration's status and flag. The contract entry
144
+ carries `refusals`, every code the endpoint can refuse with;
145
+ `LambderContractRefusalsOf`, `LambderContractRefusalMessage`,
146
+ `LambderContractRefusals` and `LambderContractAnyRefusalMessage` read it;
147
+ an outcome's `refusal` (on the browser caller, the invoke caller, the test
148
+ visitor and the mock) and a per-call `refusalHandler` are typed per
149
+ endpoint, and the constructor's `refusalHandler` across the contract. The
150
+ generated options record each API's `refusals` and each guard declaration's;
151
+ the vocabulary itself is not written, since it is shared code the mock
152
+ declares as the server does.
153
+ - **`declareRefusals(vocabulary, { requireCodes: true })`** makes every
154
+ refusal an API answers with name a declared or framework code: an uncoded
155
+ `refuse("...")` from a handler, a guard or a helper is then a crash rather
156
+ than an answer, and `ctx.refuse` and the init's `refuse` require a code.
157
+ The mock's `declareRefusals` takes the same option.
158
+ - **`assertApiRefusal(outcome, code)`** in `lambder/testing` and
159
+ `lambder/mock`: asserts a refusal's code on any failure arm and narrows its
160
+ `refusal` to that code, data typed. `LambderExpectedFailure.code` is
161
+ typed to the outcome's codes.
162
+ - **The mock declares the vocabulary and refuses as the server does.**
163
+ `initLambderMock().declareRefusals(vocabulary, { requireCodes? })` takes
164
+ the same object and option the server's init does, from shared code. A
165
+ mock handler's `ctx.refuse` and the mock init's `refuse` are then typed to
166
+ the codes the contract gives the endpoint with each code's data in the
167
+ schema's input form, and the mock checks and sends a refusal exactly as the
168
+ server would: the code among the codes the generated tables give the entry,
169
+ the data parsed through the code's schema (defaults filled, transforms run
170
+ once), and the declaration's status and flag. `failNext` and `setFailure`
171
+ take an injected refusal the same way. A mock that declared no vocabulary
172
+ types `ctx.refuse` from the contract's wire form and checks nothing.
173
+ `LambderMockRefusalsOf` is the map behind both.
174
+ - `checkedRefusal`, `LambderApiRefusalValidationError`,
175
+ `LambderRefusalDeclaration`, `LambderRefusalVocabulary`,
176
+ `LambderRefusalStatusCode`,
177
+ `LambderApiAllowedRefusal`, `LambderApiAllowedRefusals`,
178
+ `LambderEndpointRefusals`, `LambderRefusalsOption`,
179
+ `LambderRefusalNamesIn`, `LambderRefusalsOptionValue`,
180
+ `LambderHandlerRefusalsOf`, `LambderWireRefusalsOf`,
181
+ `LambderGuardRefusals`, `LambderGuardRefusalNamesOf`,
182
+ `LambderPlainRefusalMessage`, `LambderUncheckedRefusalMessage`,
183
+ `LambderDeclaredRefuse`, `LambderDeclaredRefuseOptions`,
184
+ `LambderRateLimitMessage`, `LambderRefusalEnvelopeFields`,
185
+ `isLambderRefusalCode`, `isObjectPayload`, `successEnvelope`,
186
+ `refusalEnvelope` and `plainRefusalEnvelope`.
187
+
188
+ ## [9.0.3] - 2026-09-27
189
+
190
+ An upload bucket can now sign a ticket that sends the file with a PUT instead
191
+ of a form POST, for a store without S3's presigned POST (Cloudflare R2 among
192
+ them).
193
+
194
+ ### Added
195
+
196
+ - **`LambderUploadTicket` is `{ method: "POST", ... }` or `{ method: "PUT",
197
+ ... }`.** A POST ticket carries `formFields` as before; a PUT ticket carries
198
+ `headers`, every one signed into the URL, that `LambderUploadRunner` sends
199
+ unchanged with the file as the body. `LambderMemoryUploadBucket` and
200
+ `LambderS3UploadBucket` both take a `uploadMethod` option (default `POST`)
201
+ and sign whichever kind of ticket that asks for.
202
+ - **`uploadObjectHeaders`** builds a PUT ticket's headers the way
203
+ `uploadObjectFormFields` builds a POST ticket's fields: the tag set as
204
+ `x-amz-tagging`, each metadata entry as `x-amz-meta-<name>`, `cache-control`
205
+ and `content-disposition`.
206
+
12
207
  ## [9.0.2] - 2026-09-27
13
208
 
14
209
  Nothing a handler or a caller does changes; the package, its docs and its
package/README.md CHANGED
@@ -24,7 +24,8 @@ export const handler = lambder.getHandler();
24
24
  ```
25
25
 
26
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
27
+ it is sent, and says no by throwing a refusal: `ctx.refuse()`, typed to the
28
+ codes the API declares, or `refuse()` from anywhere. Registration chains onto the
28
29
  creation call: every `addApi` returns an instance carrying the contract so
29
30
  far, so the whole backend is one declaration and `lambder.ApiContract` is the
30
31
  accumulated type.
@@ -46,6 +47,16 @@ const company = await caller.api("getCompany", { slug: "acme" });
46
47
  runtime validation and compile-time inference on both sides of the wire.
47
48
  - **One inferred contract.** The API contract is derived from the backend code
48
49
  and consumed by the frontend as a type-only import.
50
+ - **Exact results.** A success is only ever the handler's parsed output, an
51
+ object or an array, so a caller's success is typed as exactly the output and
52
+ is never null or falsy; everything a hook or an error handler answers is a
53
+ refusal.
54
+ - **Declared refusals.** An app declares its refusal codes once, each with the
55
+ schema of its data, the status it leaves with and whether it flags
56
+ `notAuthorized`; every API and guard names the codes it may refuse with, and
57
+ a guard's or a handler's `ctx.refuse` is typed to them. The contract carries
58
+ them, a caller narrows `refusal.data` on the code, and a refusal outside its
59
+ declaration is a crash, never an answer.
49
60
  - **Simple route and API declaration.** Paths, regexes, predicates and
50
61
  structured matchers, chained fluently.
51
62
  - **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
@@ -69,8 +80,9 @@ const company = await caller.api("getCompany", { slug: "acme" });
69
80
  - **Frontend hosting.** Serve a build from a folder, S3, R2 or any HTTP
70
81
  origin, with an app shell rendered through a build-pipeline-safe template
71
82
  engine.
72
- - **Direct uploads.** Files go from the browser straight to S3 on tickets
73
- that pin their size, type and SHA-256, with a browser runner that hashes,
83
+ - **Direct uploads.** Files go from the browser straight to S3, or to R2 on
84
+ presigned PUTs, on tickets that pin their size, type and SHA-256, with a
85
+ browser runner that hashes,
74
86
  retries and reports progress, and a memory bucket that holds tests and the
75
87
  mock to the same rules.
76
88
  - **Runs anywhere Lambda does.** API Gateway REST APIs (payload v1), HTTP APIs
@@ -163,7 +175,7 @@ guide that matches what you are building. The full index lives in
163
175
  | [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
164
176
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
165
177
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
166
- | [Direct uploads](./docs/uploads.md) | Files the browser posts straight to S3 with tickets the server signs, verified before they count |
178
+ | [Direct uploads](./docs/uploads.md) | Files the browser sends straight to S3 or R2 with tickets the server signs, verified before they count |
167
179
  | [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
168
180
  | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
169
181
  | [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
@@ -187,16 +199,19 @@ framework:
187
199
  ## Versioning and changes
188
200
 
189
201
  Released versions and what each one changed are in
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. The one it cannot is a change of behavior: a refusal is no
196
- longer stored under an idempotency key, so a retry after one runs the handler
197
- again. An app still on v7 goes through the 8.0.2 entry first, which
198
- names the breaks of v8 the compiler cannot find, and one on v6 through the
199
- 7.0.0 entry before that.
202
+ [CHANGELOG.md](./CHANGELOG.md). The current major is v10, which makes both
203
+ sides of an answer exact. A success is only ever the handler's parsed output,
204
+ and an output is an object or an array, so `caller.api()` is truthy exactly
205
+ on success and a success's payload is typed as exactly the output; a hook, a
206
+ fallback or an error handler answers an API call with `res.apiRefusal()`,
207
+ never with a payload. A refusal names a code the app declared, which the
208
+ contract carries and a caller narrows on, with its data typed. Every break
209
+ and what to do about it is in the 10.0.1 entry, and the compiler finds most
210
+ of them. The ones it cannot are changes of behavior: a refusal whose code its
211
+ API does not declare is now a crash rather than an answer, and a success
212
+ whose payload is not an object or an array now reads as a server failure. An
213
+ app still on v8 goes through the 9.0.1 entry first, and one on an older
214
+ major through the entries before it.
200
215
 
201
216
  ## Contributing
202
217
 
@@ -1,4 +1,5 @@
1
1
  import type { z } from "zod";
2
+ import type { LambderEndpointRefusals } from "./LambderApiRefusals.js";
2
3
  import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
3
4
  import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "../shared/wire/LambderApiOptionValues.js";
4
5
  /**
@@ -10,6 +11,13 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
10
11
  * signature (apiSignatureOf), which is what a client's build is checked
11
12
  * against; the server also parses every output a handler returns through it
12
13
  * before it is sent.
14
+ *
15
+ * `refusals` is every code the endpoint may refuse with (its own option and
16
+ * its guards'), resolved against the vocabulary, and whether every refusal
17
+ * has to name one: what every refusal it answers with is checked against
18
+ * (checkedRefusal); the codes are part of its signature. Undefined where the
19
+ * codes are not known (a mock without generated options), which checks
20
+ * nothing.
13
21
  */
14
22
  export type LambderApiDefinition = {
15
23
  name: string;
@@ -19,4 +27,5 @@ export type LambderApiDefinition = {
19
27
  idempotency?: LambderApiIdempotencyOption;
20
28
  input?: z.ZodType;
21
29
  output?: z.ZodType;
30
+ refusals?: LambderEndpointRefusals;
22
31
  };
@@ -1,26 +1,38 @@
1
1
  import type { z } from "zod";
2
- import type { LambderApiEnvelopeBody, LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
2
+ import type { LambderApiEnvelopeBody, LambderRefusalEnvelopeFields, LambderApiRefusalConfig, LambderApiRefusalEnvelope, LambderApiSuccessEnvelope } from "../shared/wire/LambderApiContract.js";
3
3
  import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
4
4
  import type { LambderCrashDetail } from "../shared/wire/LambderCrashDetail.js";
5
5
  import type { LambderApiAnswer } from "./LambderApiAnswer.js";
6
6
  export declare const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
7
- /** The envelope's config plus the logList channel the call accumulated. */
8
- export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
9
- logList?: unknown[];
10
- };
11
7
  /**
12
- * The wire envelope for one answer. Flags are only present when set, so a
13
- * plain success is `{ apiVersion, payload }` and nothing else; an empty
14
- * logList is omitted.
8
+ * A handler's answer as the wire envelope: its parsed output and the logList
9
+ * the call accumulated, nothing else. The only envelope a reader takes for a
10
+ * success; an empty logList is omitted.
11
+ */
12
+ export declare const successEnvelope: <T>(apiVersion: string | null | undefined, payload: T, logList?: unknown[]) => LambderApiSuccessEnvelope<T>;
13
+ /**
14
+ * Every other answer as the wire envelope: a null payload beside the
15
+ * refusal's message and flags. Flags are only present when set, and an
16
+ * empty logList is omitted.
17
+ */
18
+ export declare const refusalEnvelope: (apiVersion: string | null | undefined, { versionExpired, sessionExpired, notAuthorized, refusal, logList, crash }: LambderRefusalEnvelopeFields) => LambderApiRefusalEnvelope;
19
+ /**
20
+ * An API call answered from outside its handler (res.apiRefusal on the
21
+ * server, onInvalidInput in the mock) as its envelope, once the config is
22
+ * known to be a refusal: a refusal message or one of the three flags, so a
23
+ * reader never takes it for the handler's output, and a message with a
24
+ * framework code or none and no data, since it answers outside any one
25
+ * endpoint's declared refusals. `writer` names the call in the error, and
26
+ * the logList is the config's own or the one given.
15
27
  */
16
- export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
28
+ export declare const plainRefusalEnvelope: (apiVersion: string | null | undefined, config: LambderApiRefusalConfig, writer: string, logList?: unknown[]) => LambderApiRefusalEnvelope;
17
29
  /** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
18
30
  export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
19
31
  statusCode?: number;
20
32
  headers?: Record<string, string | string[]>;
21
33
  }) => LambderApiAnswer;
22
34
  /**
23
- * A thrown refusal as an answer: its errorMessage and flags on the envelope,
35
+ * A thrown refusal as an answer: its refusal and flags on the envelope,
24
36
  * its status (200 unless it set one) and its extra headers (Retry-After on a
25
37
  * rate limit). The logList the call accumulated rides along, as it does on
26
38
  * a success.
@@ -1,35 +1,70 @@
1
- import { LAMBDER_REFUSAL_CODES, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
1
+ import { LAMBDER_REFUSAL_CODES, isLambderRefusalCode, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
2
2
  import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
3
3
  /*
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
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
8
+ * path and res.apiRefusal(), 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
  */
12
12
  export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
13
13
  /**
14
- * The wire envelope for one answer. Flags are only present when set, so a
15
- * plain success is `{ apiVersion, payload }` and nothing else; an empty
16
- * logList is omitted.
14
+ * A handler's answer as the wire envelope: its parsed output and the logList
15
+ * the call accumulated, nothing else. The only envelope a reader takes for a
16
+ * success; an empty logList is omitted.
17
17
  */
18
- export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, } = {}) => ({
18
+ export const successEnvelope = (apiVersion, payload, logList) => ({
19
19
  apiVersion: apiVersion ?? null,
20
20
  payload,
21
- ...(versionExpired ? { versionExpired } : {}),
22
- ...(sessionExpired ? { sessionExpired } : {}),
23
- ...(notAuthorized ? { notAuthorized } : {}),
21
+ ...(logList?.length ? { logList } : {}),
22
+ });
23
+ /**
24
+ * Every other answer as the wire envelope: a null payload beside the
25
+ * refusal's message and flags. Flags are only present when set, and an
26
+ * empty logList is omitted.
27
+ */
28
+ export const refusalEnvelope = (apiVersion, { versionExpired, sessionExpired, notAuthorized, refusal, logList, crash }) => ({
29
+ apiVersion: apiVersion ?? null,
30
+ payload: null,
31
+ ...(versionExpired ? { versionExpired: true } : {}),
32
+ ...(sessionExpired ? { sessionExpired: true } : {}),
33
+ ...(notAuthorized ? { notAuthorized: true } : {}),
24
34
  // Presence, not truthiness: these channels carry app values, and an app
25
- // that refuses with errorMessage: "" (or 0) meant to say something. The
35
+ // that refuses with refusal: "" (or 0) meant to say something. The
26
36
  // flags above are booleans, where false and absent mean the same. An
27
- // errorMessage goes out as a message object whatever form it was written
37
+ // refusal goes out as a message object whatever form it was written
28
38
  // in, so every reader meets one shape.
29
- ...(errorMessage !== undefined ? { errorMessage: refusalMessageOf(errorMessage) } : {}),
39
+ ...(refusal !== undefined ? { refusal: refusalMessageOf(refusal) } : {}),
30
40
  ...(crash !== undefined ? { crash } : {}),
31
41
  ...(logList?.length ? { logList } : {}),
32
42
  });
43
+ /**
44
+ * An API call answered from outside its handler (res.apiRefusal on the
45
+ * server, onInvalidInput in the mock) as its envelope, once the config is
46
+ * known to be a refusal: a refusal message or one of the three flags, so a
47
+ * reader never takes it for the handler's output, and a message with a
48
+ * framework code or none and no data, since it answers outside any one
49
+ * endpoint's declared refusals. `writer` names the call in the error, and
50
+ * the logList is the config's own or the one given.
51
+ */
52
+ export const plainRefusalEnvelope = (apiVersion, config, writer, logList) => {
53
+ const { refusal, versionExpired, sessionExpired, notAuthorized } = config;
54
+ if (refusal === undefined && !versionExpired && !sessionExpired && !notAuthorized) {
55
+ throw new Error(`Lambder: ${writer} needs a refusal message or one of the versionExpired, sessionExpired and notAuthorized flags. An API call answered from outside its handler is always a refusal.`);
56
+ }
57
+ if (refusal !== undefined) {
58
+ const message = refusalMessageOf(refusal);
59
+ if (message.code !== undefined && !isLambderRefusalCode(message.code)) {
60
+ throw new Error(`Lambder: ${writer} was given the code "${message.code}". It answers outside any one API's declared refusals, so its message carries a framework code or none.`);
61
+ }
62
+ if (message.data !== undefined) {
63
+ throw new Error(`Lambder: ${writer} was given a message with data. Only a code an API declares carries data, and this answer is outside any one API's declaration.`);
64
+ }
65
+ }
66
+ return refusalEnvelope(apiVersion, { ...config, logList: config.logList ?? logList });
67
+ };
33
68
  /** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
34
69
  export const envelopeAnswer = (envelope, options = {}) => {
35
70
  const headers = { "Content-Type": [API_ANSWER_CONTENT_TYPE] };
@@ -41,13 +76,13 @@ export const envelopeAnswer = (envelope, options = {}) => {
41
76
  return { statusCode: options.statusCode ?? 200, headers, body: JSON.stringify(envelope) };
42
77
  };
43
78
  /**
44
- * A thrown refusal as an answer: its errorMessage and flags on the envelope,
79
+ * A thrown refusal as an answer: its refusal and flags on the envelope,
45
80
  * its status (200 unless it set one) and its extra headers (Retry-After on a
46
81
  * rate limit). The logList the call accumulated rides along, as it does on
47
82
  * a success.
48
83
  */
49
- export const refusalAnswer = (err, apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
50
- ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
84
+ export const refusalAnswer = (err, apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, {
85
+ ...(err.refusal !== undefined ? { refusal: err.refusal } : {}),
51
86
  ...(err.notAuthorized ? { notAuthorized: true } : {}),
52
87
  ...(err.sessionExpired ? { sessionExpired: true } : {}),
53
88
  logList,
@@ -155,17 +190,17 @@ export const validationAnswer = (zodError, logList) => {
155
190
  };
156
191
  };
157
192
  /** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */
158
- export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
159
- errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." },
193
+ export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, {
194
+ refusal: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." },
160
195
  logList,
161
196
  }));
162
197
  /** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
163
- export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { sessionExpired: true, logList }));
198
+ export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, { sessionExpired: true, logList }));
164
199
  /** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
165
- export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { versionExpired: true }));
200
+ export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(refusalEnvelope(apiVersion, { versionExpired: true }));
166
201
  /** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
167
- export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
168
- errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content: message },
202
+ export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(refusalEnvelope(apiVersion, {
203
+ refusal: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content: message },
169
204
  }), { statusCode: 400 });
170
205
  /**
171
206
  * The last-resort answer when the call crashed and nothing else could
@@ -175,4 +210,4 @@ export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buil
175
210
  * with the call's logList) is passed only for a caller the app's
176
211
  * `crashes.reveal` trusts.
177
212
  */
178
- export const crashAnswer = (apiVersion, revealed) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { errorMessage: "Internal server error.", ...revealed }), { statusCode: 500 });
213
+ export const crashAnswer = (apiVersion, revealed) => envelopeAnswer(refusalEnvelope(apiVersion, { refusal: "Internal server error.", ...revealed }), { statusCode: 500 });