lambder 15.0.1 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,28 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [15.0.2] - 2026-10-02
13
+
14
+ A patch: two fixes for apps with a large contract.
15
+
16
+ ### Fixed
17
+
18
+ - **Reading a failure compiles for a contract of hundreds of refusal codes.**
19
+ `failure.reason`, `failure.refusal`, `outcome.ok` and every other read of a
20
+ `LambderApiFailure` or `LambderApiOutcome` over a contract's refusal
21
+ messages failed with TS2590 ("union type too complex to represent") once
22
+ the vocabulary held a few hundred codes, and so did a `LambderCaller` whose
23
+ `fetchEndedHandler` or `errorHandler` read its argument: the refusal arm
24
+ declared its message both on the fields every failure shares and on the arm
25
+ itself, and a read of the intersection crossed the message union with
26
+ itself. Each arm now declares it once. The types describe the same values.
27
+ - **`checkApiRefusals` no longer reports a mock guard's unused codes.** A mock
28
+ guard declares exactly its server guard's codes, which `create()` holds it
29
+ to against `guardDeclarations`, so one that raises fewer (a captcha stand-in
30
+ letting every token through) got an `unused` finding it could not act on.
31
+ Its codes are still checked the other way: one it raises and may not send
32
+ is `undeclared`.
33
+
12
34
  ## [15.0.1] - 2026-10-02
13
35
 
14
36
  A major. Fixes for crashes, misconfigurations that passed silently, and places
@@ -16,7 +16,7 @@ export type LambderRefusalCheckFinding = {
16
16
  at: string;
17
17
  /**
18
18
  * - `undeclared`: a code the handler can reach that it may not send.
19
- * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises.
19
+ * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises. Never a mock guard's, whose codes are its server guard's.
20
20
  * - `uncoded`: a refusal with no code it can reach, where codes are required.
21
21
  * - `unreadable`: a refusal it can reach whose code is not a string literal type.
22
22
  * - `untraced`: a handler handed a typed refuse whose function cannot be
@@ -67,6 +67,24 @@ export const checkApiRefusals = async (options) => {
67
67
  return true;
68
68
  return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
69
69
  };
70
+ /**
71
+ * Whether a registration is a mock guard: `guard` read off a mock init,
72
+ * whose builder Lambder declares in its mock entry, where a server init's
73
+ * is declared in its own.
74
+ */
75
+ const isMockGuard = (call, registration) => {
76
+ if (registration !== "guard" || !ts.isPropertyAccessExpression(call.expression))
77
+ return false;
78
+ const declaration = checker.getSymbolAtLocation(call.expression.name)?.declarations?.[0];
79
+ if (!declaration || !isLambderDeclaration(declaration))
80
+ return false;
81
+ const path = realPathOf(ts, declaration.getSourceFile().fileName);
82
+ const parts = path.split(sep);
83
+ const packageAt = parts.findIndex((part, index) => part === "lambder" && parts[index - 1] === "node_modules");
84
+ // The path inside the package: dist/mock/... installed, src/mock/... over its own sources.
85
+ const inside = packageAt >= 0 ? parts.slice(packageAt + 1) : relative(lambderRoot, path).split(sep);
86
+ return inside[1] === "mock";
87
+ };
70
88
  const where = (node) => {
71
89
  const sourceFile = node.getSourceFile();
72
90
  const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
@@ -390,6 +408,12 @@ export const checkApiRefusals = async (options) => {
390
408
  findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
391
409
  }
392
410
  }
411
+ // A mock guard declares its server guard's codes, which create() holds
412
+ // it to against guardDeclarations. Standing in for that guard it may
413
+ // raise fewer of them, which only lets more through, so a code it
414
+ // never raises is no declaration to trim.
415
+ if (isMockGuard(call, registration))
416
+ return;
393
417
  for (const code of ownRefusalsOf(optionsNode) ?? []) {
394
418
  if (!reached.has(code))
395
419
  findings.push({ handler: name, at, problem: "unused", code });
@@ -42,13 +42,17 @@ export type LambderApiSuccessOutcome<T> = {
42
42
  * message the endpoint can answer with (LambderContractRefusalMessage), on
43
43
  * every arm: a refusal flagged notAuthorized arrives as that reason and still
44
44
  * carries its message.
45
+ *
46
+ * The message is declared once per arm, never here and again on the arm that
47
+ * requires it: an intersection reading `refusal` from both would intersect
48
+ * TMessage with itself, a union of every code a contract declares crossed
49
+ * with itself, which for a contract of a few hundred codes is past what the
50
+ * compiler can represent (TS2590 on any read of a failure).
45
51
  */
46
- type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> = {
52
+ type LambderApiFailureFields = {
47
53
  ok: false;
48
54
  /** HTTP status, when a response was received. */
49
55
  status?: number;
50
- /** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
51
- refusal?: TMessage;
52
56
  /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it, and so may a 503). */
53
57
  retryAfterSeconds?: number;
54
58
  /**
@@ -69,21 +73,26 @@ type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> =
69
73
  * `response` when the server answered with Lambder's own envelope, which is
70
74
  * how a crash detail and a logList arrive with it.
71
75
  */
72
- export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
76
+ export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
73
77
  reason: 'network' | 'timeout' | 'aborted' | 'server' | 'unknown';
78
+ /** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
79
+ refusal?: TMessage;
74
80
  error: Error;
75
81
  response?: LambderApiRefusalEnvelope;
76
82
  };
77
83
  /** HTTP 422: the server rejected the input against the API's schema. Always carries the issues. */
78
- export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
84
+ export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
79
85
  reason: 'validation';
86
+ /** As on a call failure. */
87
+ refusal?: TMessage;
80
88
  zodError: LambderValidationError;
81
89
  };
82
90
  /** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and a `refusal` failure always carries the refusal. */
83
- export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
91
+ export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
84
92
  response: LambderApiRefusalEnvelope;
85
93
  } & ({
86
- reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized';
94
+ reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized'; /** As on a call failure. */
95
+ refusal?: TMessage;
87
96
  } | {
88
97
  reason: 'refusal';
89
98
  refusal: TMessage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "15.0.1",
3
+ "version": "15.0.2",
4
4
  "sideEffects": false,
5
5
  "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
6
6
  "keywords": [