lambder 13.0.1 → 14.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,88 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [14.1.1] - 2026-09-30
13
+
14
+ A minor: a refusal code can declare the `sessionExpired` flag, and
15
+ `checkApiRefusals` holds every refusal a handler can reach to the codes its
16
+ endpoint may send, which is what an app that requires codes needs to know
17
+ before a call finds out.
18
+
19
+ ### Added
20
+
21
+ - **A code declared `sessionExpired: true`** leaves with the envelope's
22
+ `sessionExpired` flag, as one declared `notAuthorized: true` leaves with
23
+ that one: for a session the pipeline found good that the handler then
24
+ finds gone (its login deleted, say). With `requireCodes`, a refusal that
25
+ meant "your session is over" had no way to carry both a code and the
26
+ flag. The declaration owns the flag, so a raise site that sets it on a
27
+ declared code is still a crash; a code may set one flag at most, since a
28
+ caller routes a refusal one way; and the flag counts in an endpoint's
29
+ signature only where a code sets it, so every existing digest stays as it
30
+ was. The mock sends it the same way.
31
+ - **`checkApiRefusals`** (`lambder/build`): reads a project through the
32
+ TypeScript compiler and, for every handler Lambder hands a typed `refuse`
33
+ (an endpoint's, a guard's, a mock entry's), follows it into every function
34
+ it can reach (a call, a method of what it constructs, a function handed
35
+ along, a module loaded with `import()`, a handler the app wraps or holds
36
+ in an object) and holds each code raised there to the codes its
37
+ `ctx.refuse` takes. It names a code a handler may not send, a declared
38
+ code nothing reaches, a refusal with no code where codes are required, a
39
+ refusal whose code is any string, and a handler whose function it cannot
40
+ find; a project in which it finds no handler to check fails rather than
41
+ passes. Before it, a shared helper's code for an endpoint that did not
42
+ declare it compiled, and crashed only when a call reached it.
43
+
44
+ ### Changed
45
+
46
+ - **A refusal that leaves an API call with the `sessionExpired` flag ends
47
+ the session the call held**, whether a declared code or the raise site set
48
+ the flag, and whether a handler, a guard or a hook threw it. The caller
49
+ stops sending a session it is told is over, and before this nothing
50
+ deleted it on the server until it expired. The record is deleted and the
51
+ cookies are left alone, as when the session read finds no session, since a
52
+ cookie deletion would also delete a session another response has just
53
+ set.
54
+
55
+ ## [14.0.1] - 2026-09-30
56
+
57
+ A major: `writeApiGuardParams` writes one export per API in place of one
58
+ table, so a browser carries only the guard parameters its screens import,
59
+ and none of the API names behind them.
60
+
61
+ ### Changed (breaking)
62
+
63
+ - **`writeApiGuardParams` writes one export per API** that declares the
64
+ guard, in place of the `guardParams` table: `orders.list` exports
65
+ `ordersListGuardParam`, holding the parameter as declared (`true` for the
66
+ guard named without one) and typed
67
+ `LambderApiGuardParam<"orders.list", "store", literal>`. A bundler keeps
68
+ only the exports a client imports, and none of them carries its API's name,
69
+ which sits in the export's type and doc comment alone, so a page no longer
70
+ ships the name of every API behind the guard and what each asks. The
71
+ module imports the tag type from `lambder/client`, as a type. Two APIs
72
+ whose names would export under one identifier fail the write, and so does
73
+ a `null` parameter, which the tag cannot carry; a guard no API declares
74
+ writes an empty module. A file still in the table form reads as one to
75
+ regenerate, on a check and on a write.
76
+ - To move: regenerate the file, and import the exports a screen gates on
77
+ in place of reading `guardParams[name]`. A function that takes any of a
78
+ guard's parameters reads their union off the module with a type-only
79
+ import (`(typeof params)[keyof typeof params]`, over
80
+ `import type * as params`). Code that needs every API's name, a test
81
+ walking them all, reads it from the `writeApiOptions` file.
82
+
83
+ ### Added
84
+
85
+ - **`LambderApiGuardParam`** (`lambder/client`, `lambder`): the type of each
86
+ export `writeApiGuardParams` writes, a guard's parameter tagged in types
87
+ alone with the API's name and the guard's, so a function typed to a
88
+ guard's parameters refuses a hand-written literal.
89
+ - **`apiGuardParamExportName`** (`lambder/client`, `lambder`): the identifier
90
+ `writeApiGuardParams` exports an API's parameter under, for code that reads
91
+ a generated module by API name, such as a test comparing it with the
92
+ server.
93
+
12
94
  ## [13.0.1] - 2026-09-30
13
95
 
14
96
  A major: session data is checked against a schema on every read, and
package/README.md CHANGED
@@ -140,7 +140,7 @@ The package ships five entry points; pick by where the code runs:
140
140
  | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `LambderUploadRunner`, `LambderBackoffTimer`, `LambderSignedClaims`, `html`/`xml` tagged templates, `createLambderI18n` |
141
141
  | `lambder/mock` | Browser and Node, in development and tests | `LambderMockApp`, the mock runtime: your typed contract served from mock handlers over the real API pipeline and memory stores |
142
142
  | `lambder/testing` | Node, in tests | `lambderTestApp`: your real instance under test in this process, memory stores put under it in place, simulated browsers with typed callers in front of it, and the outcome assertions; the store conformance suites, to hold a store you write to the rules Lambder's own meet |
143
- | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server; `generateApiFiles`: every one of them, for every app a script names, in one call |
143
+ | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters, one export per API and nothing else, so a browser gating on that guard carries only the ones it imports; `writeApiContract`: the contract as plain types a client compiles instead of the server; `generateApiFiles`: every one of them, for every app a script names, in one call |
144
144
 
145
145
  Frontends and shared isomorphic packages should import from `lambder/client`
146
146
  only; the entry's module graph contains no AWS SDK, Node built-ins, or server
@@ -5,6 +5,8 @@ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
5
5
  import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
6
6
  import type { LambderApiDefinition } from "./LambderApiDefinition.js";
7
7
  import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
8
+ import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
9
+ import { type LambderEndpointRefusals } from "./LambderApiRefusals.js";
8
10
  import { type LambderApiGuard } from "./LambderApiGuards.js";
9
11
  import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitChargeResult, type LambderRateLimitChargeSubject } from "./LambderApiRateLimits.js";
10
12
  import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
@@ -107,8 +109,12 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
107
109
  * validation error through onInvalidInput, any other refusal as the refusal
108
110
  * envelope once checked against the endpoint's declared codes
109
111
  * (checkedRefusal), and a session ended while the handler held it
110
- * (LambderSessionNotFoundError) as sessionExpired. 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>;