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
@@ -3,15 +3,18 @@ import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../ap
3
3
  import { bindCallTools, createApiCallContext, responseToolsOf } from "../api/LambderApiCallContext.js";
4
4
  import { toHttpAnswer } from "../api/LambderApiAnswer.js";
5
5
  import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
6
- import { buildApiEnvelope, envelopeAnswer, crashAnswer, } from "../api/LambderApiEnvelope.js";
7
- import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
6
+ import { envelopeAnswer, crashAnswer, plainRefusalEnvelope, refusalEnvelope, successEnvelope, } from "../api/LambderApiEnvelope.js";
7
+ import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
8
+ import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
9
+ import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, refuse as refuseApiCall } from "../shared/wire/LambderApiRefusal.js";
10
+ import { allowedRefusalOf, readRefusalVocabulary, toRefusalCodes, } from "../api/LambderApiRefusals.js";
8
11
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
9
12
  import { buildTransportEnvelope } from "../shared/transport/LambderApiTransport.js";
10
13
  import { lambderCookieJarTransport } from "../shared/transport/lambderCookieJarTransport.js";
11
14
  import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
12
15
  import { serializeClearCookie } from "../shared/wire/LambderCookie.js";
13
16
  import { LOOPBACK_CLIENT_IP, normalizeClientIp } from "../shared/util/LambderClientIp.js";
14
- import { lambderGuardBuilder } from "../api/LambderApiGuards.js";
17
+ import { lambderGuardBuilder, toGuardEntries } from "../api/LambderApiGuards.js";
15
18
  import { lambderRateLimitKeyBuilder } from "../api/LambderApiRateLimits.js";
16
19
  import { LambderMockFailureInjector, LambderMockTransportError } from "./LambderMockFailureInjector.js";
17
20
  import { LambderMockCallRecorder } from "./LambderMockCallRecorder.js";
@@ -49,6 +52,8 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
49
52
  * which fixes the contract and session types first so everything else is
50
53
  * inferred from the options. `TDerived` is true for a mock created with the
51
54
  * generated `apiOptions` table, whose entries are their handlers alone.
55
+ * `TVocabulary` and `TCodesRequired` are the refusal vocabulary the init
56
+ * declared (declareRefusals), which types every handler's ctx.refuse.
52
57
  */
53
58
  export class LambderMockApp {
54
59
  apiVersion;
@@ -88,11 +93,25 @@ export class LambderMockApp {
88
93
  registry = new LambderMockEntryRegistry();
89
94
  /** The server's declared options per API, when create() was given the generated table; the entries' declarations come from here. */
90
95
  apiOptions;
96
+ /** The server's guard declarations, when create() was given the generated table: the refusal codes a declared guard adds to an entry. */
97
+ guardDeclarations;
98
+ /** The server's refusal vocabulary, when create() was given the generated table: whether each code an entry may refuse with carries data. */
99
+ refusalVocabulary;
100
+ /** Whether every refusal an entry answers with has to name a code, as the server's declareRefusals() requireCodes. */
101
+ requireRefusalCodes;
91
102
  /** The jars the runtime owns and what it planted in document.cookie (see LambderMockBrowserCookies). */
92
103
  browserCookies = new LambderMockBrowserCookies();
93
- constructor(options) {
104
+ constructor(options,
105
+ /** The vocabulary the init declared, or null: what initLambderMock().declareRefusals() passes on. */
106
+ declared = null) {
94
107
  this.apiVersion = options.apiVersion ?? null;
95
108
  this.apiOptions = options.apiOptions ?? null;
109
+ this.guardDeclarations = options.guardDeclarations ?? null;
110
+ this.refusalVocabulary = declared?.vocabulary ?? null;
111
+ this.requireRefusalCodes = declared?.requireCodes ?? false;
112
+ if (this.requireRefusalCodes && !this.apiOptions) {
113
+ throw new Error("LambderMockApp: requireCodes needs the apiOptions table, which holds the codes each entry may refuse with.");
114
+ }
96
115
  this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
97
116
  this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
98
117
  // The loopback address when nothing names a client, as for a request
@@ -124,15 +143,14 @@ export class LambderMockApp {
124
143
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
125
144
  // The server app's own answer to a bad input, where it has one:
126
145
  // without it the mock would answer 422 where the server answers,
127
- // say, 200 with an errorMessage, and a form's error handling would
146
+ // say, 200 with a refusal, and a form's error handling would
128
147
  // be tested against an answer production never gives.
129
148
  onInvalidInput: options.onInvalidInput
130
149
  ? async (zodError, ctx) => {
131
150
  const answer = await options.onInvalidInput(zodError, ctx);
132
151
  if (!answer)
133
152
  return null;
134
- const config = answer.config ?? {};
135
- return envelopeAnswer(buildApiEnvelope(this.apiVersion, answer.payload ?? null, { ...config, logList: config.logList ?? ctx.logList }), { statusCode: answer.statusCode ?? 200 });
153
+ return envelopeAnswer(plainRefusalEnvelope(this.apiVersion, answer.config, "the mock's onInvalidInput", ctx.logList), { statusCode: answer.statusCode ?? 200 });
136
154
  }
137
155
  : undefined,
138
156
  sessions: sessionOptions
@@ -219,6 +237,39 @@ export class LambderMockApp {
219
237
  }
220
238
  return declared;
221
239
  }
240
+ /**
241
+ * The codes an entry may refuse with, read off the generated tables (its
242
+ * own from apiOptions, its guards' from guardDeclarations), each resolved
243
+ * through the vocabulary the init declared, exactly as the server
244
+ * resolves its own: schema, status and flag. An entry that declares
245
+ * guards needs the guardDeclarations table, and one with any code needs
246
+ * the vocabulary; a code the tables name that the vocabulary lacks means
247
+ * the two describe different servers.
248
+ */
249
+ declaredRefusalsOf(name, declared) {
250
+ const codes = new Set(toRefusalCodes(declared.refusals));
251
+ const guards = toGuardEntries(declared.guards);
252
+ if (guards.length && !this.guardDeclarations) {
253
+ throw new Error(`LambderMockApp: "${name}" declares guards in the apiOptions table, and create() was not given the guardDeclarations table, which holds the refusal codes they add. Pass both from the generated options module.`);
254
+ }
255
+ for (const { name: guard } of guards) {
256
+ const declaration = this.guardDeclarations && Object.prototype.hasOwnProperty.call(this.guardDeclarations, guard) ? this.guardDeclarations[guard] : undefined;
257
+ for (const code of declaration?.refusals ?? [])
258
+ codes.add(code);
259
+ }
260
+ if (codes.size && !this.refusalVocabulary) {
261
+ throw new Error(`LambderMockApp: "${name}" can refuse with declared codes by the apiOptions table, and the mock init declared no refusal vocabulary. Declare the server's vocabulary, the same object, with initLambderMock().declareRefusals().`);
262
+ }
263
+ const allowed = new Map();
264
+ for (const code of codes) {
265
+ const declaration = this.refusalVocabulary.get(code);
266
+ if (!declaration) {
267
+ throw new Error(`LambderMockApp: "${name}" can refuse with "${code}" by the apiOptions and guardDeclarations tables, which the vocabulary declared on the mock init does not hold. The tables and the vocabulary describe one server: regenerate the tables, or declare the server's vocabulary.`);
268
+ }
269
+ allowed.set(code, allowedRefusalOf(declaration));
270
+ }
271
+ return allowed;
272
+ }
222
273
  /** The mode the apiOptions table gives a name, or null without a table or for a name it does not hold. */
223
274
  declaredModeOf(name) {
224
275
  if (!this.apiOptions || !Object.prototype.hasOwnProperty.call(this.apiOptions, name))
@@ -243,6 +294,7 @@ export class LambderMockApp {
243
294
  guards: declarations.guards,
244
295
  rateLimit: declarations.rateLimit,
245
296
  idempotency: declarations.idempotency,
297
+ refusals: declared ? { codes: this.declaredRefusalsOf(name, declared), codeRequired: this.requireRefusalCodes } : undefined,
246
298
  // No cast: the entry's schema is a z.ZodType, the same type the
247
299
  // definition holds. A structural { safeParse } here would let a
248
300
  // validator that is not a zod schema reach the 422 body as
@@ -411,7 +463,7 @@ export class LambderMockApp {
411
463
  // -----------------------------------------------------------------------
412
464
  // Control surface
413
465
  // -----------------------------------------------------------------------
414
- /** The next call to the endpoint fails this way; several calls queue in order. */
466
+ /** The next call to the endpoint fails this way; several calls queue in order. An injected refusal names one of the endpoint's declared codes, its data as a handler raises it (see LambderMockRefusalsOf), and is checked and sent as a real one is. */
415
467
  failNext(apiName, failure) {
416
468
  this.failures.failNext(apiName, failure);
417
469
  }
@@ -590,6 +642,9 @@ export class LambderMockApp {
590
642
  methods: {
591
643
  rateLimit: async (policy, key) => { await chargeRateLimit(policy, key, true); },
592
644
  isRateLimited: (policy, key) => chargeRateLimit(policy, key, false),
645
+ // refuse() itself, typed on a handler's context to the
646
+ // endpoint's declared codes; checked where it is rendered.
647
+ refuse: refuseApiCall,
593
648
  ...responseToolsOf(ctx, request.host),
594
649
  },
595
650
  });
@@ -665,7 +720,7 @@ export class LambderMockApp {
665
720
  // normally.
666
721
  const failure = this.failures.take(request.apiName);
667
722
  if (failure) {
668
- answer = await this.failures.answerFor(failure, request);
723
+ answer = await this.failures.answerFor(failure, request, entry?.definition.refusals);
669
724
  outcome = "injected";
670
725
  }
671
726
  else if (prepared) {
@@ -682,9 +737,12 @@ export class LambderMockApp {
682
737
  // The payload the handler sees is the restored one.
683
738
  callCtx.payload = request.payload;
684
739
  const payload = await handler(callCtx);
685
- return envelopeAnswer(buildApiEnvelope(this.apiVersion, payload === undefined ? null : payload, {
686
- logList: callCtx.logList,
687
- }));
740
+ // The server's rule, where the server applies it: an
741
+ // answer is an object or an array, and a handler that
742
+ // returns anything else has broken its contract.
743
+ if (!isObjectPayload(payload))
744
+ throw new LambderApiOutputValidationError(request.apiName, { notObject: describePayloadKind(payload) });
745
+ return envelopeAnswer(successEnvelope(this.apiVersion, payload, callCtx.logList));
688
746
  }
689
747
  // A notMocked entry refuses where a handler would run, not
690
748
  // ahead of the pipeline, so the steps that precede
@@ -693,7 +751,7 @@ export class LambderMockApp {
693
751
  // compressed payload reaches the events.
694
752
  : async () => {
695
753
  throw new LambderApiRefusal(`Not mocked: ${entry.notMockedReason}`, {
696
- errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.notMocked, content: `"${request.apiName}" is not mocked: ${entry.notMockedReason}` },
754
+ refusal: { type: "warning", code: LAMBDER_REFUSAL_CODES.notMocked, content: `"${request.apiName}" is not mocked: ${entry.notMockedReason}` },
697
755
  });
698
756
  }, trace);
699
757
  answer = result.answer;
@@ -713,7 +771,7 @@ export class LambderMockApp {
713
771
  // for what could have been on the screen. Apps that want the
714
772
  // production shape turn it off.
715
773
  answer = this.revealHandlerErrors
716
- ? envelopeAnswer(buildApiEnvelope(this.apiVersion, null, { errorMessage: error.message }), { statusCode: 500 })
774
+ ? envelopeAnswer(refusalEnvelope(this.apiVersion, { refusal: error.message }), { statusCode: 500 })
717
775
  : crashAnswer(this.apiVersion);
718
776
  outcome = "crash";
719
777
  }
@@ -849,7 +907,8 @@ export class LambderMockApp {
849
907
  * });
850
908
  * ```
851
909
  */
852
- export const initLambderMock = () => ({
910
+ /** The mock init bound to one vocabulary (or none): the builders, refuse and create() that share it. */
911
+ const lambderMockInitOf = (declared) => ({
853
912
  /** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
854
913
  guard: lambderGuardBuilder(),
855
914
  /**
@@ -859,6 +918,13 @@ export const initLambderMock = () => ({
859
918
  * while reading fields the mock context does not have.
860
919
  */
861
920
  rateLimitKey: lambderRateLimitKeyBuilder(),
921
+ /**
922
+ * refuse() typed to the vocabulary the mock declared, for a mock guard or
923
+ * a helper of the mock with no endpoint in hand: `code` is one of its
924
+ * codes and `data` the schema's input form, parsed as the server parses
925
+ * it. The free refuse() where the mock declared none.
926
+ */
927
+ refuse: refuseApiCall,
862
928
  /**
863
929
  * The mock app, with the guard map and the rate-limit policies inferred
864
930
  * from the options.
@@ -871,6 +937,32 @@ export const initLambderMock = () => ({
871
937
  * LambderMockSurplusKeys puts the error back on the key.
872
938
  */
873
939
  create(options) {
874
- return new LambderMockApp(options);
940
+ return new LambderMockApp(options, declared);
941
+ },
942
+ });
943
+ /**
944
+ * The mock's entry point: fixes the contract and session types, and hands
945
+ * out the builders and create() that share them. `declareRefusals()` binds
946
+ * the server's refusal vocabulary too, the same object the server's init
947
+ * declares, so a mock handler's ctx.refuse takes a code's data in the
948
+ * schema's input form and the mock parses it exactly as the server does.
949
+ */
950
+ export const initLambderMock = () => ({
951
+ ...lambderMockInitOf(null),
952
+ /**
953
+ * Declares the server's refusal vocabulary on the mock: the same object,
954
+ * and the same `requireCodes`, the server's init declares, imported from
955
+ * shared code (codes, zod schemas, statuses and flags hold nothing
956
+ * secret). Given, every refusal an entry answers with is checked and sent
957
+ * as the server would send it: the code among the codes the tables give
958
+ * the entry, its data parsed through the code's schema from the input
959
+ * form, and the declaration's status and flag. An entry whose tables name
960
+ * a code needs it.
961
+ */
962
+ declareRefusals(refusals, options = {}) {
963
+ const vocabulary = readRefusalVocabulary(refusals);
964
+ if (!vocabulary)
965
+ throw new Error("LambderMockApp: declareRefusals() takes the vocabulary, an object of codes.");
966
+ return lambderMockInitOf({ refusals, vocabulary, requireCodes: (options.requireCodes ?? false) });
875
967
  },
876
968
  });
@@ -42,7 +42,10 @@ const envelopeOf = (answer) => {
42
42
  }
43
43
  };
44
44
  /** How an answer reads, in the runtime's vocabulary. */
45
- const classifyAnswer = (answer, envelope) => {
45
+ const classifyAnswer = (answer, body) => {
46
+ // Read field by field: the body is whatever the answer carried, and a
47
+ // success envelope has none of a refusal's fields.
48
+ const envelope = body;
46
49
  if (answer.statusCode >= 500)
47
50
  return "crash";
48
51
  if (answer.statusCode === 422)
@@ -55,14 +58,14 @@ const classifyAnswer = (answer, envelope) => {
55
58
  return "sessionExpired";
56
59
  if (envelope.notAuthorized)
57
60
  return "notAuthorized";
58
- const code = envelope.errorMessage?.code;
61
+ const code = envelope.refusal?.code;
59
62
  if (code === LAMBDER_REFUSAL_CODES.rateLimited)
60
63
  return "rateLimited";
61
64
  if (code === LAMBDER_REFUSAL_CODES.notMocked)
62
65
  return "notMocked";
63
66
  if (code === LAMBDER_REFUSAL_CODES.apiNotFound)
64
67
  return "unknownApi";
65
- if (envelope.errorMessage)
68
+ if (envelope.refusal)
66
69
  return "refusal";
67
70
  return "ok";
68
71
  };
@@ -1,6 +1,6 @@
1
1
  import type { z } from "zod";
2
2
  import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
3
- import type { LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
3
+ import type { LambderApiRefusalConfig } from "../shared/wire/LambderApiContract.js";
4
4
  import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
5
5
  import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
6
6
  import type { LambderContractGuardNames, LambderContractIdempotencyOf, LambderContractKeysWithMode, LambderContractMode, LambderContractRateLimitNames, LambderContractRateLimitOf } from "../shared/wire/LambderApiContract.js";
@@ -282,12 +282,11 @@ export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicie
282
282
  };
283
283
  /**
284
284
  * What the server's input validation handler answers, as a mock states it:
285
- * the handler's `res.api(payload, config)` as data, with the status it went
286
- * out with (200 unless named).
285
+ * the handler's `res.apiRefusal(config)` as data, with the status it went
286
+ * out with (200 unless named). Like res.apiRefusal it is always a refusal.
287
287
  */
288
288
  export type LambderMockInvalidInputAnswer = {
289
- payload?: unknown;
290
- config?: LambderApiResponseConfig;
289
+ config: LambderApiRefusalConfig;
291
290
  statusCode?: LambderHttpStatusCode;
292
291
  };
293
292
  /** How the mock transport carries cookies: a fresh memory jar (default), a jar of yours, the memory jar mirrored into document.cookie, or none. */
@@ -1,3 +1,4 @@
1
+ import { type LambderEndpointRefusals } from "../api/LambderApiRefusals.js";
1
2
  import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
2
3
  import type { LambderApiRequest } from "../api/LambderApiRequest.js";
3
4
  import type { LambderMockFailure, LambderMockFailureReason, LambderMockLatency } from "./LambderMockTypes.js";
@@ -50,8 +51,14 @@ export declare class LambderMockFailureInjector {
50
51
  wait(ms: number, signal: AbortSignal | undefined): Promise<void>;
51
52
  /** Waits for the caller's own abort, which is what a timeout is; a call with no signal waits for ever. */
52
53
  waitForAbort(signal: AbortSignal | undefined): Promise<never>;
53
- /** The answer an injected failure produces, or a throw for the ones that never reach the caller as answers. */
54
- answerFor(failure: LambderMockFailure, request: LambderApiRequest): Promise<LambderApiAnswer>;
54
+ /**
55
+ * The answer an injected failure produces, or a throw for the ones that
56
+ * never reach the caller as answers. An injected refusal is checked
57
+ * against the endpoint's declared codes (`endpoint`), as the pipeline
58
+ * checks a real one, so a test cannot inject what the server could never
59
+ * send.
60
+ */
61
+ answerFor(failure: LambderMockFailure, request: LambderApiRequest, endpoint: LambderEndpointRefusals | undefined): Promise<LambderApiAnswer>;
55
62
  /** Clears every injected failure and puts the configured latency back. */
56
63
  reset(): void;
57
64
  }
@@ -1,6 +1,7 @@
1
1
  import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
2
2
  import { crashAnswer, refusalAnswer, sessionExpiredAnswer, versionExpiredAnswer } from "../api/LambderApiEnvelope.js";
3
3
  import { rateLimitRefusal } from "../api/LambderApiRateLimits.js";
4
+ import { checkedRefusal } from "../api/LambderApiRefusals.js";
4
5
  /** An injected refusal's message, from a string, a full message, or the reason's own wording. */
5
6
  const toRefusalMessage = (message, fallback) => typeof message === "string" ? { type: "warning", content: message }
6
7
  : message ?? { type: "warning", content: fallback };
@@ -106,19 +107,32 @@ export class LambderMockFailureInjector {
106
107
  signal.addEventListener("abort", () => reject(new LambderMockTransportError("timeout")), { once: true });
107
108
  });
108
109
  }
109
- /** The answer an injected failure produces, or a throw for the ones that never reach the caller as answers. */
110
- async answerFor(failure, request) {
110
+ /**
111
+ * The answer an injected failure produces, or a throw for the ones that
112
+ * never reach the caller as answers. An injected refusal is checked
113
+ * against the endpoint's declared codes (`endpoint`), as the pipeline
114
+ * checks a real one, so a test cannot inject what the server could never
115
+ * send.
116
+ */
117
+ async answerFor(failure, request, endpoint) {
111
118
  switch (failure.reason) {
112
119
  case "network": throw new LambderMockTransportError("network");
113
120
  case "timeout": return await this.waitForAbort(request.signal);
114
121
  case "server": return crashAnswer(this.apiVersion);
115
122
  case "refusal": {
116
123
  const message = toRefusalMessage(failure.message, "Injected refusal.");
117
- return refusalAnswer(new LambderApiRefusal(message.content, { errorMessage: message, statusCode: failure.statusCode }), this.apiVersion);
124
+ return refusalAnswer(checkedRefusal(request.apiName, endpoint, new LambderApiRefusal(message.content, { refusal: message, statusCode: failure.statusCode })), this.apiVersion);
118
125
  }
119
126
  case "notAuthorized": {
120
127
  const message = toRefusalMessage(failure.message, "Injected authorization refusal.");
121
- return refusalAnswer(new LambderApiRefusal(message.content, { errorMessage: message, notAuthorized: true }), this.apiVersion);
128
+ // A declared code's flag is its declaration's: the refusal is
129
+ // built without one and the declaration has to supply it.
130
+ const declared = message.code !== undefined && endpoint?.codes.has(message.code) === true;
131
+ const checked = checkedRefusal(request.apiName, endpoint, new LambderApiRefusal(message.content, { refusal: message, ...(declared ? {} : { notAuthorized: true }) }));
132
+ if (!checked.notAuthorized) {
133
+ throw new Error(`LambderMockApp: the injected notAuthorized failure names the code "${String(message.code)}", which is not declared notAuthorized. Inject it as a refusal, or declare the flag on the code.`);
134
+ }
135
+ return refusalAnswer(checked, this.apiVersion);
122
136
  }
123
137
  case "sessionExpired": return sessionExpiredAnswer(this.apiVersion);
124
138
  case "versionExpired": return versionExpiredAnswer(this.apiVersion);
@@ -14,7 +14,10 @@ import type { LambderApiRequest } from "../api/LambderApiRequest.js";
14
14
  import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
15
15
  import type { LambderApiDefinition } from "../api/LambderApiDefinition.js";
16
16
  import type { LambderApiEnvelopeBody } from "../shared/wire/LambderApiContract.js";
17
- import type { LambderAppRefusalMessage } from "../shared/wire/LambderApiRefusal.js";
17
+ import type { LambderUncheckedRefusalMessage, LambderDeclaredRefuse } from "../shared/wire/LambderApiRefusal.js";
18
+ import type { LambderRateLimitMessage } from "../shared/wire/LambderApiOptionValues.js";
19
+ import type { LambderContractRefusalsOf } from "../shared/wire/LambderApiContract.js";
20
+ import type { LambderHandlerRefusalsOf } from "../api/LambderApiRefusals.js";
18
21
  import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
19
22
  import type LambderSessionController from "../session/LambderSessionController.js";
20
23
  export type LambderMockInputOf<C, K extends keyof C> = C[K] extends {
@@ -67,7 +70,16 @@ export type LambderMockSessionCallContext<S = any> = Omit<LambderMockCallContext
67
70
  session: LambderSessionRecord<S>;
68
71
  };
69
72
  /** What a mock handler for endpoint K receives. */
70
- export type LambderMockContext<C, K extends keyof C, S, G> = Omit<LambderMockCallContext<S>, "apiName" | "session" | "guardData"> & {
73
+ /**
74
+ * The codes endpoint K may refuse with, as a mock handler raises them. Where
75
+ * the mock init declared the vocabulary (declareRefusals), each code's data is
76
+ * its schema's input form and the mock parses it as the server does; where it
77
+ * declared none, the contract's wire form, sent as given.
78
+ */
79
+ export type LambderMockRefusalsOf<C, K extends keyof C, TVocabulary> = [
80
+ TVocabulary
81
+ ] extends [never] ? LambderContractRefusalsOf<C, K> : LambderHandlerRefusalsOf<TVocabulary, keyof LambderContractRefusalsOf<C, K> & string>;
82
+ export type LambderMockContext<C, K extends keyof C, S, G, TVocabulary = never, TCodesRequired extends boolean = false> = Omit<LambderMockCallContext<S>, "apiName" | "session" | "guardData"> & {
71
83
  apiName: K;
72
84
  /** The payload as posted; never optional, the way a validated payload reaches a server handler. */
73
85
  payload: LambderMockInputOf<C, K>;
@@ -79,6 +91,14 @@ export type LambderMockContext<C, K extends keyof C, S, G> = Omit<LambderMockCal
79
91
  guardInputs: [LambderContractGuardInputsOf<C, K>] extends [never] ? undefined : LambderContractGuardInputsOf<C, K>;
80
92
  /** The key the caller sent, when it sent a string; anything else is not a key and reaches a handler as undefined, having already been refused wherever it mattered. */
81
93
  idempotencyKey: string | undefined;
94
+ /**
95
+ * Refuses the call, as a server handler's ctx.refuse does: `code` is one
96
+ * of the endpoint's declared codes, and `data` is the code's data as a
97
+ * handler hands it over (see LambderMockRefusalsOf). Where the mock
98
+ * declared the vocabulary with requireCodes, a code is required. Write
99
+ * `return ctx.refuse(...)` where the code after it relies on it.
100
+ */
101
+ refuse: LambderDeclaredRefuse<LambderMockRefusalsOf<C, K, TVocabulary>, TCodesRequired>;
82
102
  };
83
103
  /**
84
104
  * The guard map a mock app must declare: one guard per name any endpoint of
@@ -99,7 +119,7 @@ export type LambderMockGuards<C, S = any> = {
99
119
  session?: false;
100
120
  };
101
121
  };
102
- export type LambderMockHandler<C, K extends keyof C, S, G> = (ctx: LambderMockContext<C, K, S, G>) => LambderMockOutputOf<C, K> | Promise<LambderMockOutputOf<C, K>>;
122
+ export type LambderMockHandler<C, K extends keyof C, S, G, TVocabulary = never, TCodesRequired extends boolean = false> = (ctx: LambderMockContext<C, K, S, G, TVocabulary, TCodesRequired>) => LambderMockOutputOf<C, K> | Promise<LambderMockOutputOf<C, K>>;
103
123
  /**
104
124
  * The guards field of an entry: required whenever the contract declares any
105
125
  * guard for the endpoint, and type-equal to the server's own declaration.
@@ -196,7 +216,7 @@ type LambderMockDerivedFields = {
196
216
  * pinned to the contract, or, with `TDerived` (a mock created with the
197
217
  * generated `apiOptions` table), the handler alone.
198
218
  */
199
- export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
219
+ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false, TVocabulary = never, TCodesRequired extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
200
220
  /**
201
221
  * A schema to validate the posted payload against, so the mock answers
202
222
  * 422 exactly as the server would. Optional, and the mock's own: the
@@ -207,7 +227,7 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
207
227
  * LambderMockInputPin).
208
228
  */
209
229
  input?: TInputSchema & LambderMockInputPin<C, K, TInputSchema>;
210
- handler: LambderMockHandler<C, K, S, G>;
230
+ handler: LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired>;
211
231
  };
212
232
  /**
213
233
  * What publicApi/sessionApi accept. On a mock created with the generated
@@ -219,7 +239,7 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
219
239
  * guardless endpoint could drop its rate limit and idempotency through the
220
240
  * bare form.
221
241
  */
222
- export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
242
+ export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false, TVocabulary = never, TCodesRequired extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true, TVocabulary, TCodesRequired> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired> | LambderMockEntryOptions<C, K, S, G, TInputSchema, false, TVocabulary, TCodesRequired> : LambderMockEntryOptions<C, K, S, G, TInputSchema, false, TVocabulary, TCodesRequired>;
223
243
  /** One registry entry: the endpoint's definition as the pipeline runs it, and its handler (null when registered as not mocked). */
224
244
  export type LambderMockEntry<C, K extends keyof C & string> = {
225
245
  readonly name: K;
@@ -317,9 +337,12 @@ export type LambderMockLatency = number | {
317
337
  * uses for the real thing, so an injected 429 carries the Retry-After a
318
338
  * real one does. `network` rejects the transport; `timeout` waits for the
319
339
  * caller's own abort (a call with no timeout configured waits for its
320
- * external signal, or for ever, which is what a timeout is).
340
+ * external signal, or for ever, which is what a timeout is). TMessage is the
341
+ * endpoint's refusal message, so an injected refusal names one of its
342
+ * declared codes with that code's data, and is checked against the
343
+ * declaration as a real one is.
321
344
  */
322
- export type LambderMockFailure = {
345
+ export type LambderMockFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = {
323
346
  reason: "network";
324
347
  } | {
325
348
  reason: "timeout";
@@ -327,11 +350,11 @@ export type LambderMockFailure = {
327
350
  reason: "server";
328
351
  } | {
329
352
  reason: "refusal";
330
- message?: LambderAppRefusalMessage | string;
353
+ message?: TMessage | string;
331
354
  statusCode?: LambderHttpStatusCode;
332
355
  } | {
333
356
  reason: "notAuthorized";
334
- message?: LambderAppRefusalMessage | string;
357
+ message?: TMessage | string;
335
358
  } | {
336
359
  reason: "sessionExpired";
337
360
  } | {
@@ -339,7 +362,7 @@ export type LambderMockFailure = {
339
362
  } | {
340
363
  reason: "rateLimited";
341
364
  retryAfterSeconds?: number;
342
- message?: LambderAppRefusalMessage;
365
+ message?: LambderRateLimitMessage;
343
366
  };
344
367
  /** Every sibling in the package spells this `reason`: an outcome's, a transport failure's, an invoke failure's. */
345
368
  export type LambderMockFailureReason = LambderMockFailure["reason"];
package/dist/mock.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
10
10
  export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
11
11
  export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
12
- export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
12
+ export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
13
13
  export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
14
14
  export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
15
15
  export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
@@ -31,8 +31,9 @@ export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotency
31
31
  export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
32
32
  export type { LambderSessionCrypto } from "./session/LambderSessionCrypto.js";
33
33
  export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
34
- export type { LambderRefusalMessage } from "./shared/wire/LambderApiRefusal.js";
35
- export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
34
+ export type { LambderRefusalMessage, LambderDeclaredRefuse, LambderDeclaredRefuseOptions } from "./shared/wire/LambderApiRefusal.js";
35
+ export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
36
+ export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
36
37
  export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
37
38
  export type { LambderApiRequest } from "./api/LambderApiRequest.js";
38
39
  export type { LambderApiAnswer } from "./api/LambderApiAnswer.js";
package/dist/mock.js CHANGED
@@ -31,6 +31,7 @@ export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
31
31
  export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
32
32
  export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
33
33
  export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
34
- // The outcome assertions a test over the mock app narrows with; the same two
35
- // `lambder/testing` exports for a test over the real server.
36
- export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
34
+ export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
35
+ // The outcome assertions a test over the mock app narrows with; the same
36
+ // ones `lambder/testing` exports for a test over the real server.
37
+ export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
@@ -13,14 +13,29 @@ export type LambderUploadFileFacts = {
13
13
  /** SHA-256 of the file's bytes as base64, the form storage checks an upload against: 43 characters and one pad. */
14
14
  sha256Base64: string;
15
15
  };
16
- /** Everything the browser needs to post one file to storage, and until when. */
16
+ /**
17
+ * Everything the browser needs to send one file to storage, and until when,
18
+ * in one of the two forms a store signs. `POST` is a form (S3's presigned
19
+ * POST), `PUT` the file as the body of a signed URL (a presigned PUT, which
20
+ * Cloudflare R2 and other stores without POST policies take).
21
+ */
17
22
  export type LambderUploadTicket = {
23
+ method: "POST";
18
24
  uploadUrl: string;
19
25
  /** Sent as form fields ahead of the file, which storage wants last. */
20
26
  formFields: Record<string, string>;
21
27
  /** Epoch milliseconds after which storage refuses the ticket. */
22
28
  expiresAt: number;
29
+ } | {
30
+ method: "PUT";
31
+ uploadUrl: string;
32
+ /** Sent exactly as given, each one signed into the URL; the browser adds the length itself. */
33
+ headers: Record<string, string>;
34
+ /** Epoch milliseconds after which storage refuses the ticket. */
35
+ expiresAt: number;
23
36
  };
37
+ /** How a bucket's tickets send a file: the `method` of the tickets it signs. */
38
+ export type LambderUploadMethod = LambderUploadTicket["method"];
24
39
  /** What a bucket holds under a key, compared with what the browser said it would upload. */
25
40
  export type LambderUploadVerdict = {
26
41
  verified: true;
@@ -38,7 +53,7 @@ export type LambderUploadContentDisposition = {
38
53
  };
39
54
  /**
40
55
  * What storage keeps beside an object's bytes. A ticket pins every one of
41
- * these in its signed policy, so the browser posts them unchanged.
56
+ * these in its signature, so the browser sends them unchanged.
42
57
  */
43
58
  export type LambderUploadObjectOptions = {
44
59
  /**
@@ -91,7 +106,7 @@ export interface LambderUploadBucket {
91
106
  * Signs a ticket for exactly the file the browser described, or refuses
92
107
  * (a LambderApiRefusal, code `lambder/upload-empty`,
93
108
  * `lambder/upload-type-rejected` or `lambder/upload-too-large`) when the
94
- * rule does not accept it. Storage then enforces every fact: the post
109
+ * rule does not accept it. Storage then enforces every fact: the upload
95
110
  * fails unless the body has that byte size, that content type and that
96
111
  * SHA-256, so what verifies later is what was described here. `object`
97
112
  * is what the stored object carries besides, pinned the same way, and
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * An API payload tops out near a few megabytes once a file is base64, and a
6
6
  * Lambda's request body at six, so anything larger (a scanned lease, a
7
- * signed PDF, a video) is posted by the browser to the bucket itself, with a
7
+ * signed PDF, a video) is sent by the browser to the bucket itself, with a
8
8
  * ticket the server signed beforehand. The ticket pins everything about the
9
9
  * upload: the key, the exact byte size, the content type and the SHA-256 of
10
10
  * the bytes, all enforced by the storage, so the browser can only ever store
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * The conversation is the same three steps whatever the app stores: the
14
14
  * browser describes the file (LambderUploadFileFacts), the app's endpoint
15
- * answers with a ticket (LambderUploadTicket), and after the post the app's
15
+ * answers with a ticket (LambderUploadTicket), and after the upload the app's
16
16
  * confirm endpoint asks the bucket what arrived before its record counts as
17
17
  * uploaded. The server half is a LambderUploadBucket, the browser half is
18
18
  * LambderUploadRunner.
@@ -48,3 +48,14 @@ export type LambderReadonlyDeep<T, TDepth extends unknown[] = []> = TDepth["leng
48
48
  export type LambderNonEmptyOptionMap<TMap> = {
49
49
  [K in keyof TMap]-?: Required<Pick<TMap, K>> & Omit<TMap, K>;
50
50
  }[keyof TMap];
51
+ /**
52
+ * Rejects a key the options type does not have, which `const TOptions` would
53
+ * otherwise wave through: inferring a generic from an object literal switches
54
+ * excess-property checking off for the whole literal, so
55
+ * `requireSessionApiGuard` (no trailing "s") or `maxResponseByte` would
56
+ * compile, be dropped in silence, and leave the app on the default. That is
57
+ * worst for the two require*ApiGuards flags, which exist to make a missing
58
+ * authorization declaration a compile error. Mapping every surplus key to
59
+ * `never` puts the error back on the key itself.
60
+ */
61
+ export type LambderNoExtraKeys<TOptions, TShape> = TOptions & Record<Exclude<keyof TOptions, keyof TShape>, never>;