lambder 13.0.1 → 14.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +1 -1
- package/dist/api/LambderApiPipeline.d.ts +22 -2
- package/dist/api/LambderApiPipeline.js +28 -3
- package/dist/api/LambderApiRefusals.d.ts +16 -6
- package/dist/api/LambderApiRefusals.js +17 -10
- package/dist/api/LambderApiSignature.d.ts +5 -5
- package/dist/api/LambderApiSignature.js +7 -5
- package/dist/build/checkApiRefusals.d.ts +54 -0
- package/dist/build/checkApiRefusals.js +415 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/loadTypeScriptCompiler.d.ts +10 -0
- package/dist/build/loadTypeScriptCompiler.js +20 -0
- package/dist/build/writeApiContract.js +21 -15
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +5 -2
- package/dist/build.js +4 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +6 -5
- package/dist/core/Lambder.js +10 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
- package/dist/shared/wire/LambderApiRefusal.d.ts +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,88 @@ 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
|
+
## [14.1.1] - 2026-09-30
|
|
13
|
+
|
|
14
|
+
A minor: a refusal code can declare the `sessionExpired` flag, and
|
|
15
|
+
`checkApiRefusals` holds every refusal a handler can reach to the codes its
|
|
16
|
+
endpoint may send, which is what an app that requires codes needs to know
|
|
17
|
+
before a call finds out.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **A code declared `sessionExpired: true`** leaves with the envelope's
|
|
22
|
+
`sessionExpired` flag, as one declared `notAuthorized: true` leaves with
|
|
23
|
+
that one: for a session the pipeline found good that the handler then
|
|
24
|
+
finds gone (its login deleted, say). With `requireCodes`, a refusal that
|
|
25
|
+
meant "your session is over" had no way to carry both a code and the
|
|
26
|
+
flag. The declaration owns the flag, so a raise site that sets it on a
|
|
27
|
+
declared code is still a crash; a code may set one flag at most, since a
|
|
28
|
+
caller routes a refusal one way; and the flag counts in an endpoint's
|
|
29
|
+
signature only where a code sets it, so every existing digest stays as it
|
|
30
|
+
was. The mock sends it the same way.
|
|
31
|
+
- **`checkApiRefusals`** (`lambder/build`): reads a project through the
|
|
32
|
+
TypeScript compiler and, for every handler Lambder hands a typed `refuse`
|
|
33
|
+
(an endpoint's, a guard's, a mock entry's), follows it into every function
|
|
34
|
+
it can reach (a call, a method of what it constructs, a function handed
|
|
35
|
+
along, a module loaded with `import()`, a handler the app wraps or holds
|
|
36
|
+
in an object) and holds each code raised there to the codes its
|
|
37
|
+
`ctx.refuse` takes. It names a code a handler may not send, a declared
|
|
38
|
+
code nothing reaches, a refusal with no code where codes are required, a
|
|
39
|
+
refusal whose code is any string, and a handler whose function it cannot
|
|
40
|
+
find; a project in which it finds no handler to check fails rather than
|
|
41
|
+
passes. Before it, a shared helper's code for an endpoint that did not
|
|
42
|
+
declare it compiled, and crashed only when a call reached it.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **A refusal that leaves an API call with the `sessionExpired` flag ends
|
|
47
|
+
the session the call held**, whether a declared code or the raise site set
|
|
48
|
+
the flag, and whether a handler, a guard or a hook threw it. The caller
|
|
49
|
+
stops sending a session it is told is over, and before this nothing
|
|
50
|
+
deleted it on the server until it expired. The record is deleted and the
|
|
51
|
+
cookies are left alone, as when the session read finds no session, since a
|
|
52
|
+
cookie deletion would also delete a session another response has just
|
|
53
|
+
set.
|
|
54
|
+
|
|
55
|
+
## [14.0.1] - 2026-09-30
|
|
56
|
+
|
|
57
|
+
A major: `writeApiGuardParams` writes one export per API in place of one
|
|
58
|
+
table, so a browser carries only the guard parameters its screens import,
|
|
59
|
+
and none of the API names behind them.
|
|
60
|
+
|
|
61
|
+
### Changed (breaking)
|
|
62
|
+
|
|
63
|
+
- **`writeApiGuardParams` writes one export per API** that declares the
|
|
64
|
+
guard, in place of the `guardParams` table: `orders.list` exports
|
|
65
|
+
`ordersListGuardParam`, holding the parameter as declared (`true` for the
|
|
66
|
+
guard named without one) and typed
|
|
67
|
+
`LambderApiGuardParam<"orders.list", "store", literal>`. A bundler keeps
|
|
68
|
+
only the exports a client imports, and none of them carries its API's name,
|
|
69
|
+
which sits in the export's type and doc comment alone, so a page no longer
|
|
70
|
+
ships the name of every API behind the guard and what each asks. The
|
|
71
|
+
module imports the tag type from `lambder/client`, as a type. Two APIs
|
|
72
|
+
whose names would export under one identifier fail the write, and so does
|
|
73
|
+
a `null` parameter, which the tag cannot carry; a guard no API declares
|
|
74
|
+
writes an empty module. A file still in the table form reads as one to
|
|
75
|
+
regenerate, on a check and on a write.
|
|
76
|
+
- To move: regenerate the file, and import the exports a screen gates on
|
|
77
|
+
in place of reading `guardParams[name]`. A function that takes any of a
|
|
78
|
+
guard's parameters reads their union off the module with a type-only
|
|
79
|
+
import (`(typeof params)[keyof typeof params]`, over
|
|
80
|
+
`import type * as params`). Code that needs every API's name, a test
|
|
81
|
+
walking them all, reads it from the `writeApiOptions` file.
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- **`LambderApiGuardParam`** (`lambder/client`, `lambder`): the type of each
|
|
86
|
+
export `writeApiGuardParams` writes, a guard's parameter tagged in types
|
|
87
|
+
alone with the API's name and the guard's, so a function typed to a
|
|
88
|
+
guard's parameters refuses a hand-written literal.
|
|
89
|
+
- **`apiGuardParamExportName`** (`lambder/client`, `lambder`): the identifier
|
|
90
|
+
`writeApiGuardParams` exports an API's parameter under, for code that reads
|
|
91
|
+
a generated module by API name, such as a test comparing it with the
|
|
92
|
+
server.
|
|
93
|
+
|
|
12
94
|
## [13.0.1] - 2026-09-30
|
|
13
95
|
|
|
14
96
|
A major: session data is checked against a schema on every read, and
|
package/README.md
CHANGED
|
@@ -140,7 +140,7 @@ The package ships five entry points; pick by where the code runs:
|
|
|
140
140
|
| `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `LambderUploadRunner`, `LambderBackoffTimer`, `LambderSignedClaims`, `html`/`xml` tagged templates, `createLambderI18n` |
|
|
141
141
|
| `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 |
|
|
142
142
|
| `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 |
|
|
143
|
-
| `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,
|
|
143
|
+
| `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, one export per API and nothing else, so a browser gating on that guard carries only the ones it imports; `writeApiContract`: the contract as plain types a client compiles instead of the server; `generateApiFiles`: every one of them, for every app a script names, in one call |
|
|
144
144
|
|
|
145
145
|
Frontends and shared isomorphic packages should import from `lambder/client`
|
|
146
146
|
only; the entry's module graph contains no AWS SDK, Node built-ins, or server
|
|
@@ -5,6 +5,8 @@ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
|
5
5
|
import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
|
|
6
6
|
import type { LambderApiDefinition } from "./LambderApiDefinition.js";
|
|
7
7
|
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
8
|
+
import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
9
|
+
import { type LambderEndpointRefusals } from "./LambderApiRefusals.js";
|
|
8
10
|
import { type LambderApiGuard } from "./LambderApiGuards.js";
|
|
9
11
|
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitChargeResult, type LambderRateLimitChargeSubject } from "./LambderApiRateLimits.js";
|
|
10
12
|
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
@@ -107,8 +109,12 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
|
|
|
107
109
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
108
110
|
* envelope once checked against the endpoint's declared codes
|
|
109
111
|
* (checkedRefusal), and a session ended while the handler held it
|
|
110
|
-
* (LambderSessionNotFoundError) as sessionExpired.
|
|
111
|
-
*
|
|
112
|
+
* (LambderSessionNotFoundError) as sessionExpired. A refusal that leaves
|
|
113
|
+
* with the sessionExpired flag ends the session the call held
|
|
114
|
+
* (answerRefusal), so the server keeps no session its caller has been told
|
|
115
|
+
* is over. Anything else
|
|
116
|
+
* propagates, because only the adapter knows what a crash means (a global
|
|
117
|
+
* error handler, a mock event).
|
|
112
118
|
*
|
|
113
119
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
114
120
|
* definition is the one thing the adapters legitimately do differently (an
|
|
@@ -161,6 +167,20 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
|
|
|
161
167
|
* versionExpired by the time anything asks for an unknown name.
|
|
162
168
|
*/
|
|
163
169
|
answerUnknownApi(ctx?: TCtx): LambderApiAnswer;
|
|
170
|
+
/**
|
|
171
|
+
* A thrown refusal as the answer to a call of `apiName`: checked against
|
|
172
|
+
* the codes the endpoint declares (checkedRefusal, which throws for one it
|
|
173
|
+
* may not send), then rendered. run() answers a step's, a guard's or a
|
|
174
|
+
* handler's refusal with it, and the server a hook's.
|
|
175
|
+
*
|
|
176
|
+
* A refusal telling the caller its session is over (a declared code's
|
|
177
|
+
* flag, or a raise site's own) ends the session the call held: the caller
|
|
178
|
+
* stops sending it, and nothing else would delete it before it expires.
|
|
179
|
+
* The record is deleted and the cookies left alone, which is what the
|
|
180
|
+
* pipeline's own sessionExpired leaves behind (endWithNoSession in the
|
|
181
|
+
* session controller says why the cookies stay).
|
|
182
|
+
*/
|
|
183
|
+
answerRefusal(apiName: string, refusals: LambderEndpointRefusals | undefined, thrown: LambderApiRefusal, ctx: TCtx): Promise<LambderApiAnswer>;
|
|
164
184
|
/**
|
|
165
185
|
* The steps that come before anything may read the request: the version
|
|
166
186
|
* floor, the signature gate, then the compressed-payload restore that
|
|
@@ -37,8 +37,12 @@ const usesIdempotency = (definition) => definition.idempotency !== undefined &&
|
|
|
37
37
|
* validation error through onInvalidInput, any other refusal as the refusal
|
|
38
38
|
* envelope once checked against the endpoint's declared codes
|
|
39
39
|
* (checkedRefusal), and a session ended while the handler held it
|
|
40
|
-
* (LambderSessionNotFoundError) as sessionExpired.
|
|
41
|
-
*
|
|
40
|
+
* (LambderSessionNotFoundError) as sessionExpired. A refusal that leaves
|
|
41
|
+
* with the sessionExpired flag ends the session the call held
|
|
42
|
+
* (answerRefusal), so the server keeps no session its caller has been told
|
|
43
|
+
* is over. Anything else
|
|
44
|
+
* propagates, because only the adapter knows what a crash means (a global
|
|
45
|
+
* error handler, a mock event).
|
|
42
46
|
*
|
|
43
47
|
* `run` never sees a name it has no definition for; resolving a name to a
|
|
44
48
|
* definition is the one thing the adapters legitimately do differently (an
|
|
@@ -185,6 +189,27 @@ export class LambderApiPipeline {
|
|
|
185
189
|
ctx?.responseHeaders.applyInto(answer.headers);
|
|
186
190
|
return answer;
|
|
187
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* A thrown refusal as the answer to a call of `apiName`: checked against
|
|
194
|
+
* the codes the endpoint declares (checkedRefusal, which throws for one it
|
|
195
|
+
* may not send), then rendered. run() answers a step's, a guard's or a
|
|
196
|
+
* handler's refusal with it, and the server a hook's.
|
|
197
|
+
*
|
|
198
|
+
* A refusal telling the caller its session is over (a declared code's
|
|
199
|
+
* flag, or a raise site's own) ends the session the call held: the caller
|
|
200
|
+
* stops sending it, and nothing else would delete it before it expires.
|
|
201
|
+
* The record is deleted and the cookies left alone, which is what the
|
|
202
|
+
* pipeline's own sessionExpired leaves behind (endWithNoSession in the
|
|
203
|
+
* session controller says why the cookies stay).
|
|
204
|
+
*/
|
|
205
|
+
async answerRefusal(apiName, refusals, thrown, ctx) {
|
|
206
|
+
const checked = checkedRefusal(apiName, refusals, thrown);
|
|
207
|
+
if (checked.sessionExpired && ctx.session && this.sessions) {
|
|
208
|
+
await this.sessions.manager.deleteSession(ctx.session);
|
|
209
|
+
ctx.session = null;
|
|
210
|
+
}
|
|
211
|
+
return refusalAnswer(checked, this.apiVersion, ctx.logList);
|
|
212
|
+
}
|
|
188
213
|
/**
|
|
189
214
|
* The steps that come before anything may read the request: the version
|
|
190
215
|
* floor, the signature gate, then the compressed-payload restore that
|
|
@@ -250,7 +275,7 @@ export class LambderApiPipeline {
|
|
|
250
275
|
// idempotency engine has already released the claim, as it
|
|
251
276
|
// does for every thrown refusal: a retry runs the handler
|
|
252
277
|
// again and decides afresh.
|
|
253
|
-
answer =
|
|
278
|
+
answer = await this.answerRefusal(definition.name, definition.refusals, err, ctx);
|
|
254
279
|
}
|
|
255
280
|
else if (err instanceof LambderSessionNotFoundError) {
|
|
256
281
|
// No usable session: it ended while the handler held it (a
|
|
@@ -8,7 +8,7 @@ import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderN
|
|
|
8
8
|
* One code of an app's refusal vocabulary: the schema of the data it carries,
|
|
9
9
|
* when it carries data, and how every refusal with the code leaves the
|
|
10
10
|
* server. A code's data is an object or an array, as an API's output is. The
|
|
11
|
-
* status and the
|
|
11
|
+
* status and the flags are declared once here rather than at each raise site,
|
|
12
12
|
* so a code always reaches a caller the same way.
|
|
13
13
|
*/
|
|
14
14
|
export type LambderRefusalDeclaration = {
|
|
@@ -18,6 +18,14 @@ export type LambderRefusalDeclaration = {
|
|
|
18
18
|
status?: LambderRefusalStatusCode;
|
|
19
19
|
/** Whether every refusal with this code sets the envelope's notAuthorized flag, which a caller routes to its notAuthorizedHandler. */
|
|
20
20
|
notAuthorized?: true;
|
|
21
|
+
/**
|
|
22
|
+
* Whether every refusal with this code sets the envelope's sessionExpired
|
|
23
|
+
* flag, which a caller answers by clearing its session and routing to its
|
|
24
|
+
* sessionExpiredHandler: for a session the handler found no longer good
|
|
25
|
+
* (its login deleted, say), which the pipeline's own check could not know.
|
|
26
|
+
* The server ends the session the call held as the refusal leaves.
|
|
27
|
+
*/
|
|
28
|
+
sessionExpired?: true;
|
|
21
29
|
};
|
|
22
30
|
/**
|
|
23
31
|
* One code as its endpoint may send it: the status and flag its declaration
|
|
@@ -30,6 +38,8 @@ export type LambderApiAllowedRefusal = {
|
|
|
30
38
|
status?: LambderRefusalStatusCode;
|
|
31
39
|
/** Present when every refusal with the code sets the envelope's notAuthorized flag. */
|
|
32
40
|
notAuthorized?: true;
|
|
41
|
+
/** Present when every refusal with the code sets the envelope's sessionExpired flag. */
|
|
42
|
+
sessionExpired?: true;
|
|
33
43
|
} & ({
|
|
34
44
|
data: false;
|
|
35
45
|
} | {
|
|
@@ -61,7 +71,7 @@ type LambderRefusalDataRefusal = {
|
|
|
61
71
|
};
|
|
62
72
|
/**
|
|
63
73
|
* The vocabulary as declareRefusals() checks it, code by code: no `lambder/`
|
|
64
|
-
* prefix, no key beside data, status and
|
|
74
|
+
* prefix, no key beside data, status and the two flags (a `dat:` would leave
|
|
65
75
|
* a code that carries data declared as one that does not), and data that is
|
|
66
76
|
* an object or an array.
|
|
67
77
|
*/
|
|
@@ -154,8 +164,8 @@ export declare class LambderApiRefusalValidationError extends Error {
|
|
|
154
164
|
* - An uncoded refusal, or one carrying a framework code, goes out as it is,
|
|
155
165
|
* provided it carries no data. Where the app requires codes, an uncoded
|
|
156
166
|
* refusal is refused too; a framework code still passes.
|
|
157
|
-
* - A declared code leaves with its declaration's status and
|
|
158
|
-
*
|
|
167
|
+
* - A declared code leaves with its declaration's status and flags, and
|
|
168
|
+
* with data exactly when the code carries data, parsed through
|
|
159
169
|
* the code's schema as an output is: undeclared fields stripped, defaults
|
|
160
170
|
* filled, transforms run. The parse is synchronous, so a data schema cannot
|
|
161
171
|
* be async.
|
|
@@ -187,8 +197,8 @@ export declare const toRefusalCodes: (value: string | readonly string[] | undefi
|
|
|
187
197
|
* The vocabulary, checked: every code a non-empty string outside the
|
|
188
198
|
* framework's `lambder/` prefix, every declaration an object whose `data`,
|
|
189
199
|
* when present, is a zod schema, whose `status`, when present, is one a
|
|
190
|
-
* reader files as a refusal, and whose `notAuthorized
|
|
191
|
-
* true. A Map, so a code named for something Object.prototype carries
|
|
200
|
+
* reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
|
|
201
|
+
* when present, are true. A Map, so a code named for something Object.prototype carries
|
|
192
202
|
* ("toString") is still an ordinary code.
|
|
193
203
|
*/
|
|
194
204
|
export declare const readRefusalVocabulary: (refusals: Record<string, LambderRefusalDeclaration> | undefined) => Map<string, LambderRefusalDeclaration> | null;
|
|
@@ -18,6 +18,7 @@ export const allowedRefusalOf = (declaration) => {
|
|
|
18
18
|
const leaves = {
|
|
19
19
|
...(declaration.status !== undefined ? { status: declaration.status } : {}),
|
|
20
20
|
...(declaration.notAuthorized ? { notAuthorized: true } : {}),
|
|
21
|
+
...(declaration.sessionExpired ? { sessionExpired: true } : {}),
|
|
21
22
|
};
|
|
22
23
|
return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
|
|
23
24
|
};
|
|
@@ -63,7 +64,7 @@ export class LambderApiRefusalValidationError extends Error {
|
|
|
63
64
|
}
|
|
64
65
|
else if ("ownStatusOrFlag" in violation) {
|
|
65
66
|
message = `Lambder: API "${apiName}" refused with the declared code "${String(code)}" and a status or flag of its own, so the refusal was not sent. `
|
|
66
|
-
+ "A declared code's status and notAuthorized
|
|
67
|
+
+ "A declared code's status and its notAuthorized and sessionExpired flags are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
|
|
67
68
|
}
|
|
68
69
|
else if ("zodError" in violation) {
|
|
69
70
|
// Paths and messages only, never the values: they may be the
|
|
@@ -95,8 +96,8 @@ export class LambderApiRefusalValidationError extends Error {
|
|
|
95
96
|
* - An uncoded refusal, or one carrying a framework code, goes out as it is,
|
|
96
97
|
* provided it carries no data. Where the app requires codes, an uncoded
|
|
97
98
|
* refusal is refused too; a framework code still passes.
|
|
98
|
-
* - A declared code leaves with its declaration's status and
|
|
99
|
-
*
|
|
99
|
+
* - A declared code leaves with its declaration's status and flags, and
|
|
100
|
+
* with data exactly when the code carries data, parsed through
|
|
100
101
|
* the code's schema as an output is: undeclared fields stripped, defaults
|
|
101
102
|
* filled, transforms run. The parse is synchronous, so a data schema cannot
|
|
102
103
|
* be async.
|
|
@@ -145,11 +146,12 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
|
|
|
145
146
|
throw new LambderApiRefusalValidationError(apiName, thrown, { notObject: describePayloadKind(parsed.data) });
|
|
146
147
|
sent = parsed.data;
|
|
147
148
|
}
|
|
148
|
-
// The declaration's status and
|
|
149
|
+
// The declaration's status and flags, and the data as parsed: how every
|
|
149
150
|
// refusal with this code leaves, wherever it was raised.
|
|
150
151
|
return new LambderApiRefusal(thrown.message, {
|
|
151
152
|
refusal: { ...thrown.refusal, ...(sent !== undefined ? { data: sent } : {}) },
|
|
152
153
|
notAuthorized: declaration.notAuthorized,
|
|
154
|
+
sessionExpired: declaration.sessionExpired,
|
|
153
155
|
statusCode: declaration.status,
|
|
154
156
|
headers: thrown.headers,
|
|
155
157
|
cause: thrown.cause,
|
|
@@ -195,8 +197,8 @@ export const toRefusalCodes = (value) => value === undefined ? [] : typeof value
|
|
|
195
197
|
* The vocabulary, checked: every code a non-empty string outside the
|
|
196
198
|
* framework's `lambder/` prefix, every declaration an object whose `data`,
|
|
197
199
|
* when present, is a zod schema, whose `status`, when present, is one a
|
|
198
|
-
* reader files as a refusal, and whose `notAuthorized
|
|
199
|
-
* true. A Map, so a code named for something Object.prototype carries
|
|
200
|
+
* reader files as a refusal, and whose `notAuthorized` and `sessionExpired`,
|
|
201
|
+
* when present, are true. A Map, so a code named for something Object.prototype carries
|
|
200
202
|
* ("toString") is still an ordinary code.
|
|
201
203
|
*/
|
|
202
204
|
export const readRefusalVocabulary = (refusals) => {
|
|
@@ -215,9 +217,9 @@ export const readRefusalVocabulary = (refusals) => {
|
|
|
215
217
|
if (declaration === null || typeof declaration !== "object") {
|
|
216
218
|
throw new Error(`Lambder: the refusal "${code}" is not declared as an object: write {} for a code with no data, or { data: schema }.`);
|
|
217
219
|
}
|
|
218
|
-
const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized");
|
|
220
|
+
const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized" && key !== "sessionExpired");
|
|
219
221
|
if (extra.length) {
|
|
220
|
-
throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status and
|
|
222
|
+
throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status, notAuthorized and sessionExpired, which a refusal declaration does not take.`);
|
|
221
223
|
}
|
|
222
224
|
// Read for what it is used as rather than by class, so a schema from
|
|
223
225
|
// a second copy of zod in the bundle still counts.
|
|
@@ -228,8 +230,13 @@ export const readRefusalVocabulary = (refusals) => {
|
|
|
228
230
|
if (status !== undefined && (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status >= 500 || status === 422)) {
|
|
229
231
|
throw new Error(`Lambder: the refusal "${code}" declares the status ${String(status)}. A declared refusal leaves with a status from 200 to 499 other than 422: a reader files a 5xx as a server failure and a 422 as an input validation failure.`);
|
|
230
232
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
for (const flag of ["notAuthorized", "sessionExpired"]) {
|
|
234
|
+
if (declaration[flag] !== undefined && declaration[flag] !== true) {
|
|
235
|
+
throw new Error(`Lambder: the refusal "${code}" declares ${flag} as ${String(declaration[flag])}; write true, or leave it off.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (declaration.notAuthorized && declaration.sessionExpired) {
|
|
239
|
+
throw new Error(`Lambder: the refusal "${code}" declares both notAuthorized and sessionExpired. A caller routes a refusal one way: a session that is gone is not also a session that is not allowed.`);
|
|
233
240
|
}
|
|
234
241
|
}
|
|
235
242
|
return new Map(entries);
|
|
@@ -18,17 +18,17 @@ export type LambderApiSignatureEntry = {
|
|
|
18
18
|
* schema that guard validates (the guardInput the client sends separately,
|
|
19
19
|
* or the apiInput slice of the payload), whether it demands an idempotency
|
|
20
20
|
* key, and every refusal code it may send with its data's schema, its status
|
|
21
|
-
* and its
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* and its flags. Anything else (rate limits, a guard's parameter, the
|
|
22
|
+
* handler) changes nothing for a client and is left out, so changing it
|
|
23
|
+
* never forces a reload.
|
|
24
24
|
*
|
|
25
25
|
* The refusal codes count in full: a client's refusal type lists exactly the
|
|
26
26
|
* codes the endpoint declares, so one built before a code was added would be
|
|
27
27
|
* handed a code its types say cannot arrive. Adding a code, or changing a
|
|
28
28
|
* code's data, reloads that endpoint's clients. A code's data is received,
|
|
29
29
|
* so it is digested in the output position, where an extensibleEnum's values
|
|
30
|
-
* leave the digest. Its status and
|
|
31
|
-
* and routes on the
|
|
30
|
+
* leave the digest. Its status and flags count too: a caller reads the one
|
|
31
|
+
* and routes on the others.
|
|
32
32
|
*
|
|
33
33
|
* Schemas are hashed as built, descriptions and titles included, so a client
|
|
34
34
|
* built against a different one reloads once. The exception is an output
|
|
@@ -56,17 +56,17 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
|
|
|
56
56
|
* schema that guard validates (the guardInput the client sends separately,
|
|
57
57
|
* or the apiInput slice of the payload), whether it demands an idempotency
|
|
58
58
|
* key, and every refusal code it may send with its data's schema, its status
|
|
59
|
-
* and its
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* and its flags. Anything else (rate limits, a guard's parameter, the
|
|
60
|
+
* handler) changes nothing for a client and is left out, so changing it
|
|
61
|
+
* never forces a reload.
|
|
62
62
|
*
|
|
63
63
|
* The refusal codes count in full: a client's refusal type lists exactly the
|
|
64
64
|
* codes the endpoint declares, so one built before a code was added would be
|
|
65
65
|
* handed a code its types say cannot arrive. Adding a code, or changing a
|
|
66
66
|
* code's data, reloads that endpoint's clients. A code's data is received,
|
|
67
67
|
* so it is digested in the output position, where an extensibleEnum's values
|
|
68
|
-
* leave the digest. Its status and
|
|
69
|
-
* and routes on the
|
|
68
|
+
* leave the digest. Its status and flags count too: a caller reads the one
|
|
69
|
+
* and routes on the others.
|
|
70
70
|
*
|
|
71
71
|
* Schemas are hashed as built, descriptions and titles included, so a client
|
|
72
72
|
* built against a different one reloads once. The exception is an output
|
|
@@ -89,6 +89,8 @@ export const apiSignatureOf = async (definition, guards) => {
|
|
|
89
89
|
data: jsonSchemaOf(declaration.data ? declaration.schema : undefined, "output"),
|
|
90
90
|
status: declaration.status ?? 200,
|
|
91
91
|
notAuthorized: declaration.notAuthorized === true,
|
|
92
|
+
// Written only when set, so a vocabulary with no such code digests as it did before the flag existed.
|
|
93
|
+
...(declaration.sessionExpired ? { sessionExpired: true } : {}),
|
|
92
94
|
}]);
|
|
93
95
|
refusalShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
94
96
|
const description = {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type LambderApiRefusalCheckOptions = {
|
|
2
|
+
/** The tsconfig.json the project compiles under, whose options and path aliases resolve its imports. Relative to the working directory. */
|
|
3
|
+
tsconfig: string;
|
|
4
|
+
/** The files the program starts from, relative to the working directory; everything they import is read too. Default: the tsconfig's own files. */
|
|
5
|
+
files?: string[];
|
|
6
|
+
/** Whether a refusal with no code is a finding, as it is a crash in an app whose vocabulary requires codes (declareRefusals's requireCodes). Default: true. */
|
|
7
|
+
requireCodes?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** What one handler's check found. */
|
|
10
|
+
export type LambderRefusalCheckFinding = {
|
|
11
|
+
/** The handler, as its registration names it: an endpoint's `group.action`, a mock entry's name, a guard's name. */
|
|
12
|
+
handler: string;
|
|
13
|
+
/** Where the handler is registered, as `file:line` relative to the working directory. */
|
|
14
|
+
at: string;
|
|
15
|
+
/**
|
|
16
|
+
* - `undeclared`: a code the handler can reach that it may not send.
|
|
17
|
+
* - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises.
|
|
18
|
+
* - `uncoded`: a refusal with no code it can reach, where codes are required.
|
|
19
|
+
* - `unreadable`: a refusal it can reach whose code is not a string literal type.
|
|
20
|
+
* - `untraced`: a handler handed a typed refuse whose function cannot be
|
|
21
|
+
* found (a parameter, a dependency's value), so nothing it reaches was
|
|
22
|
+
* checked.
|
|
23
|
+
*/
|
|
24
|
+
problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced";
|
|
25
|
+
/** The code, for `undeclared` and `unused`. */
|
|
26
|
+
code?: string;
|
|
27
|
+
/** Where the refusal is raised, as `file:line`, for `undeclared`, `uncoded` and `unreadable`. */
|
|
28
|
+
raisedAt?: string;
|
|
29
|
+
};
|
|
30
|
+
export type LambderApiRefusalCheckResult = {
|
|
31
|
+
/** False when the project could not be read, no handler in it could be checked, or any handler has a finding. */
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** How many handlers were checked. */
|
|
34
|
+
handlers: number;
|
|
35
|
+
findings: LambderRefusalCheckFinding[];
|
|
36
|
+
/** What happened, as lines to print: a summary, then one line per finding. */
|
|
37
|
+
lines: string[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Checks every handler of a project against the codes it may send, and
|
|
41
|
+
* answers what it found.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { checkApiRefusals } from "lambder/build";
|
|
45
|
+
*
|
|
46
|
+
* const result = await checkApiRefusals({ tsconfig: "server/tsconfig.json" });
|
|
47
|
+
* console.log(result.lines.join("\n"));
|
|
48
|
+
* process.exit(result.ok ? 0 : 1);
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Run it where the app runs its other build checks, or from a test: it
|
|
52
|
+
* compiles the project, so it takes as long as a type check does.
|
|
53
|
+
*/
|
|
54
|
+
export declare const checkApiRefusals: (options: LambderApiRefusalCheckOptions) => Promise<LambderApiRefusalCheckResult>;
|