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 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. Anything else propagates, because only the adapter knows what a
111
- * crash means (a global error handler, a mock event).
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. Anything else propagates, because only the adapter knows what a
41
- * crash means (a global error handler, a mock event).
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 = refusalAnswer(checkedRefusal(definition.name, definition.refusals, err), this.apiVersion, ctx.logList);
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 flag are declared once here rather than at each raise site,
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 notAuthorized (a `dat:` would leave
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 notAuthorized
158
- * flag, and with data exactly when the code carries data, parsed through
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`, when present, is
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 flag are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
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 notAuthorized
99
- * flag, and with data exactly when the code carries data, parsed through
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 flag, and the data as parsed: how every
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`, when present, is
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 notAuthorized, which a refusal declaration does not take.`);
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
- if (declaration.notAuthorized !== undefined && declaration.notAuthorized !== true) {
232
- throw new Error(`Lambder: the refusal "${code}" declares notAuthorized as ${String(declaration.notAuthorized)}; write true, or leave it off.`);
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 notAuthorized flag. Anything
22
- * else (rate limits, a guard's parameter, the handler) changes nothing for a
23
- * client and is left out, so changing it never forces a reload.
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 flag count too: a caller reads the one
31
- * and routes on the other.
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 notAuthorized flag. Anything
60
- * else (rate limits, a guard's parameter, the handler) changes nothing for a
61
- * client and is left out, so changing it never forces a reload.
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 flag count too: a caller reads the one
69
- * and routes on the other.
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 loadTypeScript();
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; and generateApiFiles, every one of them for
9
- * every app a script names, in one call. Node-only and imported by nothing else
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; and generateApiFiles, every one of them for
9
- * every app a script names, in one call. Node-only and imported by nothing else
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";
@@ -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 core's one mapping, after the same check the pipeline
564
- * applies, against the endpoint the call names, its lazy group loaded if
565
- * the refusal came before the call reached it. A name no API is
566
- * registered under declares no code, so only an uncoded or a framework
567
- * refusal goes out for it.
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
  }
@@ -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 { checkedRefusal, declaredRefusalVocabulary, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
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, refusalAnswer, sessionExpiredAnswer, successEnvelope, versionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
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 core's one mapping, after the same check the pipeline
1279
- * applies, against the endpoint the call names, its lazy group loaded if
1280
- * the refusal came before the call reached it. A name no API is
1281
- * registered under declares no code, so only an uncoded or a framework
1282
- * refusal goes out for it.
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 refusal = checkedRefusal(apiName, (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS, err);
1287
- return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
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.0.1",
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": [