lambder 7.0.2 → 7.2.0

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 (40) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/api/LambderApiDefinition.d.ts +6 -3
  3. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  4. package/dist/api/LambderApiEnvelope.js +1 -1
  5. package/dist/api/LambderApiGuards.d.ts +5 -0
  6. package/dist/api/LambderApiGuards.js +2 -2
  7. package/dist/api/LambderApiPipeline.d.ts +41 -11
  8. package/dist/api/LambderApiPipeline.js +55 -13
  9. package/dist/api/LambderApiRequest.d.ts +3 -1
  10. package/dist/api/LambderApiRequest.js +1 -0
  11. package/dist/api/LambderApiSignature.d.ts +19 -0
  12. package/dist/api/LambderApiSignature.js +96 -0
  13. package/dist/client/LambderCaller.d.ts +13 -0
  14. package/dist/client/LambderCaller.js +21 -1
  15. package/dist/client/LambderReloadLoopBreaker.d.ts +38 -0
  16. package/dist/client/LambderReloadLoopBreaker.js +71 -0
  17. package/dist/client.d.ts +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +17 -1
  20. package/dist/core/Lambder.js +30 -5
  21. package/dist/core/LambderCreateOptions.d.ts +29 -0
  22. package/dist/core/LambderCreateOptions.js +0 -5
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -0
  25. package/dist/invoke/LambderInvokeCaller.d.ts +12 -1
  26. package/dist/invoke/LambderInvokeCaller.js +10 -1
  27. package/dist/invoke/LambderLambdaEvent.d.ts +1 -0
  28. package/dist/invoke/LambderLambdaEvent.js +1 -0
  29. package/dist/mock/LambderMockApp.d.ts +2 -2
  30. package/dist/mock/LambderMockApp.js +6 -4
  31. package/dist/mock/LambderMockCreateOptions.d.ts +6 -1
  32. package/dist/mock/LambderMockCreateOptions.js +0 -8
  33. package/dist/mock/LambderMockTypes.d.ts +2 -0
  34. package/dist/shared/transport/LambderApiTransport.d.ts +3 -0
  35. package/dist/shared/transport/LambderApiTransport.js +2 -0
  36. package/dist/shared/wire/LambderApiSignature.d.ts +46 -0
  37. package/dist/shared/wire/LambderApiSignature.js +45 -0
  38. package/dist/shared/wire/LambderVersionOrder.d.ts +11 -0
  39. package/dist/shared/wire/LambderVersionOrder.js +28 -0
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,118 @@ 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
+ ## [7.1.5] - 2026-09-15
13
+
14
+ ### Added
15
+
16
+ - **`minApiVersion`**, on `create()` and on the mock runtime: a floor under
17
+ the signature gate. A call naming a `version` below it answers
18
+ `versionExpired` whatever its signature says, which is the lever for a
19
+ change the digest cannot see (a security fix, a field whose meaning changed
20
+ under the same shape). Versions compare as dotted numbers, so `1.2.10` is
21
+ above `1.2.9`; a call naming no version is not judged, as one carrying no
22
+ signature is not gated. Creation refuses a floor that is not a dotted
23
+ version; a floor above `apiVersion` is taken as `apiVersion`, with a
24
+ warning, so a mistaken floor cannot refuse the build's own clients.
25
+ `compareDottedVersions` and `isDottedVersion` are exported from both
26
+ entries.
27
+
28
+ ### Changed
29
+
30
+ - **`apiNameKeyOf` memoizes nothing.** It hashed each name it was asked about
31
+ into a module-level map, kept for the life of the process. On the server
32
+ that name comes off the wire, in the pre-pass that runs before anything has
33
+ checked that it is an endpoint at all and before any rate limit, so a
34
+ request naming anything grew the map by an entry, and one naming a
35
+ megabyte's worth grew it by a megabyte. Nothing is kept now: the digest the
36
+ gate rests on is the generator's, computed once at build time, and what is
37
+ left per call is one hash of a short name against a map already in memory.
38
+ - **`apiVersion` must be a dotted version** (`"1.2.10"`), on `create()` and on
39
+ the mock runtime, where any string was taken before. `minApiVersion` reads
40
+ it as numbers, and a stamp the comparison cannot read (`"dev"`, a commit
41
+ sha, a build date) counted as zero, so setting a floor answered
42
+ `versionExpired` to every client of the build that set it. The clamp that
43
+ exists to stop exactly that could not see the case. Creation refuses it
44
+ instead, whether or not a floor is set today.
45
+ - **The server reads the generated map too.** `create()` takes
46
+ `apiSignatures`, the same file the frontend ships with, and the pipeline
47
+ compares a call's signature with the map's entry; nothing is digested at
48
+ request time any more. The one computation is the generator's, so a schema
49
+ digested differently on two builds costs its clients one reload per deploy
50
+ and can no longer leave an endpoint refused for every caller.
51
+ `LambderApiSignatureDigests` and the `LambderApiSignatureSource` type are
52
+ gone, and `LambderApiPipeline.prepare(request)` takes no definition. A
53
+ server given no map gates nothing, as the mock runtime does.
54
+ - **The signature digest hashes shape, not values.** The `default` keyword
55
+ zod emits is dropped before hashing: a default's value is server
56
+ behaviour, and for a function default (`.default(() => new Date())`,
57
+ `.prefault`, `.catch`) zod wrote whatever the function returned at
58
+ conversion, so the endpoint digested differently on every computation and
59
+ the generated map could never match the server. Whether the field may be
60
+ omitted still counts, through `required`, which is now sorted as well so
61
+ that reordering fields changes nothing. Endpoints with a defaulted field
62
+ get a new signature once.
63
+
64
+ ## [7.1.1] - 2026-09-15
65
+
66
+ The version gate is replaced by a signature gate: whether a client is stale is
67
+ decided per endpoint, by a signature of the endpoint's client-facing shape that
68
+ the client carries and the server digests from its own registrations. A deploy
69
+ now forces a reload only on the clients that call an endpoint whose shape
70
+ changed; a tab whose endpoints are unchanged keeps working. The wire format
71
+ gains one optional request field, `signature`; answers are unchanged, and a
72
+ caller that sends no signature is treated as before, minus the version check.
73
+
74
+ ### Changed
75
+
76
+ - **`apiVersion` no longer gates.** A request naming another version is not
77
+ refused any more; the string is stamped on every answer's envelope and does
78
+ nothing else. `LambderApiPipeline.isVersionStale` is gone, and `create()`
79
+ no longer refuses `apiVersion: ""`, since there is no gate for it to turn
80
+ off. An app that relied on the equality gate hands its callers
81
+ `apiSignatures` instead (below).
82
+ - **`LambderApiPipeline.prepare(request, definition)`** takes the definition
83
+ the request's name resolved to, or null, because the signature gate needs
84
+ it. Both adapters resolve the name before the pre-pass now, which is also
85
+ why a signed request for an unknown name answers `versionExpired` rather
86
+ than `apiNotFound`: the client was built against a contract that had it.
87
+ - **`LambderApiRequest` carries `signature: string | null`**, so a request
88
+ literal built by hand needs the field. `LambderApiDefinition` gains an
89
+ optional `output` schema, which `addApi`/`addSessionApi` record.
90
+
91
+ ### Added
92
+
93
+ - **`lambder.apiSignatures()`**: every registered endpoint's signature keyed
94
+ by its hashed name, a `LambderApiSignatureMap`. A generator imports the
95
+ finished instance, awaits this, and writes the object to a file the
96
+ frontend ships with its build. The signature covers the name, the mode, the
97
+ input and output schemas as JSON Schema, each declared guard's schema, and
98
+ whether the endpoint takes an idempotency key; rate limits, guard
99
+ parameters and the handler are left out, so changing them never forces a
100
+ reload. Keys are hashed so the file lists no endpoint names. See
101
+ docs/apis.md, "Signatures: when a client must update".
102
+ - **`apiSignatures` on `LambderCaller` and `LambderInvokeCaller`**: the
103
+ generated map. Each call sends its endpoint's signature; a name the map
104
+ lacks fails the call before it is sent, as an `unknown` outcome whose error
105
+ says to regenerate. Optional: a caller without the map is never gated.
106
+ - **`apiSignatures` on the mock runtime**: given the same map, the runtime
107
+ refuses a stale signature exactly as the server would; without it every
108
+ signature passes, since it holds no server schema to digest. The request
109
+ event carries `signature`.
110
+ - **Reload-loop protection in `LambderCaller`.** A `versionExpired` for the
111
+ same endpoint and signature within five minutes of the last one means the
112
+ reload brought the same bundle back (a frontend shipped with a stale map, a
113
+ cached bundle, a server deploy that failed behind it). The handler is not
114
+ called again; the failure goes to `errorHandler` and the outcome still says
115
+ `versionExpired`. Once confirmed, every `versionExpired` inside the window
116
+ counts, and after it a reload is allowed again. Kept per tab in
117
+ `sessionStorage`, in memory where there is none. `RELOAD_LOOP_WINDOW_MS` is
118
+ exported.
119
+ - `apiNameKeyOf`, `lookupApiSignature`, `readApiSignature`,
120
+ `API_SIGNATURE_HEX_LENGTH` and the `LambderApiSignatureMap` type from both
121
+ entries; `apiSignatureOf`, `LambderApiSignatureDigests` and the
122
+ `LambderApiSignatureSource` type from the root.
123
+
12
124
  ## [7.0.0] - 2026-09-15
13
125
 
14
126
  A major. The API pipeline moved out of the Lambda server into an isomorphic
@@ -4,9 +4,11 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
4
4
  /**
5
5
  * One endpoint's declaration as the pipeline runs it: what the server's
6
6
  * addApi/addSessionApi options carry, minus the handler, in a shape the mock
7
- * runtime can restate from a type-only contract. The schema is optional
8
- * because the mock has none; when present, input validation runs and the
9
- * handler sees the parsed payload.
7
+ * runtime can restate from a type-only contract. The schemas are optional
8
+ * because the mock has none; when input is present, validation runs and the
9
+ * handler sees the parsed payload. Output is read by nothing at request
10
+ * time: it is part of the endpoint's signature (apiSignatureOf), which is
11
+ * what a client's build is checked against.
10
12
  */
11
13
  export type LambderApiDefinition = {
12
14
  name: string;
@@ -15,4 +17,5 @@ export type LambderApiDefinition = {
15
17
  rateLimit?: LambderRateLimitOptionValue;
16
18
  idempotency?: LambderApiIdempotencyOption;
17
19
  input?: z.ZodType;
20
+ output?: z.ZodType;
18
21
  };
@@ -54,7 +54,7 @@ export declare const validationAnswer: (zodError: z.ZodError, logList?: unknown[
54
54
  export declare const apiNotFoundAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
55
55
  /** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
56
56
  export declare const sessionExpiredAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
57
- /** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
57
+ /** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
58
58
  export declare const versionExpiredAnswer: (apiVersion: string | null | undefined) => LambderApiAnswer;
59
59
  /** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
60
60
  export declare const invalidPayloadAnswer: (apiVersion: string | null | undefined, message: string) => LambderApiAnswer;
@@ -165,7 +165,7 @@ export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(buildAp
165
165
  }));
166
166
  /** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
167
167
  export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { sessionExpired: true, logList }));
168
- /** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
168
+ /** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
169
169
  export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { versionExpired: true }));
170
170
  /** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
171
171
  export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
@@ -277,6 +277,11 @@ type GuardInputsEntries<TGuards, TOpt> = {
277
277
  };
278
278
  /** The guardInputs map an API's contract requires clients to send; never when no declared guard uses guardInput mode. */
279
279
  export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
280
+ /** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
281
+ export declare const toGuardEntries: (value?: LambderGuardsOptionValue) => {
282
+ name: string;
283
+ param: unknown;
284
+ }[];
280
285
  /**
281
286
  * Runtime side of the guards subsystem: holds the defined guards, asserts
282
287
  * API registrations against them at startup, and executes an API's declared
@@ -7,8 +7,8 @@ import { LAMBDER_RESPONSE_BRAND, isLambderResponseLike } from "../shared/util/La
7
7
  * as server guards and run through the same engine.
8
8
  */
9
9
  export const lambderGuardBuilder = () => ((guard) => guard);
10
- /** Normalize the three guards-option forms into ordered { name, param } entries. Internal to the engine: nothing outside it reads a guards option. */
11
- const toGuardEntries = (value) => {
10
+ /** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
11
+ export const toGuardEntries = (value) => {
12
12
  if (value === undefined)
13
13
  return [];
14
14
  if (typeof value === "string")
@@ -4,6 +4,7 @@ import type { LambderApiAnswer } from "./LambderApiAnswer.js";
4
4
  import type { LambderApiCallContext } from "./LambderApiCallContext.js";
5
5
  import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
6
6
  import type { LambderApiDefinition } from "./LambderApiDefinition.js";
7
+ import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
7
8
  import type { LambderApiGuard } from "./LambderApiGuards.js";
8
9
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
9
10
  import type { LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
@@ -28,8 +29,24 @@ export type LambderApiSessionsConfig<TSessionData> = {
28
29
  cookieOptions?: LambderSessionCookieOptions;
29
30
  };
30
31
  export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> = {
31
- /** Enables the version gate: a request naming another version answers versionExpired. */
32
+ /** Stamped on every answer's envelope as apiVersion, so a client can tell which build answered; null when the app set none. */
32
33
  apiVersion?: string | null;
34
+ /**
35
+ * The floor under the signature gate: a request naming a `version` below
36
+ * it answers versionExpired whatever its signature says. Dotted numbers
37
+ * ("1.2.10"), compared segment by segment. A floor above apiVersion is
38
+ * taken as apiVersion, so a mistaken floor cannot refuse the build's own
39
+ * clients.
40
+ */
41
+ minApiVersion?: string | null;
42
+ /**
43
+ * Enables the signature gate: the generated map (Lambder.apiSignatures(),
44
+ * the same file the client ships with). A request carrying a signature
45
+ * that is not the map's entry for its endpoint answers versionExpired,
46
+ * and so does one for an endpoint the map does not hold: that client was
47
+ * built against another contract. Without a map every signature passes.
48
+ */
49
+ apiSignatures?: LambderApiSignatureMap;
33
50
  /** Ceiling on what a compressed request payload may restore to. Default: 20,000,000. */
34
51
  maxRequestPayloadBytes?: number;
35
52
  onInvalidInput?: LambderApiInputRefusal<TCtx>;
@@ -52,7 +69,7 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
52
69
  * are adapters over this class; neither reimplements a step of it.
53
70
  *
54
71
  * ```
55
- * version gate → restore payload → rate limits that need no session
72
+ * version floor → signature gate → restore payload → rate limits that need no session
56
73
  * → session (session mode) → idempotency replay → the remaining rate limits
57
74
  * → guards → input validation → exec, inside the idempotency claim
58
75
  * → drain response headers → answer
@@ -71,10 +88,12 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
71
88
  */
72
89
  export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> {
73
90
  readonly apiVersion: string | null;
91
+ readonly minApiVersion: string | null;
74
92
  private readonly policies;
75
93
  private readonly maxRequestPayloadBytes;
76
94
  private readonly onInvalidInput;
77
95
  private readonly sessions;
96
+ private readonly apiSignatures;
78
97
  constructor(options?: LambderApiPipelineOptions<TCtx, TSessionData>);
79
98
  /** True when a session manager was configured. */
80
99
  get hasSessions(): boolean;
@@ -90,28 +109,39 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
90
109
  static sessionInfoOf(request: LambderApiRequest): LambderSessionRequestInfo;
91
110
  /** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
92
111
  assertRegistration(definition: LambderApiDefinition): void;
93
- /** True when the gate is on and the request names a different version. */
94
- isVersionStale(request: LambderApiRequest): boolean;
95
112
  /**
96
113
  * The answer for a request naming no registered API: the apiNotFound
97
114
  * refusal, carrying whatever the call already wrote (a CORS header, a
98
- * cookie eviction). No version gate here: both adapters run prepare() on
99
- * the way in, before a name is resolved, so a stale client has already
100
- * been answered by the time anything asks for an unknown name.
115
+ * cookie eviction). No signature gate here: both adapters run prepare()
116
+ * on the way in, so a signed request for a name the map does not hold (a
117
+ * client built against a contract that had it) has already been answered
118
+ * versionExpired by the time anything asks for an unknown name.
101
119
  */
102
120
  answerUnknownApi(request: LambderApiRequest, ctx?: TCtx): LambderApiAnswer;
103
121
  /**
104
122
  * The steps that come before anything may read the request: the version
105
- * gate, then the compressed-payload restore that every later reader (a
106
- * rate-limit key slice, a guard, the input schema) depends on having
107
- * happened.
123
+ * floor, the signature gate, then the compressed-payload restore that
124
+ * every later reader (a rate-limit key slice, a guard, the input schema)
125
+ * depends on having happened.
126
+ *
127
+ * The floor answers versionExpired to a request naming a version below
128
+ * minApiVersion whatever its signature says: the lever for a change the
129
+ * digest cannot see (a security fix, a field whose meaning changed under
130
+ * the same shape). A request naming no version is not judged by it, as
131
+ * one carrying no signature is not gated.
132
+ *
133
+ * The gate compares the signature the request carries with the map's
134
+ * entry for the endpoint it names. A match runs; anything else, another
135
+ * entry or none, is a client built against another shape of this
136
+ * endpoint or against an endpoint that no longer exists, and is answered
137
+ * versionExpired. A request carrying no signature is never gated.
108
138
  *
109
139
  * Public and named because the server runs them earlier than run() does,
110
140
  * on the way in, so that its hooks see a plain payload and a stale client
111
141
  * is answered before any of them, whether or not the name it asked for
112
142
  * exists. run() calls it too, so an adapter that has no such step still
113
143
  * gets the whole protocol. Calling it twice is safe by construction: the
114
- * gate is a pure comparison and the restore has already removed the wire
144
+ * gates are comparisons and the restore has already removed the wire
115
145
  * fields it reads.
116
146
  *
117
147
  * Returns the answer that ends the call, or null when the request is
@@ -1,9 +1,11 @@
1
1
  import { restoreCompressedPayload } from "./LambderApiRequest.js";
2
+ import { lookupApiSignature } from "../shared/wire/LambderApiSignature.js";
2
3
  import { apiNotFoundAnswer, invalidPayloadAnswer, refusalAnswer, sessionExpiredAnswer, validationAnswer, versionExpiredAnswer, } from "./LambderApiEnvelope.js";
3
4
  import { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./LambderApiValidationRefusal.js";
4
5
  import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
5
6
  import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/wire/LambderRequestPayload.js";
6
7
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
8
+ import { compareDottedVersions, isDottedVersion } from "../shared/wire/LambderVersionOrder.js";
7
9
  import { LambderApiPolicyEngine } from "./LambderApiPolicyEngine.js";
8
10
  import LambderSessionController, { assertSessionCookiePrefixes, } from "../session/LambderSessionController.js";
9
11
  import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
@@ -13,7 +15,7 @@ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } fro
13
15
  * are adapters over this class; neither reimplements a step of it.
14
16
  *
15
17
  * ```
16
- * version gate → restore payload → rate limits that need no session
18
+ * version floor → signature gate → restore payload → rate limits that need no session
17
19
  * → session (session mode) → idempotency replay → the remaining rate limits
18
20
  * → guards → input validation → exec, inside the idempotency claim
19
21
  * → drain response headers → answer
@@ -32,12 +34,37 @@ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } fro
32
34
  */
33
35
  export class LambderApiPipeline {
34
36
  apiVersion;
37
+ minApiVersion;
35
38
  policies = new LambderApiPolicyEngine();
36
39
  maxRequestPayloadBytes;
37
40
  onInvalidInput;
38
41
  sessions;
42
+ apiSignatures;
39
43
  constructor(options = {}) {
40
44
  this.apiVersion = options.apiVersion ?? null;
45
+ // Dotted, always, whether or not a floor is set today: the floor reads
46
+ // this string as numbers, and a stamp the comparison cannot read
47
+ // ("dev", a commit sha) counts as 0, so setting minApiVersion later
48
+ // would answer versionExpired to every client of this very build.
49
+ if (this.apiVersion !== null && !isDottedVersion(this.apiVersion)) {
50
+ throw new Error(`Lambder: apiVersion must be a dotted version such as "1.2.10", got ${JSON.stringify(this.apiVersion)}.`);
51
+ }
52
+ this.minApiVersion = options.minApiVersion ?? null;
53
+ if (this.minApiVersion !== null) {
54
+ if (!isDottedVersion(this.minApiVersion)) {
55
+ throw new Error(`Lambder: minApiVersion must be a dotted version such as "1.2.10", got ${JSON.stringify(this.minApiVersion)}.`);
56
+ }
57
+ // A floor above the version this server stamps on its answers
58
+ // would refuse the very clients this build serves, and the first
59
+ // symptom would be every tab reloading. The lower of the two is
60
+ // the most a floor can mean here, so that is what it becomes, and
61
+ // the mistake is said once at creation.
62
+ if (this.apiVersion !== null && compareDottedVersions(this.minApiVersion, this.apiVersion) > 0) {
63
+ console.warn(`Lambder: minApiVersion ${this.minApiVersion} is above apiVersion ${this.apiVersion}; the floor is taken as ${this.apiVersion}.`);
64
+ this.minApiVersion = this.apiVersion;
65
+ }
66
+ }
67
+ this.apiSignatures = options.apiSignatures ?? null;
41
68
  this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
42
69
  this.onInvalidInput = options.onInvalidInput ?? null;
43
70
  this.sessions = options.sessions
@@ -90,16 +117,13 @@ export class LambderApiPipeline {
90
117
  assertRegistration(definition) {
91
118
  this.policies.assertRegistration(definition);
92
119
  }
93
- /** True when the gate is on and the request names a different version. */
94
- isVersionStale(request) {
95
- return !!this.apiVersion && !!request.version && request.version !== this.apiVersion;
96
- }
97
120
  /**
98
121
  * The answer for a request naming no registered API: the apiNotFound
99
122
  * refusal, carrying whatever the call already wrote (a CORS header, a
100
- * cookie eviction). No version gate here: both adapters run prepare() on
101
- * the way in, before a name is resolved, so a stale client has already
102
- * been answered by the time anything asks for an unknown name.
123
+ * cookie eviction). No signature gate here: both adapters run prepare()
124
+ * on the way in, so a signed request for a name the map does not hold (a
125
+ * client built against a contract that had it) has already been answered
126
+ * versionExpired by the time anything asks for an unknown name.
103
127
  */
104
128
  answerUnknownApi(request, ctx) {
105
129
  const answer = apiNotFoundAnswer(this.apiVersion, ctx?.logList);
@@ -108,24 +132,42 @@ export class LambderApiPipeline {
108
132
  }
109
133
  /**
110
134
  * The steps that come before anything may read the request: the version
111
- * gate, then the compressed-payload restore that every later reader (a
112
- * rate-limit key slice, a guard, the input schema) depends on having
113
- * happened.
135
+ * floor, the signature gate, then the compressed-payload restore that
136
+ * every later reader (a rate-limit key slice, a guard, the input schema)
137
+ * depends on having happened.
138
+ *
139
+ * The floor answers versionExpired to a request naming a version below
140
+ * minApiVersion whatever its signature says: the lever for a change the
141
+ * digest cannot see (a security fix, a field whose meaning changed under
142
+ * the same shape). A request naming no version is not judged by it, as
143
+ * one carrying no signature is not gated.
144
+ *
145
+ * The gate compares the signature the request carries with the map's
146
+ * entry for the endpoint it names. A match runs; anything else, another
147
+ * entry or none, is a client built against another shape of this
148
+ * endpoint or against an endpoint that no longer exists, and is answered
149
+ * versionExpired. A request carrying no signature is never gated.
114
150
  *
115
151
  * Public and named because the server runs them earlier than run() does,
116
152
  * on the way in, so that its hooks see a plain payload and a stale client
117
153
  * is answered before any of them, whether or not the name it asked for
118
154
  * exists. run() calls it too, so an adapter that has no such step still
119
155
  * gets the whole protocol. Calling it twice is safe by construction: the
120
- * gate is a pure comparison and the restore has already removed the wire
156
+ * gates are comparisons and the restore has already removed the wire
121
157
  * fields it reads.
122
158
  *
123
159
  * Returns the answer that ends the call, or null when the request is
124
160
  * ready to dispatch.
125
161
  */
126
162
  async prepare(request) {
127
- if (this.isVersionStale(request))
163
+ if (this.minApiVersion !== null && request.version !== null && compareDottedVersions(request.version, this.minApiVersion) < 0) {
128
164
  return versionExpiredAnswer(this.apiVersion);
165
+ }
166
+ if (request.signature !== null && this.apiSignatures) {
167
+ const expected = await lookupApiSignature(this.apiSignatures, request.apiName);
168
+ if (expected !== request.signature)
169
+ return versionExpiredAnswer(this.apiVersion);
170
+ }
129
171
  const restored = await restoreCompressedPayload(request, this.maxRequestPayloadBytes);
130
172
  if (!restored.ok)
131
173
  return invalidPayloadAnswer(this.apiVersion, restored.message);
@@ -20,8 +20,10 @@ export declare const lowercaseHeaderNames: (headers: Record<string, string | und
20
20
  */
21
21
  export type LambderApiRequest = {
22
22
  apiName: string;
23
- /** The caller's apiVersion, for the version gate; null when it sent none. */
23
+ /** The caller's apiVersion, informational; null when it sent none. */
24
24
  version: string | null;
25
+ /** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
26
+ signature: string | null;
25
27
  /** The CSRF token the caller posted in the envelope; "" when it holds none. */
26
28
  token: string;
27
29
  siteHost: string;
@@ -50,6 +50,7 @@ export const readApiEnvelope = (post, info) => {
50
50
  return {
51
51
  apiName: post.apiName,
52
52
  version: typeof post.version === "string" ? post.version : null,
53
+ signature: typeof post.signature === "string" ? post.signature : null,
53
54
  token: typeof post.token === "string" ? post.token : "",
54
55
  siteHost: typeof post.siteHost === "string" ? post.siteHost : "",
55
56
  payload: post.payload,
@@ -0,0 +1,19 @@
1
+ import type { LambderApiDefinition } from "./LambderApiDefinition.js";
2
+ import { type LambderApiGuard } from "./LambderApiGuards.js";
3
+ /**
4
+ * The digest of an endpoint's client-facing shape: its name and mode, its
5
+ * input and output schemas as JSON Schema, every guard it declares with the
6
+ * schema that guard validates (the guardInput the client sends separately,
7
+ * or the apiInput slice of the payload), and whether it demands an
8
+ * idempotency key. Anything else about the endpoint (its rate limits, a
9
+ * guard's parameter, the handler) changes nothing for a client and is left
10
+ * out, so changing it never forces a reload.
11
+ *
12
+ * The description is hashed as built, descriptions and titles included: a
13
+ * schema is what the server says it is, and a client built against a
14
+ * different one reloads once. What must hold for the digest to mean anything
15
+ * is that a schema is built from static values: one that reads the clock, a
16
+ * random source or the environment at construction digests differently in
17
+ * the generator's process and on the server.
18
+ */
19
+ export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { toGuardEntries } from "./LambderApiGuards.js";
3
+ import { API_SIGNATURE_HEX_LENGTH } from "../shared/wire/LambderApiSignature.js";
4
+ import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
5
+ /*
6
+ * The digest of an endpoint's client-facing shape, computed once, by the
7
+ * generator, through Lambder.apiSignatures(). Nothing digests at request
8
+ * time: the server and the client both carry the generated map and the
9
+ * pipeline compares entries, so the one computation has nothing to agree
10
+ * with but itself. See LambderApiSignatureMap.
11
+ */
12
+ /**
13
+ * JSON with object keys sorted at every level, so two descriptions of the
14
+ * same shape hash the same whatever order they were built in. Arrays keep
15
+ * their order: a tuple's positions and an enum's values are part of the
16
+ * shape. Undefined entries are dropped, as JSON.stringify would drop them.
17
+ */
18
+ const canonicalJson = (value) => JSON.stringify(sortKeys(value));
19
+ const sortKeys = (value) => {
20
+ if (Array.isArray(value))
21
+ return value.map(sortKeys);
22
+ if (value === null || typeof value !== "object")
23
+ return value;
24
+ const source = value;
25
+ const sorted = {};
26
+ for (const key of Object.keys(source).sort()) {
27
+ if (source[key] !== undefined)
28
+ sorted[key] = sortKeys(source[key]);
29
+ }
30
+ return sorted;
31
+ };
32
+ /**
33
+ * Two edits to every node zod emits, before it is hashed.
34
+ *
35
+ * The `default` keyword goes. Its value is server behaviour, not shape: a
36
+ * client never sends it, and its compiled types do not carry it. And for a
37
+ * function default (`.default(() => new Date())`, `.prefault`, `.catch`)
38
+ * zod writes whatever the function returned at conversion time, a clock
39
+ * reading or a random value, which would give the endpoint a different
40
+ * digest on every computation and a generated map that never matches the
41
+ * server. Nothing distinguishes such a default from a constant one once zod
42
+ * has evaluated it, so every default goes, and the one thing about a default
43
+ * a client can see, that the field may be omitted, stays through `required`.
44
+ *
45
+ * `required` is sorted. It is a set, and the order fields are declared in is
46
+ * not shape either; left as emitted, reordering two fields forced a reload.
47
+ */
48
+ const keepShapeOnly = (node) => {
49
+ delete node.default;
50
+ if (Array.isArray(node.required))
51
+ node.required.sort();
52
+ };
53
+ /**
54
+ * A schema as JSON Schema, as zod emits it minus what keepShapeOnly removes.
55
+ * A type JSON Schema cannot express (a transform's output, a custom check)
56
+ * becomes `{}` rather than throwing, because a digest has to exist for every
57
+ * endpoint; what the digest cannot see is documented with it.
58
+ */
59
+ const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema) }) : null;
60
+ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasOwnProperty.call(guards, name) ? guards[name] : undefined;
61
+ /**
62
+ * The digest of an endpoint's client-facing shape: its name and mode, its
63
+ * input and output schemas as JSON Schema, every guard it declares with the
64
+ * schema that guard validates (the guardInput the client sends separately,
65
+ * or the apiInput slice of the payload), and whether it demands an
66
+ * idempotency key. Anything else about the endpoint (its rate limits, a
67
+ * guard's parameter, the handler) changes nothing for a client and is left
68
+ * out, so changing it never forces a reload.
69
+ *
70
+ * The description is hashed as built, descriptions and titles included: a
71
+ * schema is what the server says it is, and a client built against a
72
+ * different one reloads once. What must hold for the digest to mean anything
73
+ * is that a schema is built from static values: one that reads the clock, a
74
+ * random source or the environment at construction digests differently in
75
+ * the generator's process and on the server.
76
+ */
77
+ export const apiSignatureOf = async (definition, guards) => {
78
+ const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
79
+ const guard = ownGuard(guards, name);
80
+ return [name, {
81
+ apiInput: jsonSchemaOf(guard?.apiInput, "input"),
82
+ guardInput: jsonSchemaOf(guard?.guardInput, "input"),
83
+ }];
84
+ });
85
+ guardShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
86
+ const description = {
87
+ name: definition.name,
88
+ mode: definition.mode,
89
+ input: jsonSchemaOf(definition.input, "input"),
90
+ output: jsonSchemaOf(definition.output, "output"),
91
+ guards: guardShapes,
92
+ idempotency: definition.idempotency !== undefined && definition.idempotency !== false,
93
+ };
94
+ const hex = await sha256HexOf(canonicalJson(description));
95
+ return hex.slice(0, API_SIGNATURE_HEX_LENGTH);
96
+ };
@@ -4,6 +4,7 @@ import type { LambderApiContractShape } from '../shared/wire/LambderApiContract.
4
4
  import { type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
5
5
  import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
6
6
  import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
7
+ import { type LambderApiSignatureMap } from '../shared/wire/LambderApiSignature.js';
7
8
  export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
8
9
  export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/wire/LambderCallOptions.js';
9
10
  /** A handler told that something happened, with nothing to hand it. */
@@ -56,7 +57,16 @@ export type LambderCallOptions = LambderSharedCallOptions & {
56
57
  };
57
58
  type LambderCallerBaseOptions = {
58
59
  apiPath: string;
60
+ /** Sent with every call as `version`, informational: the server stamps its own on every answer. */
59
61
  apiVersion?: string;
62
+ /**
63
+ * The server's signature map, generated from its instance
64
+ * (Lambder.apiSignatures()) and shipped with this build. Sent per call as
65
+ * `signature`, so the server answers versionExpired to a call built
66
+ * against another shape of the endpoint and runs every other call. Leave
67
+ * it out and no call is gated.
68
+ */
69
+ apiSignatures?: LambderApiSignatureMap;
60
70
  isCorsEnabled: boolean;
61
71
  /** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
62
72
  timeoutMs?: number;
@@ -101,7 +111,10 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
101
111
  private isCorsEnabled;
102
112
  private apiPath;
103
113
  private apiVersion?;
114
+ private apiSignatures?;
104
115
  private timeoutMs?;
116
+ /** What keeps a stale bundle from reloading itself forever; see the class. */
117
+ private readonly reloadLoopBreaker;
105
118
  /** The calls currently in flight, in the order they started. */
106
119
  fetchTrackerList: FetchTracker[];
107
120
  /** Whether any call is in flight. Derived, so it cannot drift from the list the way a separate flag did. */