lambder 9.0.2 → 10.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 (84) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +29 -14
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client/LambderUploadRunner.d.ts +5 -5
  23. package/dist/client/LambderUploadRunner.js +25 -16
  24. package/dist/client.d.ts +6 -5
  25. package/dist/client.js +2 -1
  26. package/dist/core/Lambder.d.ts +121 -23
  27. package/dist/core/Lambder.js +105 -51
  28. package/dist/core/LambderContext.d.ts +1 -1
  29. package/dist/core/LambderContext.js +5 -0
  30. package/dist/core/LambderCreateOptions.d.ts +58 -15
  31. package/dist/core/LambderCreateOptions.js +10 -0
  32. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  33. package/dist/core/LambderPolicyBuilders.js +10 -8
  34. package/dist/core/LambderResolver.d.ts +1 -1
  35. package/dist/core/LambderResolver.js +1 -1
  36. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  37. package/dist/core/LambderResponseBuilder.js +12 -11
  38. package/dist/index.d.ts +13 -9
  39. package/dist/index.js +6 -2
  40. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  41. package/dist/invoke/LambderInvokeCaller.js +13 -12
  42. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  43. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  44. package/dist/mock/LambderMockApp.d.ts +86 -24
  45. package/dist/mock/LambderMockApp.js +108 -16
  46. package/dist/mock/LambderMockCallRecorder.js +6 -3
  47. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  48. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  49. package/dist/mock/LambderMockFailureInjector.js +18 -4
  50. package/dist/mock/LambderMockTypes.d.ts +34 -11
  51. package/dist/mock.d.ts +4 -3
  52. package/dist/mock.js +4 -3
  53. package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
  54. package/dist/shared/contracts/LambderUploadBucket.js +2 -2
  55. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  56. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  57. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  58. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  59. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  60. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  61. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  62. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  63. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  64. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  65. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  66. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  67. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  68. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  69. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  70. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  71. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  72. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  73. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
  74. package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
  75. package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
  76. package/dist/shared/wire/LambderUploadSchemas.js +18 -7
  77. package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
  78. package/dist/stores/LambderMemoryUploadBucket.js +60 -12
  79. package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
  80. package/dist/stores/LambderS3UploadBucket.js +51 -4
  81. package/dist/testing/LambderTestApp.d.ts +1 -1
  82. package/dist/testing.d.ts +1 -1
  83. package/dist/testing.js +1 -1
  84. package/package.json +1 -1
@@ -13,17 +13,23 @@ 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";
17
+ import { refuse } from "../shared/wire/LambderApiRefusal.js";
16
18
  import { apiSignatureOf } from "../api/LambderApiSignature.js";
17
19
  import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
18
20
  import { assertPlainData } from "../shared/util/assertPlainData.js";
19
- import { apiNotFoundAnswer, buildApiEnvelope, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
21
+ import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, successEnvelope, } from "../api/LambderApiEnvelope.js";
22
+ import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
20
23
  import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
24
+ import { toGuardEntries } from "../api/LambderApiGuards.js";
21
25
  import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
22
26
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
23
27
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
24
28
  import { LambderCrashHandling } from "./LambderCrashHandling.js";
25
29
  import { policyBuildersFor } from "./LambderPolicyBuilders.js";
26
30
  import { assertCreateOptions, } from "./LambderCreateOptions.js";
31
+ /** The refusals of a name no API is registered under: no code, and none required. */
32
+ const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
27
33
  /**
28
34
  * Main Lambder class for building type-safe serverless APIs. Create
29
35
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -38,6 +44,8 @@ import { assertCreateOptions, } from "./LambderCreateOptions.js";
38
44
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
39
45
  * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
40
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)
41
49
  *
42
50
  * @example
43
51
  * ```typescript
@@ -84,6 +92,10 @@ export default class Lambder {
84
92
  guards;
85
93
  /** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
86
94
  rateLimitPolicies;
95
+ /** The refusal vocabulary given at creation: what each API's and each guard's refusal codes resolve against. Null without the option. */
96
+ refusalVocabulary;
97
+ /** Every API's refusals option as written, for apiOptionEntries(); its definition holds the resolved set. */
98
+ refusalOptions = new Map();
87
99
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
88
100
  createdHooks = [];
89
101
  initPromise = null;
@@ -98,6 +110,8 @@ export default class Lambder {
98
110
  corsConfig = null;
99
111
  finalizeOptions;
100
112
  requireSessionApiGuards;
113
+ /** Whether every refusal an API answers with has to name a code (declareRefusals's requireCodes). */
114
+ requireRefusalCodes;
101
115
  /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
102
116
  crashWatcher = null;
103
117
  /** The crashes option applied: reporting, and the framework's own 500. */
@@ -127,6 +141,7 @@ export default class Lambder {
127
141
  const session = options.session;
128
142
  this.guards = options.guards;
129
143
  this.rateLimitPolicies = options.rateLimits?.policies;
144
+ this.refusalVocabulary = readRefusalVocabulary(options.refusals);
130
145
  this.pipeline = new LambderApiPipeline({
131
146
  apiVersion: this.apiVersion,
132
147
  minApiVersion: options.minApiVersion,
@@ -157,12 +172,13 @@ export default class Lambder {
157
172
  this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
158
173
  this.trustedHostHeaders = options.trustedHostHeaders ?? [];
159
174
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
175
+ this.requireRefusalCodes = options.requireRefusalCodes ?? false;
160
176
  this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
161
177
  this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
162
178
  this.contextTools = {
163
179
  sessionControllerFor: (ctx) => this.getSessionController(ctx),
164
180
  chargeRateLimit: async (ctx, policy, key, refuse) => {
165
- const { checkResult, refusal } = await this.pipeline.chargeRateLimit(policy, {
181
+ const { checkResult, refusal: refusalToThrow } = await this.pipeline.chargeRateLimit(policy, {
166
182
  // A per-API budget counts per registered API. The posted
167
183
  // name of a call no API matched (a hook or the fallback
168
184
  // charging it) is the caller's choice, and a fresh name
@@ -172,13 +188,13 @@ export default class Lambder {
172
188
  session: ctx.session,
173
189
  key,
174
190
  });
175
- if (refuse && refusal) {
191
+ if (refuse && refusalToThrow) {
176
192
  if (ctx.api)
177
- throw refusal;
193
+ throw refusalToThrow;
178
194
  // A route has no envelope to carry a refusal, so it
179
195
  // answers the same 429 as text, with the same Retry-After
180
196
  // and the policy's own words.
181
- throw this.getResolver(ctx).text(refusal.errorMessage.content, { statusCode: 429, headers: refusal.headers });
197
+ throw this.getResolver(ctx).text(refusalToThrow.refusal.content, { statusCode: 429, headers: refusalToThrow.headers });
182
198
  }
183
199
  return checkResult;
184
200
  },
@@ -265,14 +281,14 @@ export default class Lambder {
265
281
  }
266
282
  // Typed API with Zod
267
283
  addApi(name, schema,
268
- /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
284
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
269
285
  handler) {
270
286
  this.registerApi(name, "public", schema, handler);
271
287
  return this;
272
288
  }
273
289
  // Typed Session API with Zod
274
290
  addSessionApi(name, schema,
275
- /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
291
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
276
292
  handler) {
277
293
  this.registerApi(name, "session", schema, handler);
278
294
  return this;
@@ -303,9 +319,22 @@ export default class Lambder {
303
319
  throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
304
320
  `Declare the guard that authorizes it, or ${optOut}.`);
305
321
  }
306
- const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
322
+ // The option, like guards and rateLimit, is a declaration or absent:
323
+ // an empty list would read as declaring codes while declaring none.
324
+ const ownRefusals = toRefusalCodes(schema.refusals);
325
+ if (schema.refusals !== undefined && ownRefusals.length === 0) {
326
+ 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.`);
327
+ }
328
+ const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, toGuardEntries(schema.guards).map(({ name: guard }) => ({
329
+ guard,
330
+ codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
331
+ })));
332
+ const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
333
+ const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
307
334
  this.pipeline.assertRegistration(definition);
308
335
  this.apiDefinitions.set(name, definition);
336
+ if (schema.refusals !== undefined)
337
+ this.refusalOptions.set(name, schema.refusals);
309
338
  this.actionList.push({
310
339
  match: (ctx) => ctx.apiName === name ? {} : false,
311
340
  actionFn: (ctx) => this.runApi(ctx, definition, schema.output, schema.compress ?? "auto", handler),
@@ -436,13 +465,16 @@ export default class Lambder {
436
465
  * and guard name. A policy's key handler is never written: its `per`
437
466
  * says "custom" and no more. A guard's input schema is never written
438
467
  * either; its declaration says only which of the three input modes it
439
- * has. Every table is sorted by name, so the module diffs by endpoint
440
- * and never moves when registrations are reordered.
468
+ * has. The refusal vocabulary is not written at all: it is shared code
469
+ * (codes, zod schemas, statuses and flags), and the mock declares the
470
+ * same object. Every table is sorted by name, so the module diffs by
471
+ * endpoint and never moves when registrations are reordered.
441
472
  */
442
473
  apiOptionEntries() {
443
474
  const apis = {};
444
475
  for (const name of [...this.apiDefinitions.keys()].sort()) {
445
476
  const { mode, guards, rateLimit, idempotency } = this.apiDefinitions.get(name);
477
+ const refusals = this.refusalOptions.get(name);
446
478
  const entry = { mode };
447
479
  if (guards !== undefined) {
448
480
  assertPlainData(guards, `the guards option of API "${name}"`);
@@ -454,11 +486,13 @@ export default class Lambder {
454
486
  }
455
487
  if (idempotency !== undefined)
456
488
  entry.idempotency = idempotency;
489
+ if (refusals !== undefined)
490
+ entry.refusals = refusals;
457
491
  apis[name] = entry;
458
492
  }
459
493
  const rateLimitPolicies = {};
460
494
  for (const name of Object.keys(this.rateLimitPolicies ?? {}).sort()) {
461
- const { per, budget, chargeAt, errorMessage, ...windows } = this.rateLimitPolicies[name];
495
+ const { per, budget, chargeAt, refusal, ...windows } = this.rateLimitPolicies[name];
462
496
  const entry = {};
463
497
  for (const [window, limit] of Object.entries(windows)) {
464
498
  if (limit !== undefined)
@@ -470,9 +504,9 @@ export default class Lambder {
470
504
  entry.budget = budget;
471
505
  if (chargeAt !== undefined)
472
506
  entry.chargeAt = chargeAt;
473
- if (errorMessage !== undefined) {
474
- assertPlainData(errorMessage, `the errorMessage of rate-limit policy "${name}"`);
475
- entry.errorMessage = errorMessage;
507
+ if (refusal !== undefined) {
508
+ assertPlainData(refusal, `the refusal of rate-limit policy "${name}"`);
509
+ entry.refusal = refusal;
476
510
  }
477
511
  rateLimitPolicies[name] = entry;
478
512
  }
@@ -483,6 +517,7 @@ export default class Lambder {
483
517
  input: guard.apiInput ? "apiInput" : guard.guardInput ? "guardInput" : "none",
484
518
  session: guard.session === true,
485
519
  runAt: guard.runAt ?? "beforeInputValidation",
520
+ ...(guard.refusals?.length ? { refusals: guard.refusals } : {}),
486
521
  };
487
522
  }
488
523
  return { apis, rateLimitPolicies, guards };
@@ -902,7 +937,9 @@ export default class Lambder {
902
937
  * see the declared shape. The handler returns the schema's input form, so
903
938
  * a transform runs exactly once.
904
939
  *
905
- * An output the schema rejects is the handler breaking its contract,
940
+ * An output the schema rejects, or one that is not an object or an array
941
+ * (isObjectPayload: what makes a caller's success never falsy), is the
942
+ * handler breaking its contract,
906
943
  * answered as a crash rather than sent (LambderApiOutputValidationError,
907
944
  * which an idempotency key records as its answer, since the handler has
908
945
  * already run). The parse is synchronous, so an output schema cannot be
@@ -929,52 +966,69 @@ export default class Lambder {
929
966
  }
930
967
  if (!parsed.success)
931
968
  throw new LambderApiOutputValidationError(definition.name, { zodError: parsed.error });
932
- return envelopeAnswer(buildApiEnvelope(this.apiVersion, parsed.data, { logList: ctx.logList }));
969
+ if (!isObjectPayload(parsed.data))
970
+ throw new LambderApiOutputValidationError(definition.name, { notObject: describePayloadKind(parsed.data) });
971
+ return envelopeAnswer(successEnvelope(this.apiVersion, parsed.data, ctx.logList));
933
972
  });
934
973
  // The API's compress option, on whatever answer the call ended with:
935
974
  // a replayed one comes back from its store without the hint.
936
975
  return responseFromAnswer({ ...answer, compress });
937
976
  }
938
- /** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
977
+ /**
978
+ * A thrown LambderApiRefusal (from a hook, say) as the structured API
979
+ * envelope: the core's one mapping, after the same check the pipeline
980
+ * applies, against the endpoint the call names. A name no API is
981
+ * registered under declares no code, so only an uncoded or a framework
982
+ * refusal goes out for it.
983
+ */
939
984
  apiErrorResponse(err, ctx) {
940
- return responseFromAnswer(refusalAnswer(err, this.apiVersion, ctx.logList));
985
+ const apiName = ctx.apiName ?? "";
986
+ const refusal = checkedRefusal(apiName, this.apiDefinitions.get(apiName)?.refusals ?? NO_DECLARED_REFUSALS, err);
987
+ return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
941
988
  }
942
989
  }
990
+ /** The init bound to one vocabulary (or none): the policy builders, refuse and create() that share it. */
991
+ const lambderInitOf = (declared) => ({
992
+ ...policyBuildersFor(declared?.vocabulary ?? null),
993
+ /**
994
+ * refuse() typed to the app's whole vocabulary, for a shared helper or a
995
+ * hook that raises a declared code with no endpoint in hand: `code` is
996
+ * one of the vocabulary's and `data` follows it. Which endpoint may send
997
+ * the code is checked where the refusal is rendered, as for the free
998
+ * refuse(). Inside an API handler ctx.refuse is narrower, that
999
+ * endpoint's codes alone.
1000
+ */
1001
+ refuse: refuse,
1002
+ create(options) {
1003
+ const withRefusals = { ...options, refusals: declared?.refusals, requireRefusalCodes: declared?.requireCodes ?? false };
1004
+ return new Lambder(withRefusals);
1005
+ },
1006
+ });
943
1007
  /**
944
- * The canonical way to create an instance: fix the session data type first,
945
- * then create with the full configuration in one declaration. The policy,
946
- * guard and idempotency types are inferred from the options, so the instance
947
- * is born fully typed and `typeof lambderApp` is the annotation type for api
948
- * modules. There are no ordering rules, and nothing can be half-configured.
949
- *
950
- * ```typescript
951
- * // app.ts (imports no api modules, so modules can import the type back)
952
- * export const lambderApp = initLambder<SessionData>().create({
953
- * apiPath: "/api",
954
- * session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: "..." },
955
- * rateLimits: { limiter, policies },
956
- * guards,
957
- * idempotency: { store },
958
- * });
959
- * export type AppLambder = typeof lambderApp;
960
- *
961
- * // orders.ts
962
- * export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
963
- *
964
- * // index.ts: registration only
965
- * const lambder = lambderApp.addHook(...).use(orderApi)...;
966
- * export const handler = lambder.getHandler();
967
- * ```
968
- *
969
- * Curried because TypeScript type arguments are all-or-nothing per call:
970
- * passing the session data type to `new Lambder<S>(...)` would silently
971
- * widen the inferred policy and guard types to their {} defaults. Fixing the
972
- * session type in the first call lets the second infer everything else.
973
- * `new Lambder(options)` serves untyped or session-data-free instances.
1008
+ * The entry point of an app: binds the session data type, and hands out the
1009
+ * builders and create() that share it. `declareRefusals()` binds the app's
1010
+ * refusal vocabulary too, so a guard's ctx.refuse and the init's own refuse
1011
+ * are typed to it before any instance exists, and create() gives it to the
1012
+ * instance for every API's refusals option to name codes from.
974
1013
  */
975
1014
  export const initLambder = () => ({
976
- ...policyBuildersFor(),
977
- create(options) {
978
- return new Lambder(options);
1015
+ ...lambderInitOf(null),
1016
+ /**
1017
+ * Declares the app's refusal vocabulary: every code once, with the schema
1018
+ * of its data (`{ data: schema }`) or none (`{}`), the status every
1019
+ * refusal with it leaves with (`status`, 200 by default) and whether it
1020
+ * sets the notAuthorized flag (`notAuthorized: true`). Returns the init
1021
+ * bound to it: its guard() types ctx.refuse to a guard's refusals and
1022
+ * refuses a guard naming a code outside the vocabulary, its refuse is
1023
+ * typed to the whole vocabulary, and its create() hands the vocabulary to
1024
+ * the instance. With `requireCodes`, an uncoded refusal from an API
1025
+ * handler, a guard or a helper is a crash rather than an answer, so every
1026
+ * "no" a client reads names a code.
1027
+ */
1028
+ declareRefusals(refusals, options = {}) {
1029
+ const vocabulary = readRefusalVocabulary(refusals);
1030
+ if (!vocabulary)
1031
+ throw new Error("Lambder: declareRefusals() takes the vocabulary, an object of codes.");
1032
+ return lambderInitOf({ refusals, vocabulary, requireCodes: (options.requireCodes ?? false) });
979
1033
  },
980
1034
  });
@@ -120,7 +120,7 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
120
120
  /**
121
121
  * Counts one attempt against a named rate-limit policy and refuses the
122
122
  * request when it is over: a 429 envelope on an API call, a plain 429 on
123
- * a route, with Retry-After and the policy's errorMessage either way. A
123
+ * a route, with Retry-After and the policy's refusal either way. A
124
124
  * policy without `per` takes the key as the second argument; a `per:
125
125
  * "ip"` or `per: "session"` one reads it off the request. The instance's
126
126
  * limiter, failOpen and key bounding apply, as for a declared limit.
@@ -6,6 +6,7 @@ import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
6
6
  import { LAMBDER_INVOKE_API_ID, LAMBDER_LOCAL_API_ID } from "../shared/wire/LambderInvokeApiId.js";
7
7
  import { bindCallTools, responseToolsOf } from "../api/LambderApiCallContext.js";
8
8
  import { decodeRequestPath } from "./LambderRequestPath.js";
9
+ import { refuse } from "../shared/wire/LambderApiRefusal.js";
9
10
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
10
11
  export const isV2HttpEvent = (event) => !!event && typeof event === "object"
11
12
  && event.version === "2.0"
@@ -23,6 +24,10 @@ export const bindContextTools = (ctx, tools) => {
23
24
  methods: {
24
25
  rateLimit: async (policy, key) => { await tools.chargeRateLimit(bound, policy, key, true); },
25
26
  isRateLimited: (policy, key) => tools.chargeRateLimit(bound, policy, key, false),
27
+ // refuse() itself: what an API handler's context types as the
28
+ // endpoint's declared codes (LambderDeclaredRefuse). The check
29
+ // against the declaration happens where the refusal is rendered.
30
+ refuse,
26
31
  ...responseToolsOf(bound, bound.host),
27
32
  },
28
33
  });
@@ -14,6 +14,10 @@ import type { LambderSessionCrypto } from "../session/LambderSessionCrypto.js";
14
14
  import type { LambderApiGuard } from "../api/LambderApiGuards.js";
15
15
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "../api/LambderApiRateLimits.js";
16
16
  import type { LambderApiIdempotencyConfig } from "../api/LambderApiIdempotency.js";
17
+ import { type LambderRefusalVocabulary } from "../api/LambderApiRefusals.js";
18
+ import type { LambderNoExtraKeys } from "../shared/util/LambderTypeUtilities.js";
19
+ import type { LambderJsonOf } from "../shared/wire/LambderApiContract.js";
20
+ import type { LambderRateLimitMessage } from "../shared/wire/LambderApiOptionValues.js";
17
21
  import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
18
22
  export type LambderRouteHandler = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
19
23
  export type LambderSessionRouteHandler<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
@@ -250,6 +254,29 @@ export type LambderCreateOptions<TSessionData = any> = {
250
254
  * rejected here rather than reading fields that are not on its context.
251
255
  */
252
256
  guards?: Record<string, LambderApiGuard<any, any, any, LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>>>;
257
+ /**
258
+ * The app's refusal vocabulary: every code an API or a guard may refuse
259
+ * with, each declared once with the schema of its data (`{ data: schema }`
260
+ * or none), the status every refusal with it leaves with and whether it
261
+ * sets the notAuthorized flag. An API names the codes it may refuse with
262
+ * in its own `refusals` option, a guard in its own, and a refusal
263
+ * carrying any other code is a crash rather than an answer
264
+ * (LambderApiRefusalValidationError). The codes are what goes on the
265
+ * wire, so they may not start with the framework's `lambder/`; a code's
266
+ * data is an object or an array, as an output is.
267
+ *
268
+ * An app declares it with `initLambder().declareRefusals()`, which hands
269
+ * it here and types the init's guard() and refuse to it; it is written
270
+ * here only when the class is constructed directly.
271
+ */
272
+ refusals?: LambderRefusalVocabulary;
273
+ /**
274
+ * Whether every refusal an API answers with has to name a declared code
275
+ * (or a framework code): an uncoded refuse() is then a crash rather than
276
+ * an answer, and ctx.refuse requires a code. Set through
277
+ * declareRefusals()'s `requireCodes`, as `refusals` is. Default false.
278
+ */
279
+ requireRefusalCodes?: boolean;
253
280
  /**
254
281
  * Make an authorization declaration part of registering a session API:
255
282
  * every addSessionApi must declare `guards`, at the type level (a missing
@@ -305,6 +332,19 @@ type LambderSessionOptionRequired = {
305
332
  * away to nothing.
306
333
  */
307
334
  export type LambderSessionEnabledInstance<TSessionsEnabled extends boolean> = TSessionsEnabled extends true ? unknown : LambderSessionOptionRequired;
335
+ /**
336
+ * An API answers with an object or an array (see isObjectPayload), so a
337
+ * caller's success is never null, false, 0 or "". Intersected onto an API's
338
+ * options: an output schema whose wire form is anything else (z.void(),
339
+ * z.boolean(), a nullable or an optional object, a Date) is refused where it
340
+ * is written, and the property name is the message.
341
+ */
342
+ export type LambderObjectOutputCheck<TOutput extends z.ZodType> = 0 extends 1 & z.output<TOutput> ? unknown : undefined extends z.output<TOutput> ? LambderObjectOutputRefusal : [LambderJsonOf<z.output<TOutput>>] extends [never] ? LambderObjectOutputRefusal : [LambderJsonOf<z.output<TOutput>>] extends [object] ? unknown : LambderObjectOutputRefusal;
343
+ type LambderObjectOutputRefusal = {
344
+ output: {
345
+ readonly "lambder: an API answers with an object or an array. One with nothing to answer declares output: z.object({}) and returns {}.": never;
346
+ };
347
+ };
308
348
  /**
309
349
  * The `guards` field of an API's options: optional by default, required once
310
350
  * create() received the require*ApiGuards flag for that kind of API, so that
@@ -320,17 +360,7 @@ export type LambderRequirableGuardsField<TRequired extends boolean, TGuardsOpt>
320
360
  /** Named guards, run in declared order before input validation (after it for a guard declared runAt: "afterInputValidation"): a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
321
361
  guards?: TGuardsOpt;
322
362
  };
323
- /**
324
- * Rejects a key the options type does not have, which `const TOptions` would
325
- * otherwise wave through: inferring a generic from an object literal switches
326
- * excess-property checking off for the whole literal, so
327
- * `requireSessionApiGuard` (no trailing "s") or `maxResponseByte` would
328
- * compile, be dropped in silence, and leave the app on the default. That is
329
- * worst for the two require*ApiGuards flags, which exist to make a missing
330
- * authorization declaration a compile error. Mapping every surplus key to
331
- * `never` puts the error back on the key itself.
332
- */
333
- export type LambderNoExtraKeys<TOptions, TShape> = TOptions & Record<Exclude<keyof TOptions, keyof TShape>, never>;
363
+ export type { LambderNoExtraKeys };
334
364
  /**
335
365
  * What create() was actually given for one option, or undefined when the
336
366
  * literal does not carry the key at all.
@@ -346,6 +376,16 @@ export type LambderGivenOption<TOptions, TKey extends PropertyKey> = TKey extend
346
376
  * option they check.
347
377
  */
348
378
  type LambderOptionShape<TSessionData, TKey extends keyof LambderCreateOptions<TSessionData>> = NonNullable<LambderCreateOptions<TSessionData>[TKey]>;
379
+ /**
380
+ * A guard's `refusals` against the vocabulary given beside it: the guard is
381
+ * built before the instance, so a code it names that the vocabulary does not
382
+ * hold is caught here, on the guard's entry in the guards option.
383
+ */
384
+ type LambderGuardRefusalsKnown<TGuard, TRefusals> = TGuard extends {
385
+ refusals?: infer TNamed;
386
+ } ? NonNullable<TNamed> extends readonly (infer TCode extends string)[] ? string extends TCode ? unknown : [Exclude<TCode, keyof NonNullable<TRefusals> & string>] extends [never] ? unknown : {
387
+ refusals: readonly (keyof NonNullable<TRefusals> & string)[];
388
+ } : unknown : unknown;
349
389
  /**
350
390
  * The surplus-key rule one level down, over the option objects a typo is
351
391
  * worst on. Excess-property checking is off for the WHOLE literal under
@@ -358,7 +398,7 @@ type LambderOptionShape<TSessionData, TKey extends keyof LambderCreateOptions<TS
358
398
  * checked against its own option's shape, so the error lands on the
359
399
  * misspelled key.
360
400
  */
361
- export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCreateOptions<TSessionData>> = {
401
+ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCreateOptions<TSessionData>, TRefusals = TOptions["refusals"]> = {
362
402
  session?: LambderNoExtraKeys<NonNullable<TOptions["session"]>, LambderOptionShape<TSessionData, "session">> & {
363
403
  cookie?: LambderNoExtraKeys<NonNullable<NonNullable<TOptions["session"]>["cookie"]>, LambderSessionCookieOptions>;
364
404
  };
@@ -366,11 +406,15 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
366
406
  crashes?: LambderNoExtraKeys<NonNullable<TOptions["crashes"]>, LambderOptionShape<TSessionData, "crashes">>;
367
407
  rateLimits?: LambderNoExtraKeys<NonNullable<TOptions["rateLimits"]>, LambderOptionShape<TSessionData, "rateLimits">> & {
368
408
  policies?: {
369
- [TPolicy in keyof NonNullable<TOptions["rateLimits"]>["policies"]]: LambderNoExtraKeys<NonNullable<TOptions["rateLimits"]>["policies"][TPolicy], LambderApiRateLimitPolicyConfig<LambderRenderContext>>;
409
+ [TPolicy in keyof NonNullable<TOptions["rateLimits"]>["policies"]]: LambderNoExtraKeys<NonNullable<TOptions["rateLimits"]>["policies"][TPolicy], LambderApiRateLimitPolicyConfig<LambderRenderContext>> & (NonNullable<TOptions["rateLimits"]>["policies"][TPolicy] extends {
410
+ refusal: infer TMessage;
411
+ } ? {
412
+ refusal?: LambderNoExtraKeys<TMessage, LambderRateLimitMessage>;
413
+ } : unknown);
370
414
  };
371
415
  };
372
416
  guards?: {
373
- [TGuard in keyof NonNullable<TOptions["guards"]>]: LambderNoExtraKeys<NonNullable<TOptions["guards"]>[TGuard], LambderOptionShape<TSessionData, "guards">[string]>;
417
+ [TGuard in keyof NonNullable<TOptions["guards"]>]: LambderNoExtraKeys<NonNullable<TOptions["guards"]>[TGuard], LambderOptionShape<TSessionData, "guards">[string]> & LambderGuardRefusalsKnown<NonNullable<TOptions["guards"]>[TGuard], TRefusals>;
374
418
  };
375
419
  files?: TOptions["files"] extends {
376
420
  source: unknown;
@@ -387,4 +431,3 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
387
431
  * response (maxResponseBytes: 0) that an app discovers in production.
388
432
  */
389
433
  export declare const assertCreateOptions: (options: LambderCreateOptions<any>) => void;
390
- export {};
@@ -1,3 +1,4 @@
1
+ import { assertRefusalCodesDeclared, readRefusalVocabulary } from "../api/LambderApiRefusals.js";
1
2
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
2
3
  /**
3
4
  * Everything create() refuses before an instance exists, in one place: a
@@ -25,6 +26,15 @@ export const assertCreateOptions = (options) => {
25
26
  if (typeof cors === "object" && cors.credentials && (cors.origins === undefined || cors.origins === "*")) {
26
27
  throw new Error('Lambder: cors.credentials needs cors.origins to be an allowlist or a predicate. With every origin allowed, any website could make credentialed calls and read the answers.');
27
28
  }
29
+ // The vocabulary, and every code a guard names against it: a guard built
30
+ // by the standalone lambderGuard() meets the vocabulary here first.
31
+ const vocabulary = readRefusalVocabulary(options.refusals);
32
+ for (const [name, guard] of Object.entries(options.guards ?? {})) {
33
+ assertRefusalCodesDeclared(`guard "${name}"`, (guard?.refusals ?? []), vocabulary);
34
+ }
35
+ if (options.requireRefusalCodes && !vocabulary) {
36
+ throw new Error("Lambder: requireRefusalCodes needs a refusals vocabulary to name codes from; declare one with initLambder().declareRefusals().");
37
+ }
28
38
  if ((options.requireSessionApiGuards || options.requirePublicApiGuards) && !options.guards) {
29
39
  const requireFlag = options.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
30
40
  throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
@@ -9,21 +9,24 @@
9
9
  */
10
10
  import type { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
11
11
  import { type LambderGuardBuilder } from "../api/LambderApiGuards.js";
12
+ import type { LambderRefusalDeclaration, LambderRefusalVocabulary } from "../api/LambderApiRefusals.js";
12
13
  import { type LambderRateLimitKeyBuilder } from "../api/LambderApiRateLimits.js";
13
14
  /** Builder for the server's guards: the handler sees the render context (session-typed when `session: true`). */
14
15
  export declare const lambderGuard: LambderGuardBuilder<LambderRenderContext, LambderSessionRenderContext<any, any>>;
15
16
  /** Builder for the server's rate-limit keys: the handler sees the render context. */
16
17
  export declare const lambderRateLimitKey: LambderRateLimitKeyBuilder<LambderRenderContext>;
17
18
  /**
18
- * The same two builders bound to one app's session data, which is what
19
- * initLambder<SessionData>() hands out beside create(): a guard's
20
- * `ctx.session.data` and `ctx.sessionController` are SessionData where the standalone
21
- * lambderGuard() leaves them `any`. The server's counterpart of the mock's
22
- * `guard` and `rateLimitKey`.
19
+ * The same two builders bound to one app's session data and refusal
20
+ * vocabulary, which is what initLambder<SessionData>() hands out beside
21
+ * create(): a guard's `ctx.session.data` and `ctx.sessionController` are
22
+ * SessionData where the standalone lambderGuard() leaves them `any`, and its
23
+ * `ctx.refuse` is typed to the guard's refusals from the vocabulary
24
+ * declareRefusals() gave (null: none declared, so a guard names no code).
25
+ * The server's counterpart of the mock's `guard` and `rateLimitKey`.
23
26
  */
24
- export declare const policyBuildersFor: <TSessionData>() => {
25
- /** Builds a guard whose handler sees this app's session type. */
26
- guard: LambderGuardBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>>;
27
+ export declare const policyBuildersFor: <TSessionData, TRefusals extends LambderRefusalVocabulary = {}, TCodesRequired extends boolean = false>(vocabulary: ReadonlyMap<string, LambderRefusalDeclaration> | null) => {
28
+ /** Builds a guard whose handler sees this app's session type and, on ctx.refuse, the guard's own refusal codes. */
29
+ guard: LambderGuardBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>, TRefusals, TCodesRequired>;
27
30
  /** Builds a rate-limit key whose handler sees this app's session type; the counterpart of `guard`. */
28
31
  rateLimitKey: LambderRateLimitKeyBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>>;
29
32
  };
@@ -14,15 +14,17 @@ export const lambderGuard = lambderGuardBuilder();
14
14
  /** Builder for the server's rate-limit keys: the handler sees the render context. */
15
15
  export const lambderRateLimitKey = lambderRateLimitKeyBuilder();
16
16
  /**
17
- * The same two builders bound to one app's session data, which is what
18
- * initLambder<SessionData>() hands out beside create(): a guard's
19
- * `ctx.session.data` and `ctx.sessionController` are SessionData where the standalone
20
- * lambderGuard() leaves them `any`. The server's counterpart of the mock's
21
- * `guard` and `rateLimitKey`.
17
+ * The same two builders bound to one app's session data and refusal
18
+ * vocabulary, which is what initLambder<SessionData>() hands out beside
19
+ * create(): a guard's `ctx.session.data` and `ctx.sessionController` are
20
+ * SessionData where the standalone lambderGuard() leaves them `any`, and its
21
+ * `ctx.refuse` is typed to the guard's refusals from the vocabulary
22
+ * declareRefusals() gave (null: none declared, so a guard names no code).
23
+ * The server's counterpart of the mock's `guard` and `rateLimitKey`.
22
24
  */
23
- export const policyBuildersFor = () => ({
24
- /** Builds a guard whose handler sees this app's session type. */
25
- guard: lambderGuardBuilder(),
25
+ export const policyBuildersFor = (vocabulary) => ({
26
+ /** Builds a guard whose handler sees this app's session type and, on ctx.refuse, the guard's own refusal codes. */
27
+ guard: lambderGuardBuilder(vocabulary),
26
28
  /** Builds a rate-limit key whose handler sees this app's session type; the counterpart of `guard`. */
27
29
  rateLimitKey: lambderRateLimitKeyBuilder(),
28
30
  });
@@ -14,7 +14,7 @@ interface DieResolverMethods {
14
14
  redirect: SyncDie<LambderResponseBuilder["redirect"]>;
15
15
  versionExpired: SyncDie<LambderResponseBuilder["versionExpired"]>;
16
16
  fileBase64: SyncDie<LambderResponseBuilder["fileBase64"]>;
17
- api: SyncDie<LambderResponseBuilder["api"]>;
17
+ apiRefusal: SyncDie<LambderResponseBuilder["apiRefusal"]>;
18
18
  file: AsyncDie<LambderResponseBuilder["file"]>;
19
19
  templateFile: AsyncDie<LambderResponseBuilder["templateFile"]>;
20
20
  }
@@ -22,7 +22,7 @@ export default class LambderResolver extends LambderResponseBuilder {
22
22
  redirect: (...a) => { throw this.redirect(...a); },
23
23
  versionExpired: (...a) => { throw this.versionExpired(...a); },
24
24
  fileBase64: (...a) => { throw this.fileBase64(...a); },
25
- api: (...a) => { throw this.api(...a); },
25
+ apiRefusal: (...a) => { throw this.apiRefusal(...a); },
26
26
  file: async (...a) => { throw await this.file(...a); },
27
27
  templateFile: async (...a) => { throw await this.templateFile(...a); },
28
28
  };
@@ -4,8 +4,8 @@ import { LambderResponse, type LambderHeadersInput } from "./LambderResponse.js"
4
4
  import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
5
5
  import { LambderSafeHtml } from "../shared/LambderHtml.js";
6
6
  import type { LambderTemplateData } from "./LambderTemplatingEngine.js";
7
- import type { LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
8
- export type { LambderApiEnvelopeBody, LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
7
+ import type { LambderApiRefusalConfig } from "../shared/wire/LambderApiContract.js";
8
+ export type { LambderApiEnvelopeBody, LambderApiRefusalConfig } from "../shared/wire/LambderApiContract.js";
9
9
  export type LambderResponseOptions = {
10
10
  statusCode?: LambderHttpStatusCode;
11
11
  headers?: LambderHeadersInput;
@@ -79,12 +79,14 @@ export default class LambderResponseBuilder {
79
79
  htmlVirtualSlots?: boolean;
80
80
  }): Promise<LambderResponse>;
81
81
  /**
82
- * An API envelope written by hand: what a hook, an input validation
83
- * handler or a global error handler answers an API call with (a refusal
84
- * flag, an errorMessage, a crash). The payload goes out as given; an API
85
- * handler's own output is parsed through its schema by the instance
86
- * instead. The logList channel is what the request accumulated unless the
87
- * config names its own.
82
+ * An API call answered from outside its handler: what a hook, a fallback,
83
+ * the input validation handler or the global error handler answers with.
84
+ * It is always a refusal (a refusal message, or one of the versionExpired,
85
+ * sessionExpired and notAuthorized flags) with a null payload, so a
86
+ * caller's success is only ever the handler's parsed output. Its message
87
+ * carries a framework code or none, and no data, since it answers outside
88
+ * any one endpoint's declared refusals. The logList channel is what the
89
+ * request accumulated unless the config names its own.
88
90
  */
89
- api(payload: unknown, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
91
+ apiRefusal(config: LambderApiRefusalConfig, options?: LambderResponseOptions): LambderResponse;
90
92
  }