lambder 11.1.1 → 13.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +25 -15
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  5. package/dist/api/LambderApiDeclarations.js +52 -0
  6. package/dist/api/LambderApiDefinition.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +4 -1
  8. package/dist/api/LambderApiGuards.d.ts +21 -22
  9. package/dist/api/LambderApiGuards.js +1 -1
  10. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  11. package/dist/api/LambderApiRateLimits.js +1 -1
  12. package/dist/api/LambderApiRefusals.d.ts +22 -0
  13. package/dist/api/LambderApiRefusals.js +13 -0
  14. package/dist/api/LambderApiRequest.d.ts +15 -6
  15. package/dist/api/LambderApiRequest.js +8 -9
  16. package/dist/build/generateApiFiles.d.ts +60 -0
  17. package/dist/build/generateApiFiles.js +85 -0
  18. package/dist/build/generatedTables.d.ts +2 -1
  19. package/dist/build/generatedTables.js +1 -1
  20. package/dist/build.d.ts +4 -1
  21. package/dist/build.js +3 -1
  22. package/dist/client/LambderCaller.d.ts +36 -23
  23. package/dist/client/LambderCaller.js +88 -67
  24. package/dist/client/lambderFetchTransport.js +2 -1
  25. package/dist/client.d.ts +3 -1
  26. package/dist/client.js +2 -0
  27. package/dist/core/Lambder.d.ts +232 -92
  28. package/dist/core/Lambder.js +400 -103
  29. package/dist/core/LambderCallSummary.d.ts +43 -0
  30. package/dist/core/LambderCallSummary.js +10 -0
  31. package/dist/core/LambderContext.d.ts +15 -2
  32. package/dist/core/LambderContext.js +32 -9
  33. package/dist/core/LambderCreateOptions.d.ts +78 -64
  34. package/dist/core/LambderCreateOptions.js +39 -21
  35. package/dist/core/LambderInvocationScope.d.ts +25 -0
  36. package/dist/core/LambderInvocationScope.js +21 -0
  37. package/dist/core/LambderResponse.d.ts +11 -0
  38. package/dist/core/LambderResponse.js +12 -0
  39. package/dist/core/LambderResponseBuilder.js +5 -1
  40. package/dist/index.d.ts +13 -7
  41. package/dist/index.js +3 -1
  42. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  43. package/dist/invoke/LambderInvokeCaller.js +110 -94
  44. package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
  45. package/dist/invoke/LambderLambdaEvent.js +8 -3
  46. package/dist/invoke/lambderHandlerTransport.js +2 -1
  47. package/dist/mock/LambderMockApp.d.ts +39 -33
  48. package/dist/mock/LambderMockApp.js +66 -55
  49. package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
  50. package/dist/mock/LambderMockTypes.d.ts +44 -7
  51. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  52. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  53. package/dist/mock/lambderMockMswHandler.js +8 -4
  54. package/dist/mock.d.ts +2 -2
  55. package/dist/session/LambderSessionManager.d.ts +46 -3
  56. package/dist/session/LambderSessionManager.js +62 -11
  57. package/dist/shared/LambderI18n.js +49 -4
  58. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  59. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  60. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  61. package/dist/shared/transport/LambderApiTransport.js +2 -3
  62. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  63. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  64. package/dist/shared/util/LambderInvocationScope.js +5 -0
  65. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  66. package/dist/shared/util/LambderNamedMaps.js +19 -0
  67. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  68. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  69. package/dist/shared/wire/LambderApiContract.js +1 -1
  70. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  71. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  72. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  73. package/dist/shared/wire/LambderApiNames.js +81 -0
  74. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  75. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  76. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  77. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  78. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  79. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  80. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  81. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  82. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  83. package/dist/testing/LambderTestApp.d.ts +18 -5
  84. package/dist/testing/LambderTestApp.js +21 -5
  85. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  86. package/dist/testing/LambderTestVisitor.js +33 -25
  87. package/package.json +1 -1
@@ -13,47 +13,66 @@ 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, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
16
+ import { checkedRefusal, 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, } from "../api/LambderApiEnvelope.js";
21
+ import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, 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";
25
+ import { buildApiGroup, buildLazyApiGroup, isRegistrableApiGroup, } from "../api/LambderApiDeclarations.js";
26
+ import { splitApiName } from "../shared/wire/LambderApiNames.js";
25
27
  import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
26
28
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
27
29
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
28
30
  import { LambderCrashHandling } from "./LambderCrashHandling.js";
31
+ import { currentInvocation, runInvocation } from "./LambderInvocationScope.js";
32
+ import { roundedMilliseconds, writeCallSummaryLine } from "./LambderCallSummary.js";
33
+ import { outcomeOfAnswerText } from "../shared/wire/LambderCallOutcome.js";
34
+ import { LAMBDER_INVOKE_API_ID, LAMBDER_PARENT_REQUEST_HEADER } from "../shared/wire/LambderInvokeApiId.js";
29
35
  import { policyBuildersFor } from "./LambderPolicyBuilders.js";
30
36
  import { assertCreateOptions, mergeNamedMaps, } from "./LambderCreateOptions.js";
31
37
  /** The refusals of a name no API is registered under: no code, and none required. */
32
38
  const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
39
+ /** Longest endpoint name a call summary carries: a page built before paths posts the name in its body, and a line is no place for a long one. */
40
+ const MAX_SUMMARY_API_NAME_CHARS = 200;
41
+ /** A request id as the parent header may carry one: what Lambda's own ids look like, and no longer than any of them. */
42
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
43
+ /**
44
+ * The request id of the invocation that invoked this one, from an invoke's
45
+ * event alone: over HTTP the header is whatever a client wrote, and an
46
+ * invoke's apiId is one no gateway lets a client set.
47
+ */
48
+ const parentRequestIdOf = (event) => {
49
+ if (event.requestContext?.apiId !== LAMBDER_INVOKE_API_ID)
50
+ return null;
51
+ const value = Object.entries(event.headers ?? {}).find(([name]) => name.toLowerCase() === LAMBDER_PARENT_REQUEST_HEADER)?.[1];
52
+ return typeof value === "string" && REQUEST_ID_PATTERN.test(value) ? value : null;
53
+ };
54
+ /** The API call a request is, as its invocation records it; null for a request that is no API call. */
55
+ const apiCallOf = (ctx, event) => ctx.api ? { api: ctx.api.apiName, parentRequestId: parentRequestIdOf(event) } : null;
33
56
  /**
34
57
  * Main Lambder class for building type-safe serverless APIs. Create
35
58
  * instances with initLambder<SessionData>().create({...}) (see below): the
36
59
  * whole configuration, including the typed policy layer, is given at
37
- * construction, and only registration (routes, apis, hooks, use) chains.
60
+ * construction. Endpoints are declared as values on the instance
61
+ * (defineApi, defineApiGroup) and registered in one registerApiGroups()
62
+ * call; routes, hooks and actions chain.
38
63
  *
39
- * @typeParam TSessionData - Type of session data stored in DynamoDB
40
- * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
41
- * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
42
- * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
43
- * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
44
- * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
45
- * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
46
- * @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). Defaults to true, unlike its siblings, so a plugin annotating its parameter as the bare Lambder<SessionData> can still register session APIs. create() knows the option and supplies the false; `new Lambder(...)` relies on the registration-time throw alone.
47
- * @typeParam _TRefusals - @internal The refusal vocabulary declareRefusals() gave the init (do not pass manually)
48
- * @typeParam _TRefusalCodesRequired - @internal True when declareRefusals() received requireCodes (do not pass manually)
64
+ * @typeParam TApp - @internal What create() configured, as LambderAppTypes: the session data, policies, guards, idempotency, the guard requirement, sessions and the refusal vocabulary (do not pass manually)
65
+ * @typeParam _TContract - @internal The contract of the groups registered so far (do not pass manually)
49
66
  *
50
67
  * @example
51
68
  * ```typescript
52
69
  * interface SessionData { userId: string; role: string; }
53
70
  *
54
- * const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
55
- * .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
56
- * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
71
+ * const app = initLambder<SessionData>().create({ apiPath: '/api', session, guards });
72
+ * const userApis = app.defineApiGroup("users", {
73
+ * get: app.defineApi({ input: z.object({...}), output: z.object({...}), guards: "signedIn" }, handler),
74
+ * });
75
+ * const lambder = app.registerApiGroups(userApis);
57
76
  * ```
58
77
  */
59
78
  export default class Lambder {
@@ -70,24 +89,35 @@ export default class Lambder {
70
89
  * Type property for extracting the API contract: every registered API's
71
90
  * input, output, mode and declared options, as a client calls it.
72
91
  *
73
- * A small app's client imports it as it is. It is an intersection one
74
- * member deep per endpoint, so reading it generically costs a large app's
75
- * client most of its type check; writeApiContract (lambder/build) reads
76
- * this property off the exported instance and writes the contract out as
77
- * plain types for such a client to import instead.
92
+ * A small app's client imports it as it is. A large app's client reads
93
+ * the plain types writeApiContract (lambder/build) prints from this
94
+ * property instead, which cost it nothing to resolve.
78
95
  *
79
96
  * @example
80
97
  * ```typescript
81
- * export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
98
+ * export const lambder = lambderApp.registerApiGroups(userApis, orderApis);
82
99
  * export type ApiContractType = typeof lambder.ApiContract;
83
100
  * ```
84
101
  */
85
102
  ApiContract;
103
+ /**
104
+ * Type property for what create() configured (LambderAppTypes): the
105
+ * session data, policies, guards and the rest, for code that takes any
106
+ * instance and needs one of them (lambderTestApp reads the session data
107
+ * type here). Like ApiContract, it has no value.
108
+ */
109
+ AppTypes;
86
110
  actionList = [];
87
111
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
88
112
  pipeline;
89
- /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
113
+ /** Every registered API by name: what dispatch, apiSignatures() and apiOptionEntries() read. */
90
114
  apiDefinitions = new Map();
115
+ /** What answers each registered API: its output schema, compression setting and handler. */
116
+ apiHandlers = new Map();
117
+ /** Every group name registered, lazy or not: the duplicate-group check. */
118
+ apiGroupNames = new Set();
119
+ /** The lazy groups not loaded yet, each with its load in progress, if one is. */
120
+ lazyApiGroups = new Map();
91
121
  /** The guards given at creation, merged into one map, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
92
122
  guards;
93
123
  /** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
@@ -109,7 +139,8 @@ export default class Lambder {
109
139
  eventActionList = [];
110
140
  corsConfig = null;
111
141
  finalizeOptions;
112
- requireSessionApiGuards;
142
+ /** Whether every endpoint has to declare guards (create()'s requireApiGuards). */
143
+ requireApiGuards;
113
144
  /** Whether every refusal an API answers with has to name a code (declareRefusals's requireCodes). */
114
145
  requireRefusalCodes;
115
146
  /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
@@ -120,7 +151,9 @@ export default class Lambder {
120
151
  contextTools;
121
152
  trustedClientIpHeaders;
122
153
  trustedHostHeaders;
123
- requirePublicApiGuards;
154
+ originProof;
155
+ /** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
156
+ callSummaryWriter;
124
157
  constructor(given = {}) {
125
158
  // The guards and the rate-limit policies as one map each, whether
126
159
  // they were given as one or as a list; a name two maps declare
@@ -165,7 +198,9 @@ export default class Lambder {
165
198
  sessionSalt: session.sessionSalt,
166
199
  enableSlidingExpiration: session.enableSlidingExpiration,
167
200
  slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
168
- dataRefresh: session.dataRefresh,
201
+ // As given: the manager refuses dataRefresh without
202
+ // the schema, which an untyped caller can still pass.
203
+ ...{ dataRefresh: session.dataRefresh, dataSchema: session.dataSchema },
169
204
  crypto: session.crypto,
170
205
  }),
171
206
  tokenCookieKey: session.tokenCookieKey,
@@ -179,19 +214,21 @@ export default class Lambder {
179
214
  });
180
215
  this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
181
216
  this.trustedHostHeaders = options.trustedHostHeaders ?? [];
182
- this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
217
+ this.originProof = options.originProof ?? null;
218
+ this.callSummaryWriter = options.callSummary === false ? null : options.callSummary ?? writeCallSummaryLine;
219
+ this.requireApiGuards = options.requireApiGuards ?? false;
183
220
  this.requireRefusalCodes = options.requireRefusalCodes ?? false;
184
- this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
185
221
  this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
186
222
  this.contextTools = {
187
223
  sessionControllerFor: (ctx) => this.getSessionController(ctx),
188
224
  chargeRateLimit: async (ctx, policy, key, refuse) => {
225
+ // A per-API budget counts per registered API. The posted name
226
+ // of a call no API matched (a hook or the fallback charging
227
+ // it) is the caller's choice, and a fresh name per request
228
+ // would be a fresh counter.
229
+ const apiName = ctx.api && await this.registeredDefinitionOf(ctx.api.apiName) ? ctx.api.apiName : null;
189
230
  const { checkResult, refusal: refusalToThrow } = await this.pipeline.chargeRateLimit(policy, {
190
- // A per-API budget counts per registered API. The posted
191
- // name of a call no API matched (a hook or the fallback
192
- // charging it) is the caller's choice, and a fresh name
193
- // per request would be a fresh counter.
194
- apiName: ctx.api && this.apiDefinitions.has(ctx.api.apiName) ? ctx.api.apiName : null,
231
+ apiName,
195
232
  ip: ctx.ip,
196
233
  session: ctx.session,
197
234
  key,
@@ -287,45 +324,196 @@ export default class Lambder {
287
324
  });
288
325
  return this;
289
326
  }
290
- // Typed API with Zod
291
- addApi(name, schema,
327
+ // =====================================================================
328
+ // Endpoints
329
+ // Declared as values, gathered into named groups, registered in one call.
330
+ // =====================================================================
331
+ /**
332
+ * Declares one endpoint, typed on this instance's types: its input and
333
+ * output schemas, its guards, rate limits, idempotency and refusals, and
334
+ * its handler. Nothing is registered until the declaration is put in a
335
+ * group (defineApiGroup) and the group is registered (registerApiGroups).
336
+ *
337
+ * The guards say who may call it, and so its mode: an endpoint whose
338
+ * guards include one that needs a session (`session: true`) is a session
339
+ * endpoint, its session read before the guards run and a call without
340
+ * one answered sessionExpired, and its handler's ctx.session is typed
341
+ * present. Any other endpoint is public.
342
+ *
343
+ * An arrow property rather than a method, so an app can hand it out
344
+ * detached: `export const { defineApi, defineApiGroup } = lambderApp;`.
345
+ */
346
+ defineApi = (options,
292
347
  /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
293
- handler) {
294
- this.registerApi(name, "public", schema, handler);
295
- return this;
348
+ handler) => ({ kind: "lambderApi", options: options, handler: handler });
349
+ /**
350
+ * Gathers endpoints into a named group: each is registered as
351
+ * `name.action` and called at `{apiPath}/{name}/{action}`, and every
352
+ * Lambder caller reaches it as `caller.name.action(input)`. A group's
353
+ * name and its actions are identifiers, and a group may not take a name
354
+ * a caller already answers for (see LAMBDER_RESERVED_GROUP_NAMES).
355
+ *
356
+ * A group declared across files takes each file's part:
357
+ * `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action
358
+ * two parts declare is refused, at compile time and at startup, rather
359
+ * than one silently replacing the other as an object spread would.
360
+ */
361
+ defineApiGroup = (name, ...parts) => buildApiGroup(name, parts);
362
+ /**
363
+ * A group loaded on the first call to one of its endpoints:
364
+ * `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
365
+ * A cold start then parses none of it, nor anything only it imports,
366
+ * until a request calls it; the contract is the loaded group's, read off
367
+ * its type. The group loaded must carry the same name, which the type
368
+ * checks and loading checks again. `loadApiGroups()` loads every group,
369
+ * for a build step or a boot check that has to see them all.
370
+ */
371
+ lazyApiGroup = (name, load) => buildLazyApiGroup(name, load);
372
+ /**
373
+ * Registers groups of endpoints, as one contract: `typeof
374
+ * lambder.ApiContract` then maps every `group.action` to its entry, one
375
+ * flat object type the api-contract generator prints as it is. Register
376
+ * every group in one call where you can: the contract of one call is one
377
+ * mapped type, and a second call intersects it with the first.
378
+ *
379
+ * The call's groups take their place in the first-match chain here, as
380
+ * a route does: a route or action registered before them sees their calls
381
+ * first, one registered after them never does. The beforeRender hooks run
382
+ * before a lazy group loads, so a hook that answers a request spares it
383
+ * the import; one that throws a refusal or charges a per-API budget loads
384
+ * it (see registeredDefinitionOf).
385
+ *
386
+ * Refused at compile time: a group typed `any`, or a lazy one loading a
387
+ * group typed `any`, or a group whose endpoints are (its endpoints would
388
+ * be `any` to every client), and a group name given twice. Refused at
389
+ * registration, which is at startup for a group and at its first call for
390
+ * a lazy one: everything registration refuses of one endpoint (an unknown
391
+ * guard or policy, a missing guard where requireApiGuards is on, a
392
+ * refusal code outside the vocabulary).
393
+ */
394
+ registerApiGroups(...groups) {
395
+ const instance = this;
396
+ // Every group and every endpoint is checked before anything is
397
+ // claimed, so a registration refused anywhere leaves the instance as
398
+ // it was: no group name taken, no endpoint half registered, and a
399
+ // caller that catches the error and fixes the declaration is told the
400
+ // real problem on its next try.
401
+ const names = new Set();
402
+ const prepared = [];
403
+ for (const group of groups) {
404
+ if (!isRegistrableApiGroup(group)) {
405
+ throw new Error("Lambder: registerApiGroups takes groups built by defineApiGroup() or lazyApiGroup().");
406
+ }
407
+ if (instance.apiGroupNames.has(group.name) || names.has(group.name)) {
408
+ throw new Error(`Lambder: group "${group.name}" is registered twice. A group is one namespace, so its endpoints belong in one defineApiGroup().`);
409
+ }
410
+ names.add(group.name);
411
+ if (group.kind === "lambderApiGroup")
412
+ prepared.push(...instance.prepareApiGroup(group));
413
+ }
414
+ for (const group of groups) {
415
+ instance.apiGroupNames.add(group.name);
416
+ if (group.kind === "lambderLazyApiGroup")
417
+ instance.lazyApiGroups.set(group.name, { group, loading: null });
418
+ }
419
+ for (const api of prepared)
420
+ instance.commitApi(api);
421
+ instance.actionList.push({
422
+ match: (ctx) => {
423
+ const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
424
+ return group !== undefined && names.has(group) ? {} : false;
425
+ },
426
+ actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
427
+ });
428
+ return instance;
296
429
  }
297
- // Typed Session API with Zod
298
- addSessionApi(name, schema,
299
- /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
300
- handler) {
301
- this.registerApi(name, "session", schema, handler);
302
- return this;
430
+ /**
431
+ * Loads every lazy group not loaded yet, registering its endpoints: what
432
+ * a build step digesting the signatures, a generator writing the options
433
+ * or a boot check has to do before it sees every endpoint. A group whose
434
+ * registration fails rejects here, which is the point of calling it.
435
+ */
436
+ async loadApiGroups() {
437
+ for (const name of [...this.lazyApiGroups.keys()])
438
+ await this.loadLazyApiGroup(name);
439
+ }
440
+ /**
441
+ * An API call to a registered group, its hooks run: its group loaded
442
+ * first when it is a lazy one no call has reached yet, then its endpoint
443
+ * run. An action the group does not have is answered as any unmatched API
444
+ * call is, fallback hooks first.
445
+ */
446
+ async dispatchApiCall(ctx, resolver) {
447
+ const apiName = ctx.api.apiName;
448
+ const definition = await this.registeredDefinitionOf(apiName);
449
+ const endpoint = this.apiHandlers.get(apiName);
450
+ if (!definition || !endpoint)
451
+ return await this.answerUnmatched(ctx, resolver);
452
+ return await this.runApi(ctx, definition, endpoint.output, endpoint.compress, endpoint.handler);
453
+ }
454
+ /**
455
+ * The definition of the endpoint a call names, its group loaded first
456
+ * when it is a lazy one no call has reached yet; undefined for a name no
457
+ * registered group declares. Everything that reads an endpoint's
458
+ * declarations for a call asks here (its dispatch, a refusal a hook
459
+ * throws on the way to it, a per-API budget a hook charges), so a lazy
460
+ * group answers each of them as an eager one does.
461
+ */
462
+ async registeredDefinitionOf(apiName) {
463
+ const group = splitApiName(apiName)?.group;
464
+ if (group !== undefined && this.lazyApiGroups.has(group))
465
+ await this.loadLazyApiGroup(group);
466
+ return this.apiDefinitions.get(apiName);
467
+ }
468
+ /** The endpoints of one group, each checked as `group.action`; nothing is registered until they are committed. */
469
+ prepareApiGroup(group) {
470
+ return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler));
303
471
  }
304
472
  /**
305
- * What registering an API is, for addApi and addSessionApi alike: the
306
- * checks that can refuse it, then its definition recorded (what
307
- * apiSignatures() digests) and its action appended to the first-match
308
- * chain. The two public methods differ only in the mode and in the types
309
- * they give the handler.
473
+ * A lazy group, loaded once: its import, checked to be the group it was
474
+ * registered as, then registered. Concurrent first calls share one load;
475
+ * a failed load is forgotten, so the next call tries again rather than
476
+ * answering every later call with the first failure.
310
477
  */
311
- registerApi(name, mode, schema, handler) {
312
- if (this.apiDefinitions.has(name)) {
313
- throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
478
+ async loadLazyApiGroup(name) {
479
+ const lazy = this.lazyApiGroups.get(name);
480
+ if (!lazy)
481
+ return;
482
+ if (!lazy.loading) {
483
+ const loading = (async () => {
484
+ const loaded = await lazy.group.load();
485
+ if (!isRegistrableApiGroup(loaded) || loaded.kind !== "lambderApiGroup") {
486
+ throw new Error(`Lambder: the loader of lazy group "${name}" resolved to something that is not a group. Resolve to the value defineApiGroup() built.`);
487
+ }
488
+ if (loaded.name !== name) {
489
+ throw new Error(`Lambder: lazy group "${name}" loaded the group "${loaded.name}". The loader must resolve to the group of the name it was registered under.`);
490
+ }
491
+ for (const api of this.prepareApiGroup(loaded))
492
+ this.commitApi(api);
493
+ this.lazyApiGroups.delete(name);
494
+ })();
495
+ lazy.loading = loading;
496
+ loading.catch(() => { if (lazy.loading === loading)
497
+ lazy.loading = null; });
314
498
  }
315
- // Everything that can refuse the registration runs before the name is
316
- // claimed below: a refusal the app catches and fixes would otherwise
317
- // leave the name taken, and the retry would report a duplicate
318
- // instead of the problem it was fixing.
499
+ await lazy.loading;
500
+ }
501
+ /**
502
+ * What registering an endpoint takes: the checks that can refuse it and
503
+ * its mode read off its guards, into the definition apiSignatures()
504
+ * digests, beside its handler. Nothing is recorded here (commitApi does
505
+ * that), so a refusal anywhere in a registration leaves nothing behind.
506
+ */
507
+ prepareApi(name, schema, handler) {
508
+ const guardEntries = toGuardEntries(schema.guards);
509
+ const guardNeedsSession = (guard) => !!this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) && this.guards[guard]?.session === true;
510
+ const mode = guardEntries.some(({ name: guard }) => guardNeedsSession(guard)) ? "session" : "public";
319
511
  if (mode === "session" && !this.pipeline.hasSessions) {
320
- throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
512
+ throw new Error(`Lambder: a guard of API "${name}" needs a session, and the instance was created without the session option.`);
321
513
  }
322
- const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
323
- if (guardsRequired && schema.guards === undefined) {
324
- const optOut = mode === "session"
325
- ? "the named no-op guard that marks the session itself as the whole authorization"
326
- : "the named no-op guard that records why anyone may call it";
327
- throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
328
- `Declare the guard that authorizes it, or ${optOut}.`);
514
+ if (this.requireApiGuards && schema.guards === undefined) {
515
+ throw new Error(`Lambder: API "${name}" declares no guards, and requireApiGuards is on. ` +
516
+ `Declare the guard that authorizes it, or the named no-op guard that records why it needs nothing more.`);
329
517
  }
330
518
  // The option, like guards and rateLimit, is a declaration or absent:
331
519
  // an empty list would read as declaring codes while declaring none.
@@ -333,20 +521,22 @@ export default class Lambder {
333
521
  if (schema.refusals !== undefined && ownRefusals.length === 0) {
334
522
  throw new Error(`Lambder: API "${name}" declares an empty refusals option, which declares no code. Name the codes it refuses with, or omit the option entirely.`);
335
523
  }
336
- const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, toGuardEntries(schema.guards).map(({ name: guard }) => ({
524
+ const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({
337
525
  guard,
338
526
  codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
339
527
  })));
340
528
  const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
341
529
  const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
342
530
  this.pipeline.assertRegistration(definition);
343
- this.apiDefinitions.set(name, definition);
344
- if (schema.refusals !== undefined)
345
- this.refusalOptions.set(name, schema.refusals);
346
- this.actionList.push({
347
- match: (ctx) => ctx.apiName === name ? {} : false,
348
- actionFn: (ctx) => this.runApi(ctx, definition, schema.output, schema.compress ?? "auto", handler),
349
- });
531
+ return { definition, refusalsOption: schema.refusals, output: schema.output, compress: schema.compress ?? "auto", handler };
532
+ }
533
+ /** Records a checked endpoint: its definition, its refusals option as written, and what answers it. */
534
+ commitApi(api) {
535
+ const { name } = api.definition;
536
+ this.apiDefinitions.set(name, api.definition);
537
+ if (api.refusalsOption !== undefined)
538
+ this.refusalOptions.set(name, api.refusalsOption);
539
+ this.apiHandlers.set(name, { output: api.output, compress: api.compress, handler: api.handler });
350
540
  }
351
541
  addHook(hookEvent, hookFn, priority = 0) {
352
542
  if (hookEvent === "created") {
@@ -378,16 +568,15 @@ export default class Lambder {
378
568
  });
379
569
  return this;
380
570
  }
381
- // Plugin system
382
- // The policy generics are `any` in the plugin signature on purpose: a
383
- // module may annotate its parameter as the bare Lambder<SessionData> or
384
- // as the app's narrowed alias, and both must chain. Registration-time
385
- // assertions still check every referenced policy/guard name. Every
386
- // policy generic must be listed: a missing one falls back to its default,
387
- // making an instance with a non-default value unassignable to its own
388
- // plugins.
571
+ /**
572
+ * Hands the instance to a function that registers on it (routes, hooks,
573
+ * actions) and continues the chain with what it returns. Endpoints are
574
+ * not registered this way: they are values, registered by
575
+ * registerApiGroups(), so a plugin adds nothing to the contract.
576
+ */
389
577
  use(plugin) {
390
- return plugin(this);
578
+ plugin(this);
579
+ return this;
391
580
  }
392
581
  // =====================================================================
393
582
  // Accessors
@@ -418,6 +607,8 @@ export default class Lambder {
418
607
  [LAMBDER_BACKEND_SWAP](backends) {
419
608
  if (this.files && backends.fileSource)
420
609
  this.files[LAMBDER_BACKEND_SWAP](backends.fileSource);
610
+ if (backends.callSummary !== undefined)
611
+ this.callSummaryWriter = backends.callSummary;
421
612
  return { ...this.pipeline[LAMBDER_BACKEND_SWAP](backends), files: this.files !== null };
422
613
  }
423
614
  /**
@@ -451,6 +642,7 @@ export default class Lambder {
451
642
  * unless the generator writes it there.
452
643
  */
453
644
  async apiSignatureEntries() {
645
+ await this.loadApiGroups();
454
646
  const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
455
647
  name: definition.name,
456
648
  key: await apiNameKeyOf(definition.name),
@@ -478,7 +670,8 @@ export default class Lambder {
478
670
  * same object. Every table is sorted by name, so the module diffs by
479
671
  * endpoint and never moves when registrations are reordered.
480
672
  */
481
- apiOptionEntries() {
673
+ async apiOptionEntries() {
674
+ await this.loadApiGroups();
482
675
  const apis = {};
483
676
  for (const name of [...this.apiDefinitions.keys()].sort()) {
484
677
  const { mode, guards, rateLimit, idempotency } = this.apiDefinitions.get(name);
@@ -624,11 +817,22 @@ export default class Lambder {
624
817
  const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
625
818
  if (beforeRenderResult instanceof LambderResponse)
626
819
  return beforeRenderResult;
627
- const currentCtx = beforeRenderResult;
820
+ return await this.answerUnmatched(beforeRenderResult, resolver);
821
+ }
822
+ /**
823
+ * What answers a request nothing matched, its beforeRender hooks run:
824
+ * the fallback hooks, then the API fallback for an API call, and
825
+ * otherwise the public files, the shell and the route fallback in turn.
826
+ */
827
+ async answerUnmatched(currentCtx, resolver) {
628
828
  for (const hook of this.hookList["fallback"]) {
629
829
  await hook.hookFn(currentCtx, resolver);
630
830
  }
631
- const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath;
831
+ // A request under apiPath is the API's to answer, whatever it asked
832
+ // for. A root apiPath shares every path with the site, so there only
833
+ // apiPath itself and the calls are.
834
+ const apiArea = this.apiPath.replace(/\/+$/, "");
835
+ const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath || (apiArea !== "" && currentCtx.path.startsWith(`${apiArea}/`));
632
836
  if (isAPI) {
633
837
  if (this.apiFallbackHandler)
634
838
  return await this.apiFallbackHandler(currentCtx, resolver);
@@ -661,6 +865,10 @@ export default class Lambder {
661
865
  if (this.isCorsPreflight(ctx))
662
866
  return new LambderResponse({ statusCode: 204, body: null });
663
867
  if (ctx.api) {
868
+ // A page built before endpoints had paths: its call cannot be
869
+ // answered, and telling it its version expired is what reloads it.
870
+ if (ctx.api.retiredPath)
871
+ return responseFromAnswer(versionExpiredAnswer(this.apiVersion));
664
872
  // The protocol's own pre-pass, run here rather than left to the
665
873
  // pipeline so that hooks and route matching see a plain payload,
666
874
  // and so a stale client is answered before any of them, whether or
@@ -694,7 +902,19 @@ export default class Lambder {
694
902
  return beforeRenderResult;
695
903
  return await matched.action.actionFn(beforeRenderResult, resolver);
696
904
  }
905
+ /**
906
+ * One HTTP invocation, from the event to the finalized response, under
907
+ * an invocation record of its own (see LambderInvocationScope): what an
908
+ * API call's summary line is written from once the response is final.
909
+ */
697
910
  async render(event, lambdaContext) {
911
+ return await runInvocation(lambdaContext, async (invocation) => {
912
+ const response = await this.renderRequest(event, lambdaContext, invocation);
913
+ this.writeCallSummary(invocation, response.statusCode);
914
+ return response;
915
+ });
916
+ }
917
+ async renderRequest(event, lambdaContext, invocation) {
698
918
  let ctx = null;
699
919
  let started = false;
700
920
  // Settled as soon as the context exists and reused by every answer,
@@ -703,11 +923,8 @@ export default class Lambder {
703
923
  try {
704
924
  await this.ensureInitialized();
705
925
  started = true;
706
- ctx = bindContextTools(createContext(event, lambdaContext, {
707
- apiPath: this.apiPath,
708
- trustedClientIpHeaders: this.trustedClientIpHeaders,
709
- trustedHostHeaders: this.trustedHostHeaders,
710
- }), this.contextTools);
926
+ ctx = bindContextTools(createContext(event, lambdaContext, this.contextOptions()), this.contextTools);
927
+ invocation.call = apiCallOf(ctx, event);
711
928
  if (this.corsConfig)
712
929
  allowedOrigin = allowedCorsOriginOf(this.corsConfig, ctx);
713
930
  const resolver = this.getResolver(ctx);
@@ -756,12 +973,33 @@ export default class Lambder {
756
973
  // reach it.
757
974
  ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
758
975
  this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
976
+ invocation.outcome = response.callOutcome ?? { outcome: "other", code: null };
759
977
  return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
760
978
  }
761
979
  catch (err) {
980
+ // A crash before the context existed (a created hook that failed)
981
+ // still answers an API call, and its summary line is the one a
982
+ // dashboard counts the crash by. The context is read from the
983
+ // event and the instance's options alone, so it can be read here;
984
+ // an event it cannot be read from has no call to name.
985
+ if (!ctx) {
986
+ try {
987
+ invocation.call = apiCallOf(createContext(event, lambdaContext, this.contextOptions()), event);
988
+ }
989
+ catch { /* no call to name */ }
990
+ }
762
991
  return await this.answerCrash(err, ctx, allowedOrigin, started, event, lambdaContext);
763
992
  }
764
993
  }
994
+ /** What createContext reads this instance's requests with. */
995
+ contextOptions() {
996
+ return {
997
+ apiPath: this.apiPath,
998
+ trustedClientIpHeaders: this.trustedClientIpHeaders,
999
+ trustedHostHeaders: this.trustedHostHeaders,
1000
+ originProof: this.originProof,
1001
+ };
1002
+ }
765
1003
  /**
766
1004
  * A thrown value that is an answer rather than a crash, as the response;
767
1005
  * anything else is rethrown to the crash path.
@@ -781,7 +1019,7 @@ export default class Lambder {
781
1019
  if (thrown instanceof LambderResponse)
782
1020
  return thrown;
783
1021
  if (isLambderApiRefusal(thrown) && ctx.api)
784
- return this.apiErrorResponse(thrown, ctx);
1022
+ return await this.apiErrorResponse(thrown, ctx);
785
1023
  if (thrown instanceof LambderSessionNotFoundError)
786
1024
  return await this.sessionMissingResponse(ctx, resolver);
787
1025
  throw thrown;
@@ -800,6 +1038,10 @@ export default class Lambder {
800
1038
  // invocation would reject with a 502 no client can parse.
801
1039
  const error = coerceToError(thrown, "an unstringifiable thrown value");
802
1040
  this.crashWatcher?.(error);
1041
+ // A crash, whatever the global error handler then answers with.
1042
+ const invocation = currentInvocation();
1043
+ if (invocation)
1044
+ invocation.outcome = { outcome: "crash", code: null };
803
1045
  const site = !started ? { kind: "startup", lambdaContext }
804
1046
  : ctx?.api ? { kind: "api", ctx, lambdaContext }
805
1047
  : { kind: "route", ctx, lambdaContext };
@@ -836,6 +1078,37 @@ export default class Lambder {
836
1078
  const crashResponse = this.withCallHeaders(ctx, allowedOrigin, await this.crashHandling.frameworkResponse(error, ctx, errorHandlerCrash));
837
1079
  return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
838
1080
  }
1081
+ /**
1082
+ * The summary line of an API call, once its response is final: nothing
1083
+ * for an invocation that served no API call. A writer that throws costs
1084
+ * the call its line and nothing else.
1085
+ */
1086
+ writeCallSummary(invocation, status) {
1087
+ const { call } = invocation;
1088
+ if (!call || !this.callSummaryWriter)
1089
+ return;
1090
+ const summary = {
1091
+ kind: "lambder.call",
1092
+ // The path form is two identifiers; a page built before paths
1093
+ // posts the name in its body, which is whatever it sent.
1094
+ api: call.api.slice(0, MAX_SUMMARY_API_NAME_CHARS),
1095
+ outcome: invocation.outcome?.outcome ?? "other",
1096
+ code: invocation.outcome?.code ?? null,
1097
+ status,
1098
+ durationMs: roundedMilliseconds(performance.now() - invocation.startedAt),
1099
+ handlerMs: invocation.handlerMs === null ? null : roundedMilliseconds(invocation.handlerMs),
1100
+ replayed: invocation.replayed,
1101
+ coldStart: invocation.coldStart,
1102
+ requestId: invocation.requestId,
1103
+ parentRequestId: call.parentRequestId,
1104
+ };
1105
+ try {
1106
+ this.callSummaryWriter(summary);
1107
+ }
1108
+ catch (err) {
1109
+ console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api}" was not written. ${coerceToError(err).message}`);
1110
+ }
1111
+ }
839
1112
  /**
840
1113
  * An answer to a crash, carrying what the call wrote and its CORS headers.
841
1114
  * As on the success path, headers belong to the call: a call that wrote a
@@ -892,6 +1165,11 @@ export default class Lambder {
892
1165
  * so Lambda's retries and dead-letter queues still see the failure.
893
1166
  */
894
1167
  async renderEvent(event, lambdaContext) {
1168
+ // Under an invocation record, like an HTTP invocation, so the calls an
1169
+ // action makes over invoke carry its request id.
1170
+ return await runInvocation(lambdaContext, () => this.renderEventAction(event, lambdaContext));
1171
+ }
1172
+ async renderEventAction(event, lambdaContext) {
895
1173
  let started = false;
896
1174
  try {
897
1175
  await this.ensureInitialized();
@@ -923,7 +1201,8 @@ export default class Lambder {
923
1201
  */
924
1202
  async inputValidationRefusal(ctx, zodError) {
925
1203
  if (this.apiInputValidationErrorHandler) {
926
- return answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError));
1204
+ // A validation refusal, however the app words it.
1205
+ return { ...answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError)), outcome: { outcome: "validation", code: null } };
927
1206
  }
928
1207
  // null asks the pipeline for the standard 422, so that answer is
929
1208
  // written once, in the core, rather than here as well.
@@ -962,9 +1241,18 @@ export default class Lambder {
962
1241
  const request = ctx.api;
963
1242
  if (!request)
964
1243
  throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
965
- const { answer } = await this.pipeline.run(request, ctx, definition, async () => {
1244
+ const invocation = currentInvocation();
1245
+ const { answer, replayed } = await this.pipeline.run(request, ctx, definition, async () => {
966
1246
  ctx.apiPayload = request.payload;
967
- const returned = await handler(ctx);
1247
+ const handlerStarted = performance.now();
1248
+ let returned;
1249
+ try {
1250
+ returned = await handler(ctx);
1251
+ }
1252
+ finally {
1253
+ if (invocation)
1254
+ invocation.handlerMs = performance.now() - handlerStarted;
1255
+ }
968
1256
  let parsed;
969
1257
  try {
970
1258
  parsed = output.safeParse(returned);
@@ -979,19 +1267,23 @@ export default class Lambder {
979
1267
  return envelopeAnswer(successEnvelope(this.apiVersion, parsed.data, ctx.logList));
980
1268
  });
981
1269
  // The API's compress option, on whatever answer the call ended with:
982
- // a replayed one comes back from its store without the hint.
983
- return responseFromAnswer({ ...answer, compress });
1270
+ // a replayed one comes back from its store without the hints, and
1271
+ // its outcome is read back from its text, which only a replay costs.
1272
+ if (invocation)
1273
+ invocation.replayed = replayed;
1274
+ return responseFromAnswer({ ...answer, compress, outcome: answer.outcome ?? outcomeOfAnswerText(answer.statusCode, answer.body) });
984
1275
  }
985
1276
  /**
986
1277
  * A thrown LambderApiRefusal (from a hook, say) as the structured API
987
1278
  * envelope: the core's one mapping, after the same check the pipeline
988
- * applies, against the endpoint the call names. A name no API is
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
989
1281
  * registered under declares no code, so only an uncoded or a framework
990
1282
  * refusal goes out for it.
991
1283
  */
992
- apiErrorResponse(err, ctx) {
1284
+ async apiErrorResponse(err, ctx) {
993
1285
  const apiName = ctx.apiName ?? "";
994
- const refusal = checkedRefusal(apiName, this.apiDefinitions.get(apiName)?.refusals ?? NO_DECLARED_REFUSALS, err);
1286
+ const refusal = checkedRefusal(apiName, (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS, err);
995
1287
  return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
996
1288
  }
997
1289
  }
@@ -1025,7 +1317,10 @@ export const initLambder = () => ({
1025
1317
  * Declares the app's refusal vocabulary: every code once, with the schema
1026
1318
  * of its data (`{ data: schema }`) or none (`{}`), the status every
1027
1319
  * refusal with it leaves with (`status`, 200 by default) and whether it
1028
- * sets the notAuthorized flag (`notAuthorized: true`). Returns the init
1320
+ * sets the notAuthorized flag (`notAuthorized: true`). One map of codes,
1321
+ * or a list of them, as guards and rate-limit policies are declared, so
1322
+ * each part of an app declares its own codes; a code two maps declare is
1323
+ * refused, at compile time and at the call. Returns the init
1029
1324
  * bound to it: its guard() types ctx.refuse to a guard's refusals and
1030
1325
  * refuses a guard naming a code outside the vocabulary, its refuse is
1031
1326
  * typed to the whole vocabulary, and its create() hands the vocabulary to
@@ -1034,9 +1329,11 @@ export const initLambder = () => ({
1034
1329
  * "no" a client reads names a code.
1035
1330
  */
1036
1331
  declareRefusals(refusals, options = {}) {
1037
- const vocabulary = readRefusalVocabulary(refusals);
1038
- if (!vocabulary)
1039
- throw new Error("Lambder: declareRefusals() takes the vocabulary, an object of codes.");
1040
- return lambderInitOf({ refusals, vocabulary, requireCodes: (options.requireCodes ?? false) });
1332
+ const declared = declaredRefusalVocabulary(refusals, "Lambder");
1333
+ return lambderInitOf({
1334
+ refusals: declared.refusals,
1335
+ vocabulary: declared.vocabulary,
1336
+ requireCodes: (options.requireCodes ?? false),
1337
+ });
1041
1338
  },
1042
1339
  });