lambder 14.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 +43 -0
- 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/loadTypeScriptCompiler.d.ts +10 -0
- package/dist/build/loadTypeScriptCompiler.js +20 -0
- package/dist/build/writeApiContract.js +21 -15
- package/dist/build.d.ts +5 -2
- package/dist/build.js +4 -2
- package/dist/core/Lambder.d.ts +6 -5
- package/dist/core/Lambder.js +10 -9
- package/dist/shared/wire/LambderApiRefusal.d.ts +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,49 @@ 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
|
+
|
|
12
55
|
## [14.0.1] - 2026-09-30
|
|
13
56
|
|
|
14
57
|
A major: `writeApiGuardParams` writes one export per API in place of one
|
|
@@ -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>;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { dirname, relative, resolve, sep } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { loadTypeScriptCompiler } from "./loadTypeScriptCompiler.js";
|
|
4
|
+
/** The registrations whose handler Lambder types a refuse for, by the name they are called under, and where each one's handler is. */
|
|
5
|
+
const HANDLER_REGISTRATIONS = {
|
|
6
|
+
defineApi: "second",
|
|
7
|
+
guard: "optionsHandler",
|
|
8
|
+
lambderGuard: "optionsHandler",
|
|
9
|
+
api: "secondOrItsHandler",
|
|
10
|
+
override: "secondOrItsHandler",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Checks every handler of a project against the codes it may send, and
|
|
14
|
+
* answers what it found.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { checkApiRefusals } from "lambder/build";
|
|
18
|
+
*
|
|
19
|
+
* const result = await checkApiRefusals({ tsconfig: "server/tsconfig.json" });
|
|
20
|
+
* console.log(result.lines.join("\n"));
|
|
21
|
+
* process.exit(result.ok ? 0 : 1);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Run it where the app runs its other build checks, or from a test: it
|
|
25
|
+
* compiles the project, so it takes as long as a type check does.
|
|
26
|
+
*/
|
|
27
|
+
export const checkApiRefusals = async (options) => {
|
|
28
|
+
const ts = await loadTypeScriptCompiler("checkApiRefusals reads the project");
|
|
29
|
+
const requireCodes = options.requireCodes ?? true;
|
|
30
|
+
const configPath = resolve(options.tsconfig);
|
|
31
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
32
|
+
if (read.error)
|
|
33
|
+
return failed(`✗ ${options.tsconfig} could not be read: ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
|
|
34
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath);
|
|
35
|
+
if (parsed.errors.length)
|
|
36
|
+
return failed(`✗ ${options.tsconfig} has errors: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, " ")).join("; ")}`);
|
|
37
|
+
const rootNames = options.files?.map((file) => resolve(file)) ?? parsed.fileNames;
|
|
38
|
+
const program = ts.createProgram({ rootNames, options: { ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false } });
|
|
39
|
+
const checker = program.getTypeChecker();
|
|
40
|
+
const lambderRoot = realPathOf(ts, resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."));
|
|
41
|
+
const ownFiles = new Map();
|
|
42
|
+
/** A file of the project itself: not a dependency's, not a declaration file, and not Lambder's own (its tests excepted, which are apps). */
|
|
43
|
+
const isOwnFile = (sourceFile) => {
|
|
44
|
+
const known = ownFiles.get(sourceFile.fileName);
|
|
45
|
+
if (known !== undefined)
|
|
46
|
+
return known;
|
|
47
|
+
const path = realPathOf(ts, sourceFile.fileName);
|
|
48
|
+
const own = !sourceFile.isDeclarationFile
|
|
49
|
+
&& !path.split(sep).includes("node_modules")
|
|
50
|
+
&& (!isInside(path, lambderRoot) || isInside(path, resolve(lambderRoot, "tests")));
|
|
51
|
+
ownFiles.set(sourceFile.fileName, own);
|
|
52
|
+
return own;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A declaration of Lambder's: in the declaration files of any installed
|
|
56
|
+
* copy (a workspace holds one per set of peer versions, and a package's
|
|
57
|
+
* imports resolve to its own), or in its sources when this runs over its
|
|
58
|
+
* own tests.
|
|
59
|
+
*/
|
|
60
|
+
const isLambderDeclaration = (node) => {
|
|
61
|
+
if (!node)
|
|
62
|
+
return false;
|
|
63
|
+
const path = realPathOf(ts, node.getSourceFile().fileName);
|
|
64
|
+
const parts = path.split(sep);
|
|
65
|
+
if (parts.some((part, index) => part === "node_modules" && parts[index + 1] === "lambder"))
|
|
66
|
+
return true;
|
|
67
|
+
return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
|
|
68
|
+
};
|
|
69
|
+
const where = (node) => {
|
|
70
|
+
const sourceFile = node.getSourceFile();
|
|
71
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
72
|
+
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}`;
|
|
73
|
+
};
|
|
74
|
+
// ── Raise sites ──────────────────────────────────────────────────────
|
|
75
|
+
/** The string literal values of a type, or null when it is not made of string literals alone. */
|
|
76
|
+
const literalsOf = (type) => {
|
|
77
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
78
|
+
const values = [];
|
|
79
|
+
for (const part of parts) {
|
|
80
|
+
if (part.isStringLiteral())
|
|
81
|
+
values.push(part.value);
|
|
82
|
+
else
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return values;
|
|
86
|
+
};
|
|
87
|
+
/** The code a refuse options argument names, read off its type. */
|
|
88
|
+
const codeOfOptions = (argument, property, at) => {
|
|
89
|
+
if (!argument)
|
|
90
|
+
return [{ at, uncoded: true }];
|
|
91
|
+
let optionsType = checker.getTypeAtLocation(argument);
|
|
92
|
+
if (property === "refusal") {
|
|
93
|
+
const refusal = checker.getPropertyOfType(optionsType, "refusal");
|
|
94
|
+
if (!refusal)
|
|
95
|
+
return [{ at, uncoded: true }];
|
|
96
|
+
optionsType = checker.getTypeOfSymbolAtLocation(refusal, argument);
|
|
97
|
+
}
|
|
98
|
+
const code = checker.getPropertyOfType(optionsType, "code");
|
|
99
|
+
if (!code)
|
|
100
|
+
return [{ at, uncoded: true }];
|
|
101
|
+
const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, argument));
|
|
102
|
+
if (codeType.flags & ts.TypeFlags.Never)
|
|
103
|
+
return [{ at, uncoded: true }];
|
|
104
|
+
const values = literalsOf(codeType);
|
|
105
|
+
if (values === null)
|
|
106
|
+
return [{ at, unreadable: true }];
|
|
107
|
+
return values.map((value) => ({ at, code: value }));
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The refusal a call or construction raises, when it is one of Lambder's
|
|
111
|
+
* refuse functions or its refusal class's constructor. The constructor is
|
|
112
|
+
* told by the signature the call resolves to rather than by the name
|
|
113
|
+
* written, so it counts constructed as itself, as a class of the app's
|
|
114
|
+
* own that declares no constructor and inherits it, or as the super() of
|
|
115
|
+
* one that declares its own, which is then raised wherever that class is
|
|
116
|
+
* constructed.
|
|
117
|
+
*/
|
|
118
|
+
const raiseSitesOf = (node) => {
|
|
119
|
+
const callee = node.expression;
|
|
120
|
+
const constructs = ts.isNewExpression(node) || callee.kind === ts.SyntaxKind.SuperKeyword;
|
|
121
|
+
if (!constructs) {
|
|
122
|
+
const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
123
|
+
if (name !== "refuse")
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const declaration = checker.getResolvedSignature(node)?.declaration;
|
|
127
|
+
if (!isLambderDeclaration(declaration))
|
|
128
|
+
return [];
|
|
129
|
+
if (constructs && !(declaration && ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal"))
|
|
130
|
+
return [];
|
|
131
|
+
const args = node.arguments ?? ts.factory.createNodeArray();
|
|
132
|
+
return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
|
|
133
|
+
};
|
|
134
|
+
// ── The call graph ───────────────────────────────────────────────────
|
|
135
|
+
const unwrap = (expression) => {
|
|
136
|
+
let current = expression;
|
|
137
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current)) {
|
|
138
|
+
current = current.expression;
|
|
139
|
+
}
|
|
140
|
+
return current;
|
|
141
|
+
};
|
|
142
|
+
const isFunctionWithBody = (node) => (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
|
|
143
|
+
|| ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)) && node.body !== undefined;
|
|
144
|
+
/** The functions a declaration stands for: itself, the function it is initialized to, or a class's constructor where it is constructed. */
|
|
145
|
+
const functionsOfDeclaration = (declaration, reference) => {
|
|
146
|
+
if (isFunctionWithBody(declaration))
|
|
147
|
+
return [declaration];
|
|
148
|
+
if ((ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) && declaration.initializer) {
|
|
149
|
+
const initializer = unwrap(declaration.initializer);
|
|
150
|
+
return isFunctionWithBody(initializer) ? [initializer] : [];
|
|
151
|
+
}
|
|
152
|
+
if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) {
|
|
153
|
+
const constructed = ts.isNewExpression(reference.parent) && reference.parent.expression === reference;
|
|
154
|
+
return constructed ? declaration.members.filter((member) => ts.isConstructorDeclaration(member) && member.body !== undefined) : [];
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
};
|
|
158
|
+
/** The functions of the project an identifier refers to, followed through an import, a shorthand property or a destructured binding. */
|
|
159
|
+
const functionsReferredToBy = (reference) => {
|
|
160
|
+
let symbol = checker.getSymbolAtLocation(reference);
|
|
161
|
+
if (!symbol)
|
|
162
|
+
return [];
|
|
163
|
+
if (ts.isShorthandPropertyAssignment(reference.parent) && reference.parent.name === reference) {
|
|
164
|
+
symbol = checker.getShorthandAssignmentValueSymbol(reference.parent) ?? symbol;
|
|
165
|
+
}
|
|
166
|
+
if (symbol.flags & ts.SymbolFlags.Alias)
|
|
167
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
168
|
+
const found = [];
|
|
169
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
170
|
+
if (!isOwnFile(declaration.getSourceFile()))
|
|
171
|
+
continue;
|
|
172
|
+
if (ts.isBindingElement(declaration)) {
|
|
173
|
+
// `const { stamp } = await import("./stamper.js")`: the binding names no
|
|
174
|
+
// function, and the type it is given does.
|
|
175
|
+
for (const signature of checker.getTypeOfSymbolAtLocation(symbol, reference).getCallSignatures()) {
|
|
176
|
+
if (signature.declaration && isFunctionWithBody(signature.declaration) && isOwnFile(signature.declaration.getSourceFile()))
|
|
177
|
+
found.push(signature.declaration);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
found.push(...functionsOfDeclaration(declaration, reference));
|
|
182
|
+
}
|
|
183
|
+
return found;
|
|
184
|
+
};
|
|
185
|
+
const scanned = new Map();
|
|
186
|
+
/**
|
|
187
|
+
* A function's own raise sites and the functions it refers to, or an
|
|
188
|
+
* expression's (a handler written as a wrapper's call, say). A function
|
|
189
|
+
* written inside it is one it reaches, since it runs as part of it.
|
|
190
|
+
*/
|
|
191
|
+
const scan = (root) => {
|
|
192
|
+
const known = scanned.get(root);
|
|
193
|
+
if (known)
|
|
194
|
+
return known;
|
|
195
|
+
const result = { raises: [], reaches: [] };
|
|
196
|
+
const visit = (node) => {
|
|
197
|
+
if (node !== root && isFunctionWithBody(node)) {
|
|
198
|
+
result.reaches.push(node);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node))
|
|
202
|
+
result.raises.push(...raiseSitesOf(node));
|
|
203
|
+
if (ts.isIdentifier(node))
|
|
204
|
+
result.reaches.push(...functionsReferredToBy(node));
|
|
205
|
+
ts.forEachChild(node, visit);
|
|
206
|
+
};
|
|
207
|
+
visit(root);
|
|
208
|
+
scanned.set(root, result);
|
|
209
|
+
return result;
|
|
210
|
+
};
|
|
211
|
+
/** Every raise site a handler can reach, through everything it refers to. */
|
|
212
|
+
const reachOf = (handler) => {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const stack = [handler];
|
|
215
|
+
const raises = [];
|
|
216
|
+
let functions = 0;
|
|
217
|
+
while (stack.length) {
|
|
218
|
+
const node = stack.pop();
|
|
219
|
+
if (seen.has(node))
|
|
220
|
+
continue;
|
|
221
|
+
seen.add(node);
|
|
222
|
+
if (isFunctionWithBody(node))
|
|
223
|
+
functions += 1;
|
|
224
|
+
const { raises: own, reaches } = scan(node);
|
|
225
|
+
raises.push(...own);
|
|
226
|
+
stack.push(...reaches);
|
|
227
|
+
}
|
|
228
|
+
return { raises, functions };
|
|
229
|
+
};
|
|
230
|
+
// ── Handlers ─────────────────────────────────────────────────────────
|
|
231
|
+
/** The codes a handler's `ctx.refuse` takes, or null when its context carries no typed refuse. */
|
|
232
|
+
const allowedCodesOf = (handler) => {
|
|
233
|
+
const contextNode = handler;
|
|
234
|
+
// The type the registration expects the handler to have, which holds
|
|
235
|
+
// however the handler is written (a function, one held elsewhere, a
|
|
236
|
+
// wrapper's result) and whether or not it names its context. A
|
|
237
|
+
// registration that takes a handler or an entry holding one expects a
|
|
238
|
+
// union: the function member is the handler's. A method written in an
|
|
239
|
+
// options object is read off its own type, which the registration
|
|
240
|
+
// gave it.
|
|
241
|
+
const expected = ts.isMethodDeclaration(handler) ? checker.getTypeAtLocation(handler) : checker.getContextualType(handler);
|
|
242
|
+
const members = expected?.isUnion() ? expected.types : expected ? [expected] : [];
|
|
243
|
+
const parameter = members.flatMap((member) => member.getCallSignatures())[0]?.parameters[0];
|
|
244
|
+
const contextType = parameter && checker.getTypeOfSymbolAtLocation(parameter, contextNode);
|
|
245
|
+
const refuse = contextType && checker.getPropertyOfType(contextType, "refuse");
|
|
246
|
+
if (!refuse)
|
|
247
|
+
return null;
|
|
248
|
+
const codes = new Set();
|
|
249
|
+
for (const signature of checker.getTypeOfSymbolAtLocation(refuse, contextNode).getCallSignatures()) {
|
|
250
|
+
const optionsParameter = signature.parameters[1];
|
|
251
|
+
if (!optionsParameter)
|
|
252
|
+
return null;
|
|
253
|
+
const optionsType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(optionsParameter, contextNode));
|
|
254
|
+
for (const part of optionsType.isUnion() ? optionsType.types : [optionsType]) {
|
|
255
|
+
const code = checker.getPropertyOfType(part, "code");
|
|
256
|
+
if (!code)
|
|
257
|
+
continue;
|
|
258
|
+
const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, contextNode));
|
|
259
|
+
// A handler whose endpoint declares no code is handed a refuse that takes none.
|
|
260
|
+
if (codeType.flags & ts.TypeFlags.Never)
|
|
261
|
+
continue;
|
|
262
|
+
const values = literalsOf(codeType);
|
|
263
|
+
// A refuse whose code is any string is the untyped one: it says nothing of what the handler may send.
|
|
264
|
+
if (values === null)
|
|
265
|
+
return null;
|
|
266
|
+
for (const value of values)
|
|
267
|
+
codes.add(value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return [...codes];
|
|
271
|
+
};
|
|
272
|
+
/** The codes an options object's `refusals` property names: one code or a list, each a literal or a constant typed as one. */
|
|
273
|
+
const ownRefusalsOf = (optionsNode) => {
|
|
274
|
+
const options = optionsNode && unwrap(optionsNode);
|
|
275
|
+
if (!options || !ts.isObjectLiteralExpression(options))
|
|
276
|
+
return [];
|
|
277
|
+
const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
|
|
278
|
+
if (!property || !ts.isPropertyAssignment(property))
|
|
279
|
+
return [];
|
|
280
|
+
const value = unwrap(property.initializer);
|
|
281
|
+
const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
|
|
282
|
+
return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
|
|
283
|
+
};
|
|
284
|
+
/** How a registration names its handler: `group.action` for an endpoint in a group, the key or variable it is held under otherwise, the name a mock entry is given, or the registration's own name when it is held under none. */
|
|
285
|
+
const handlerNameOf = (call, registration) => {
|
|
286
|
+
if (registration === "api" || registration === "override") {
|
|
287
|
+
const [name] = call.arguments;
|
|
288
|
+
if (name && ts.isStringLiteralLike(name))
|
|
289
|
+
return name.text;
|
|
290
|
+
}
|
|
291
|
+
const holder = call.parent;
|
|
292
|
+
const key = ts.isPropertyAssignment(holder) ? holder.name.getText()
|
|
293
|
+
: ts.isVariableDeclaration(holder) ? holder.name.getText() : null;
|
|
294
|
+
if (registration === "defineApi" && key && ts.isPropertyAssignment(holder)) {
|
|
295
|
+
const group = holder.parent.parent;
|
|
296
|
+
if (ts.isCallExpression(group) && group.arguments[1] === holder.parent && group.arguments[0] && ts.isStringLiteralLike(group.arguments[0])) {
|
|
297
|
+
return `${group.arguments[0].text}.${key}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Unnamed (inside an app's own wrapper, say): the finding's `at` says where.
|
|
301
|
+
return key ?? registration;
|
|
302
|
+
};
|
|
303
|
+
const findings = [];
|
|
304
|
+
const unchecked = [];
|
|
305
|
+
let handlers = 0;
|
|
306
|
+
const checkRegistration = (call) => {
|
|
307
|
+
const callee = call.expression;
|
|
308
|
+
const registration = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
309
|
+
const form = registration ? HANDLER_REGISTRATIONS[registration] : undefined;
|
|
310
|
+
if (!registration || !form)
|
|
311
|
+
return;
|
|
312
|
+
if (!isLambderDeclaration(checker.getResolvedSignature(call)?.declaration))
|
|
313
|
+
return;
|
|
314
|
+
const [first, second] = call.arguments;
|
|
315
|
+
const handlerOf = (holder) => {
|
|
316
|
+
if (!holder || !ts.isObjectLiteralExpression(unwrap(holder)))
|
|
317
|
+
return undefined;
|
|
318
|
+
const property = unwrap(holder).properties.find((candidate) => candidate.name?.getText() === "handler");
|
|
319
|
+
if (!property)
|
|
320
|
+
return undefined;
|
|
321
|
+
if (ts.isPropertyAssignment(property))
|
|
322
|
+
return property.initializer;
|
|
323
|
+
if (ts.isMethodDeclaration(property))
|
|
324
|
+
return property;
|
|
325
|
+
if (ts.isShorthandPropertyAssignment(property))
|
|
326
|
+
return property.name;
|
|
327
|
+
return undefined;
|
|
328
|
+
};
|
|
329
|
+
const handler = form === "second" ? second
|
|
330
|
+
: form === "optionsHandler" ? handlerOf(first)
|
|
331
|
+
: second && isFunctionWithBody(unwrap(second)) ? second : handlerOf(second) ?? second;
|
|
332
|
+
const optionsNode = form === "second" ? first : form === "optionsHandler" ? first : second;
|
|
333
|
+
if (!handler)
|
|
334
|
+
return;
|
|
335
|
+
const name = handlerNameOf(call, registration);
|
|
336
|
+
const at = where(call);
|
|
337
|
+
const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOfOrNull(optionsNode) : null);
|
|
338
|
+
if (allowed === null) {
|
|
339
|
+
unchecked.push(`${name} (${at})`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
handlers += 1;
|
|
343
|
+
const { raises, functions } = reachOf(ts.isMethodDeclaration(handler) ? handler : unwrap(handler));
|
|
344
|
+
if (functions === 0) {
|
|
345
|
+
findings.push({ handler: name, at, problem: "untraced" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const allowedSet = new Set(allowed);
|
|
349
|
+
const reached = new Set();
|
|
350
|
+
const reported = new Set();
|
|
351
|
+
for (const raise of raises) {
|
|
352
|
+
if ("code" in raise) {
|
|
353
|
+
if (raise.code.startsWith("lambder/"))
|
|
354
|
+
continue;
|
|
355
|
+
reached.add(raise.code);
|
|
356
|
+
if (!allowedSet.has(raise.code) && !reported.has(raise.code)) {
|
|
357
|
+
reported.add(raise.code);
|
|
358
|
+
findings.push({ handler: name, at, problem: "undeclared", code: raise.code, raisedAt: raise.at });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if ("uncoded" in raise) {
|
|
362
|
+
if (requireCodes)
|
|
363
|
+
findings.push({ handler: name, at, problem: "uncoded", raisedAt: raise.at });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const code of ownRefusalsOf(optionsNode)) {
|
|
370
|
+
if (!reached.has(code))
|
|
371
|
+
findings.push({ handler: name, at, problem: "unused", code });
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
/** A guard's `refusals` option, or null when it has none: what a guard built without the vocabulary may send. */
|
|
375
|
+
const ownRefusalsOfOrNull = (optionsNode) => {
|
|
376
|
+
const own = ownRefusalsOf(optionsNode);
|
|
377
|
+
return own.length ? own : null;
|
|
378
|
+
};
|
|
379
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
380
|
+
if (!isOwnFile(sourceFile))
|
|
381
|
+
continue;
|
|
382
|
+
const visit = (node) => {
|
|
383
|
+
if (ts.isCallExpression(node))
|
|
384
|
+
checkRegistration(node);
|
|
385
|
+
ts.forEachChild(node, visit);
|
|
386
|
+
};
|
|
387
|
+
visit(sourceFile);
|
|
388
|
+
}
|
|
389
|
+
const uncheckedLine = unchecked.length ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.join(", ")}`] : [];
|
|
390
|
+
if (handlers === 0) {
|
|
391
|
+
return failed(`✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`, uncheckedLine);
|
|
392
|
+
}
|
|
393
|
+
// Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
|
|
394
|
+
const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
|
|
395
|
+
const lines = [
|
|
396
|
+
unique.length === 0
|
|
397
|
+
? `✓ ${handlers} handlers reach only the refusals they may send`
|
|
398
|
+
: `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers} handlers`,
|
|
399
|
+
...unique.map(describeFinding),
|
|
400
|
+
...uncheckedLine,
|
|
401
|
+
];
|
|
402
|
+
return { ok: unique.length === 0, handlers, findings: unique, lines };
|
|
403
|
+
};
|
|
404
|
+
const describeFinding = (finding) => {
|
|
405
|
+
switch (finding.problem) {
|
|
406
|
+
case "undeclared": return ` ${finding.handler} (${finding.at}) can refuse with "${finding.code}" (${finding.raisedAt}), which it may not send: name it in its refusals, or keep the handler from reaching it`;
|
|
407
|
+
case "unused": return ` ${finding.handler} (${finding.at}) declares "${finding.code}", which nothing it reaches raises`;
|
|
408
|
+
case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
|
|
409
|
+
case "unreadable": return ` ${finding.handler} (${finding.at}) can reach a refusal whose code is not a string literal (${finding.raisedAt}), so which code it sends cannot be told`;
|
|
410
|
+
case "untraced": return ` ${finding.handler} (${finding.at}) is handed a typed refuse, but its function cannot be found (a parameter, a dependency's value), so nothing it reaches was checked`;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const failed = (line, details = []) => ({ ok: false, handlers: 0, findings: [], lines: [line, ...details] });
|
|
414
|
+
const isInside = (path, directory) => path === directory || path.startsWith(directory + sep);
|
|
415
|
+
const realPathOf = (ts, path) => ts.sys.realpath?.(path) ?? path;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The typescript package as the build tools read it: its compiler API. */
|
|
2
|
+
export type TypeScriptModule = typeof import("typescript");
|
|
3
|
+
/**
|
|
4
|
+
* The compiler API a build tool reads a project through, from the typescript
|
|
5
|
+
* package installed beside lambder. TypeScript 7 ships none, so its package
|
|
6
|
+
* answers the import without one, and an app on TypeScript 7 installs a 6.x
|
|
7
|
+
* where its generator script runs. `reader` says who reads what, for the
|
|
8
|
+
* error: "writeApiContract reads the contract".
|
|
9
|
+
*/
|
|
10
|
+
export declare const loadTypeScriptCompiler: (reader: string) => Promise<TypeScriptModule>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compiler API a build tool reads a project through, from the typescript
|
|
3
|
+
* package installed beside lambder. TypeScript 7 ships none, so its package
|
|
4
|
+
* answers the import without one, and an app on TypeScript 7 installs a 6.x
|
|
5
|
+
* where its generator script runs. `reader` says who reads what, for the
|
|
6
|
+
* error: "writeApiContract reads the contract".
|
|
7
|
+
*/
|
|
8
|
+
export const loadTypeScriptCompiler = async (reader) => {
|
|
9
|
+
const requirement = `${reader} through the TypeScript compiler API (typescript 5.4 to 6.x): install one beside lambder, such as in the generator's own package when the app is on TypeScript 7`;
|
|
10
|
+
let ts;
|
|
11
|
+
try {
|
|
12
|
+
ts = (await import("typescript")).default;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new Error(requirement, { cause: err });
|
|
16
|
+
}
|
|
17
|
+
if (typeof ts?.createProgram !== "function")
|
|
18
|
+
throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
|
|
19
|
+
return ts;
|
|
20
|
+
};
|
|
@@ -2,7 +2,27 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { dirname, resolve } from "path";
|
|
3
3
|
import { ContractTypePrinter } from "./ContractTypePrinter.js";
|
|
4
4
|
import { modulePathOf } from "./moduleLocation.js";
|
|
5
|
+
import { loadTypeScriptCompiler } from "./loadTypeScriptCompiler.js";
|
|
5
6
|
import { writeFileAtomically } from "./writeFileAtomically.js";
|
|
7
|
+
/*
|
|
8
|
+
* The contract a client compiles against, written out once as plain types.
|
|
9
|
+
*
|
|
10
|
+
* A client that imports `typeof lambder.ApiContract` from the server's
|
|
11
|
+
* sources compiles the server to get it: every endpoint's schemas, the
|
|
12
|
+
* libraries they infer through, and whatever the server's entry imports
|
|
13
|
+
* besides, and it resolves every endpoint's entry out of the declarations
|
|
14
|
+
* registerApiGroups() mapped. In a large app that is most of the client's
|
|
15
|
+
* type-check time and memory, for one type. This reads the ApiContract
|
|
16
|
+
* property off the exported instance and writes it out as the structure it
|
|
17
|
+
* resolves to, one object type with plain members in a module that imports
|
|
18
|
+
* nothing, so the client compiles the contract alone.
|
|
19
|
+
*
|
|
20
|
+
* What can go wrong is the file: stale against the server, or printed as
|
|
21
|
+
* something other than the type it stands for. A check compares it with what
|
|
22
|
+
* the server's sources print now, and a write first proves the printed type
|
|
23
|
+
* the same as the server's, entry by entry, in both directions, before the
|
|
24
|
+
* file is touched.
|
|
25
|
+
*/
|
|
6
26
|
const DEFAULT_HEADER = [
|
|
7
27
|
"Generated by writeApiContract() from lambder/build. Do not edit.",
|
|
8
28
|
"",
|
|
@@ -54,7 +74,7 @@ const DIAGNOSTIC_LIMIT = 20;
|
|
|
54
74
|
* over the old one, so a build reading it meanwhile never sees half of it.
|
|
55
75
|
*/
|
|
56
76
|
export const writeApiContract = async (options) => {
|
|
57
|
-
const ts = await
|
|
77
|
+
const ts = await loadTypeScriptCompiler("writeApiContract reads the contract");
|
|
58
78
|
const file = resolve(options.file);
|
|
59
79
|
const modulePath = modulePathOf(options.module);
|
|
60
80
|
const exportName = options.exportName ?? "default";
|
|
@@ -89,20 +109,6 @@ export const writeApiContract = async (options) => {
|
|
|
89
109
|
writeFileAtomically(file, text, previous !== null);
|
|
90
110
|
return result(true, true, [`✓ Wrote ${options.file} (${count} APIs)`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
|
|
91
111
|
};
|
|
92
|
-
/** The compiler API writeApiContract reads with. TypeScript 7 ships none, so its package answers the import without one. */
|
|
93
|
-
const loadTypeScript = async () => {
|
|
94
|
-
const requirement = "writeApiContract reads the contract through the TypeScript compiler API (typescript 5.4 to 6.x): install one beside lambder, such as in the generator's own package when the app is on TypeScript 7";
|
|
95
|
-
let ts;
|
|
96
|
-
try {
|
|
97
|
-
ts = (await import("typescript")).default;
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
throw new Error(requirement, { cause: err });
|
|
101
|
-
}
|
|
102
|
-
if (typeof ts?.createProgram !== "function")
|
|
103
|
-
throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
|
|
104
|
-
return ts;
|
|
105
|
-
};
|
|
106
112
|
/** The server's compiler options, and the root files a program over the module needs: the module, and the project's own declaration files, which may declare globals the module relies on. */
|
|
107
113
|
const readProject = (ts, tsconfig, modulePath) => {
|
|
108
114
|
const configPath = tsconfig ? resolve(tsconfig) : ts.findConfigFile(dirname(modulePath), ts.sys.fileExists);
|
package/dist/build.d.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* ships: the signature file both sides read, the declared options as plain
|
|
6
6
|
* data, and one guard's parameters as the least a browser needs of them, all
|
|
7
7
|
* from the app's own instance; and the contract a client compiles against,
|
|
8
|
-
* from the server's sources;
|
|
9
|
-
* every app a script names, in one call
|
|
8
|
+
* from the server's sources; generateApiFiles, every one of them for
|
|
9
|
+
* every app a script names, in one call; and checkApiRefusals, every
|
|
10
|
+
* refusal a handler can reach held to the codes it may send. Node-only and imported by nothing else
|
|
10
11
|
* in the package, so no deployment or bundle carries it.
|
|
11
12
|
*/
|
|
12
13
|
export { writeApiSignatures } from "./build/writeApiSignatures.js";
|
|
@@ -18,6 +19,8 @@ export type { LambderApiOptionsFileOptions, LambderApiOptionsFileResult, } from
|
|
|
18
19
|
export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
|
|
19
20
|
export type { LambderApiGuardParamsFileOptions, LambderApiGuardParamsFileResult, } from "./build/writeApiGuardParams.js";
|
|
20
21
|
export type { LambderApiOptionsSource, LambderNameChanges } from "./build/generatedTables.js";
|
|
22
|
+
export { checkApiRefusals } from "./build/checkApiRefusals.js";
|
|
23
|
+
export type { LambderApiRefusalCheckOptions, LambderApiRefusalCheckResult, LambderRefusalCheckFinding, } from "./build/checkApiRefusals.js";
|
|
21
24
|
export { generateApiFiles } from "./build/generateApiFiles.js";
|
|
22
25
|
export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesResult } from "./build/generateApiFiles.js";
|
|
23
26
|
export type { LambderModuleLocation } from "./build/moduleLocation.js";
|
package/dist/build.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* ships: the signature file both sides read, the declared options as plain
|
|
6
6
|
* data, and one guard's parameters as the least a browser needs of them, all
|
|
7
7
|
* from the app's own instance; and the contract a client compiles against,
|
|
8
|
-
* from the server's sources;
|
|
9
|
-
* every app a script names, in one call
|
|
8
|
+
* from the server's sources; generateApiFiles, every one of them for
|
|
9
|
+
* every app a script names, in one call; and checkApiRefusals, every
|
|
10
|
+
* refusal a handler can reach held to the codes it may send. Node-only and imported by nothing else
|
|
10
11
|
* in the package, so no deployment or bundle carries it.
|
|
11
12
|
*/
|
|
12
13
|
export { writeApiSignatures } from "./build/writeApiSignatures.js";
|
|
13
14
|
export { writeApiContract } from "./build/writeApiContract.js";
|
|
14
15
|
export { writeApiOptions } from "./build/writeApiOptions.js";
|
|
15
16
|
export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
|
|
17
|
+
export { checkApiRefusals } from "./build/checkApiRefusals.js";
|
|
16
18
|
export { generateApiFiles } from "./build/generateApiFiles.js";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -560,11 +560,12 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
|
|
|
560
560
|
private runApi;
|
|
561
561
|
/**
|
|
562
562
|
* A thrown LambderApiRefusal (from a hook, say) as the structured API
|
|
563
|
-
* envelope: the
|
|
564
|
-
*
|
|
565
|
-
* the refusal came before the call reached it
|
|
566
|
-
*
|
|
567
|
-
* refusal goes out
|
|
563
|
+
* envelope: the pipeline's own answer to a refusal (answerRefusal), so
|
|
564
|
+
* it is checked against the endpoint the call names, its lazy group
|
|
565
|
+
* loaded if the refusal came before the call reached it, and one saying
|
|
566
|
+
* the session is over ends it. A name no API is registered under
|
|
567
|
+
* declares no code, so only an uncoded or a framework refusal goes out
|
|
568
|
+
* for it.
|
|
568
569
|
*/
|
|
569
570
|
private apiErrorResponse;
|
|
570
571
|
}
|
package/dist/core/Lambder.js
CHANGED
|
@@ -13,12 +13,12 @@ import { LambderFiles } from "./LambderFiles.js";
|
|
|
13
13
|
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
14
14
|
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
15
15
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
16
|
-
import {
|
|
16
|
+
import { declaredRefusalVocabulary, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
17
17
|
import { refuse } from "../shared/wire/LambderApiRefusal.js";
|
|
18
18
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
19
19
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
import { assertPlainData } from "../shared/util/assertPlainData.js";
|
|
21
|
-
import { apiNotFoundAnswer, envelopeAnswer,
|
|
21
|
+
import { apiNotFoundAnswer, envelopeAnswer, sessionExpiredAnswer, successEnvelope, versionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
22
22
|
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
23
23
|
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
24
24
|
import { toGuardEntries } from "../api/LambderApiGuards.js";
|
|
@@ -1275,16 +1275,17 @@ export default class Lambder {
|
|
|
1275
1275
|
}
|
|
1276
1276
|
/**
|
|
1277
1277
|
* A thrown LambderApiRefusal (from a hook, say) as the structured API
|
|
1278
|
-
* envelope: the
|
|
1279
|
-
*
|
|
1280
|
-
* the refusal came before the call reached it
|
|
1281
|
-
*
|
|
1282
|
-
* refusal goes out
|
|
1278
|
+
* envelope: the pipeline's own answer to a refusal (answerRefusal), so
|
|
1279
|
+
* it is checked against the endpoint the call names, its lazy group
|
|
1280
|
+
* loaded if the refusal came before the call reached it, and one saying
|
|
1281
|
+
* the session is over ends it. A name no API is registered under
|
|
1282
|
+
* declares no code, so only an uncoded or a framework refusal goes out
|
|
1283
|
+
* for it.
|
|
1283
1284
|
*/
|
|
1284
1285
|
async apiErrorResponse(err, ctx) {
|
|
1285
1286
|
const apiName = ctx.apiName ?? "";
|
|
1286
|
-
const
|
|
1287
|
-
return responseFromAnswer(
|
|
1287
|
+
const refusals = (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS;
|
|
1288
|
+
return responseFromAnswer(await this.pipeline.answerRefusal(apiName, refusals, err, ctx));
|
|
1288
1289
|
}
|
|
1289
1290
|
}
|
|
1290
1291
|
/** The init bound to one vocabulary (or none): the policy builders, refuse and create() that share it. */
|
|
@@ -10,7 +10,7 @@ export type LambderApiRefusalOptions = {
|
|
|
10
10
|
refusal?: LambderUncheckedRefusalMessage | string;
|
|
11
11
|
/** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
|
|
12
12
|
notAuthorized?: boolean;
|
|
13
|
-
/** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler). */
|
|
13
|
+
/** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler); on an API call, the server also ends the session the call held. */
|
|
14
14
|
sessionExpired?: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* HTTP status of the refusal response. Default 200: the envelope is the
|
|
@@ -178,7 +178,7 @@ export type LambderRefuseOptions = {
|
|
|
178
178
|
title?: string;
|
|
179
179
|
/** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). A declared code's flag is its declaration's, and a refusal with such a code that sets it here is a crash. */
|
|
180
180
|
notAuthorized?: boolean;
|
|
181
|
-
/** Sets the envelope's sessionExpired flag. Not for a refusal with a declared code, whose flags are its declaration's. */
|
|
181
|
+
/** Sets the envelope's sessionExpired flag, and the server ends the session the call held. Not for a refusal with a declared code, whose flags are its declaration's. */
|
|
182
182
|
sessionExpired?: boolean;
|
|
183
183
|
/** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). A declared code's status is its declaration's, and a refusal with such a code that sets one here is a crash. */
|
|
184
184
|
statusCode?: LambderHttpStatusCode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.1.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
6
6
|
"keywords": [
|