lambder 7.1.4 → 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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,58 @@ 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
+
12
64
  ## [7.1.1] - 2026-09-15
13
65
 
14
66
  The version gate is replaced by a signature gate: whether a client is stale is
@@ -4,7 +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 { LambderApiSignatureSource } from "./LambderApiSignature.js";
7
+ import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
8
8
  import type { LambderApiGuard } from "./LambderApiGuards.js";
9
9
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
10
10
  import type { LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
@@ -32,12 +32,21 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
32
32
  /** Stamped on every answer's envelope as apiVersion, so a client can tell which build answered; null when the app set none. */
33
33
  apiVersion?: string | null;
34
34
  /**
35
- * Enables the signature gate: a request carrying a signature that is not
36
- * the one this source expects for its endpoint answers versionExpired.
37
- * Without a source every signature passes, which is what the mock runtime
38
- * does unless it is given the generated map.
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.
39
40
  */
40
- signatures?: LambderApiSignatureSource;
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;
41
50
  /** Ceiling on what a compressed request payload may restore to. Default: 20,000,000. */
42
51
  maxRequestPayloadBytes?: number;
43
52
  onInvalidInput?: LambderApiInputRefusal<TCtx>;
@@ -60,7 +69,7 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
60
69
  * are adapters over this class; neither reimplements a step of it.
61
70
  *
62
71
  * ```
63
- * signature gate → restore payload → rate limits that need no session
72
+ * version floor → signature gate → restore payload → rate limits that need no session
64
73
  * → session (session mode) → idempotency replay → the remaining rate limits
65
74
  * → guards → input validation → exec, inside the idempotency claim
66
75
  * → drain response headers → answer
@@ -79,11 +88,12 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
79
88
  */
80
89
  export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> {
81
90
  readonly apiVersion: string | null;
91
+ readonly minApiVersion: string | null;
82
92
  private readonly policies;
83
93
  private readonly maxRequestPayloadBytes;
84
94
  private readonly onInvalidInput;
85
95
  private readonly sessions;
86
- private readonly signatures;
96
+ private readonly apiSignatures;
87
97
  constructor(options?: LambderApiPipelineOptions<TCtx, TSessionData>);
88
98
  /** True when a session manager was configured. */
89
99
  get hasSessions(): boolean;
@@ -103,23 +113,27 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
103
113
  * The answer for a request naming no registered API: the apiNotFound
104
114
  * refusal, carrying whatever the call already wrote (a CORS header, a
105
115
  * cookie eviction). No signature gate here: both adapters run prepare()
106
- * on the way in, with the definition the name resolved to or null, so a
107
- * signed request for an unknown name (a client built against a contract
108
- * that had it) has already been answered versionExpired by the time
109
- * anything asks for an unknown name.
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.
110
119
  */
111
120
  answerUnknownApi(request: LambderApiRequest, ctx?: TCtx): LambderApiAnswer;
112
121
  /**
113
- * The steps that come before anything may read the request: the
114
- * signature gate, then the compressed-payload restore that every later
115
- * reader (a rate-limit key slice, a guard, the input schema) depends on
116
- * having happened.
122
+ * The steps that come before anything may read the request: the version
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.
117
132
  *
118
- * The gate compares the signature the request carries with the one the
119
- * source expects for the endpoint the name resolved to (`definition`,
120
- * null for a name the adapter does not know). A match runs; anything
121
- * else is a client built against another shape of this endpoint, or
122
- * against an endpoint that no longer exists, and is answered
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
123
137
  * versionExpired. A request carrying no signature is never gated.
124
138
  *
125
139
  * Public and named because the server runs them earlier than run() does,
@@ -127,13 +141,13 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
127
141
  * is answered before any of them, whether or not the name it asked for
128
142
  * exists. run() calls it too, so an adapter that has no such step still
129
143
  * gets the whole protocol. Calling it twice is safe by construction: the
130
- * gate compares against a memoized digest and the restore has already
131
- * removed the wire fields it reads.
144
+ * gates are comparisons and the restore has already removed the wire
145
+ * fields it reads.
132
146
  *
133
147
  * Returns the answer that ends the call, or null when the request is
134
148
  * ready to dispatch.
135
149
  */
136
- prepare(request: LambderApiRequest, definition: LambderApiDefinition | null): Promise<LambderApiAnswer | null>;
150
+ prepare(request: LambderApiRequest): Promise<LambderApiAnswer | null>;
137
151
  /**
138
152
  * One call, one answer. Refusals are rendered; crashes propagate.
139
153
  *
@@ -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
- * signature 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,14 +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;
39
- signatures;
42
+ apiSignatures;
40
43
  constructor(options = {}) {
41
44
  this.apiVersion = options.apiVersion ?? null;
42
- this.signatures = options.signatures ?? 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;
43
68
  this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
44
69
  this.onInvalidInput = options.onInvalidInput ?? null;
45
70
  this.sessions = options.sessions
@@ -96,10 +121,9 @@ export class LambderApiPipeline {
96
121
  * The answer for a request naming no registered API: the apiNotFound
97
122
  * refusal, carrying whatever the call already wrote (a CORS header, a
98
123
  * cookie eviction). No signature gate here: both adapters run prepare()
99
- * on the way in, with the definition the name resolved to or null, so a
100
- * signed request for an unknown name (a client built against a contract
101
- * that had it) has already been answered versionExpired by the time
102
- * anything asks for an unknown name.
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);
@@ -107,16 +131,21 @@ export class LambderApiPipeline {
107
131
  return answer;
108
132
  }
109
133
  /**
110
- * The steps that come before anything may read the request: the
111
- * signature gate, then the compressed-payload restore that every later
112
- * reader (a rate-limit key slice, a guard, the input schema) depends on
113
- * having happened.
134
+ * The steps that come before anything may read the request: the version
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.
114
144
  *
115
- * The gate compares the signature the request carries with the one the
116
- * source expects for the endpoint the name resolved to (`definition`,
117
- * null for a name the adapter does not know). A match runs; anything
118
- * else is a client built against another shape of this endpoint, or
119
- * against an endpoint that no longer exists, and is answered
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
120
149
  * versionExpired. A request carrying no signature is never gated.
121
150
  *
122
151
  * Public and named because the server runs them earlier than run() does,
@@ -124,15 +153,18 @@ export class LambderApiPipeline {
124
153
  * is answered before any of them, whether or not the name it asked for
125
154
  * exists. run() calls it too, so an adapter that has no such step still
126
155
  * gets the whole protocol. Calling it twice is safe by construction: the
127
- * gate compares against a memoized digest and the restore has already
128
- * removed the wire fields it reads.
156
+ * gates are comparisons and the restore has already removed the wire
157
+ * fields it reads.
129
158
  *
130
159
  * Returns the answer that ends the call, or null when the request is
131
160
  * ready to dispatch.
132
161
  */
133
- async prepare(request, definition) {
134
- if (request.signature !== null && this.signatures) {
135
- const expected = await this.signatures.expectedSignatureOf(request.apiName, definition);
162
+ async prepare(request) {
163
+ if (this.minApiVersion !== null && request.version !== null && compareDottedVersions(request.version, this.minApiVersion) < 0) {
164
+ return versionExpiredAnswer(this.apiVersion);
165
+ }
166
+ if (request.signature !== null && this.apiSignatures) {
167
+ const expected = await lookupApiSignature(this.apiSignatures, request.apiName);
136
168
  if (expected !== request.signature)
137
169
  return versionExpiredAnswer(this.apiVersion);
138
170
  }
@@ -175,7 +207,7 @@ export class LambderApiPipeline {
175
207
  return { answer, ...trace };
176
208
  }
177
209
  async execute(request, ctx, definition, exec, trace) {
178
- const unprepared = await this.prepare(request, definition);
210
+ const unprepared = await this.prepare(request);
179
211
  if (unprepared)
180
212
  return unprepared;
181
213
  // The limits whose key is known from the request alone, before the
@@ -1,16 +1,5 @@
1
1
  import type { LambderApiDefinition } from "./LambderApiDefinition.js";
2
2
  import { type LambderApiGuard } from "./LambderApiGuards.js";
3
- /**
4
- * Where the pipeline asks what signature a request should carry. Null for
5
- * an endpoint the source does not know, so a signed call for a name the
6
- * server does not have is answered versionExpired rather than apiNotFound:
7
- * the client was built against a contract that had it. The server answers
8
- * from its own schemas (LambderApiSignatureDigests); the mock runtime, which
9
- * holds no server schema, answers from the generated map when given one.
10
- */
11
- export type LambderApiSignatureSource = {
12
- expectedSignatureOf(apiName: string, definition: LambderApiDefinition | null): Promise<string | null>;
13
- };
14
3
  /**
15
4
  * The digest of an endpoint's client-facing shape: its name and mode, its
16
5
  * input and output schemas as JSON Schema, every guard it declares with the
@@ -22,20 +11,9 @@ export type LambderApiSignatureSource = {
22
11
  *
23
12
  * The description is hashed as built, descriptions and titles included: a
24
13
  * schema is what the server says it is, and a client built against a
25
- * different one reloads once.
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.
26
18
  */
27
19
  export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
28
- /**
29
- * The server's signature source: every registered endpoint digested from
30
- * its own schemas, once per endpoint per container, on first use. What
31
- * Lambder.apiSignatures() reads to build the client's map, and what the
32
- * pipeline compares a request's signature against.
33
- */
34
- export declare class LambderApiSignatureDigests implements LambderApiSignatureSource {
35
- private readonly guards;
36
- private readonly digests;
37
- constructor(guards: Record<string, LambderApiGuard<any, any, any>> | undefined);
38
- /** The endpoint's signature, computed on the first ask and kept. A digest that failed is not kept, so the next call tries again rather than failing forever. */
39
- signatureOf(definition: LambderApiDefinition): Promise<string>;
40
- expectedSignatureOf(apiName: string, definition: LambderApiDefinition | null): Promise<string | null>;
41
- }
@@ -2,6 +2,13 @@ import { z } from "zod";
2
2
  import { toGuardEntries } from "./LambderApiGuards.js";
3
3
  import { API_SIGNATURE_HEX_LENGTH } from "../shared/wire/LambderApiSignature.js";
4
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
+ */
5
12
  /**
6
13
  * JSON with object keys sorted at every level, so two descriptions of the
7
14
  * same shape hash the same whatever order they were built in. Arrays keep
@@ -23,12 +30,33 @@ const sortKeys = (value) => {
23
30
  return sorted;
24
31
  };
25
32
  /**
26
- * A schema as JSON Schema, exactly as zod emits it. A type JSON Schema
27
- * cannot express (a transform's output, a custom check) becomes `{}` rather
28
- * than throwing, because a digest has to exist for every endpoint; what the
29
- * digest cannot see is documented with it.
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.
30
58
  */
31
- const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any" }) : null;
59
+ const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema) }) : null;
32
60
  const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasOwnProperty.call(guards, name) ? guards[name] : undefined;
33
61
  /**
34
62
  * The digest of an endpoint's client-facing shape: its name and mode, its
@@ -41,7 +69,10 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
41
69
  *
42
70
  * The description is hashed as built, descriptions and titles included: a
43
71
  * schema is what the server says it is, and a client built against a
44
- * different one reloads once.
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.
45
76
  */
46
77
  export const apiSignatureOf = async (definition, guards) => {
47
78
  const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
@@ -63,29 +94,3 @@ export const apiSignatureOf = async (definition, guards) => {
63
94
  const hex = await sha256HexOf(canonicalJson(description));
64
95
  return hex.slice(0, API_SIGNATURE_HEX_LENGTH);
65
96
  };
66
- /**
67
- * The server's signature source: every registered endpoint digested from
68
- * its own schemas, once per endpoint per container, on first use. What
69
- * Lambder.apiSignatures() reads to build the client's map, and what the
70
- * pipeline compares a request's signature against.
71
- */
72
- export class LambderApiSignatureDigests {
73
- guards;
74
- digests = new Map();
75
- constructor(guards) {
76
- this.guards = guards;
77
- }
78
- /** The endpoint's signature, computed on the first ask and kept. A digest that failed is not kept, so the next call tries again rather than failing forever. */
79
- signatureOf(definition) {
80
- let pending = this.digests.get(definition.name);
81
- if (!pending) {
82
- pending = apiSignatureOf(definition, this.guards);
83
- this.digests.set(definition.name, pending);
84
- pending.catch(() => this.digests.delete(definition.name));
85
- }
86
- return pending;
87
- }
88
- async expectedSignatureOf(apiName, definition) {
89
- return definition ? await this.signatureOf(definition) : null;
90
- }
91
- }
package/dist/client.d.ts CHANGED
@@ -18,6 +18,7 @@ export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
18
18
  export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
19
19
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
20
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
21
22
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
22
23
  export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, LambderLogListHandler, } from "./client/LambderCaller.js";
23
24
  export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@ export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
17
17
  // The per-endpoint signature map a build ships with, how a caller reads it, and the reload-loop window.
18
18
  export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
19
19
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
20
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
20
21
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
21
22
  // in the browser they are plain Errors).
22
23
  export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
@@ -69,10 +69,10 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
69
69
  private actionList;
70
70
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
71
71
  private readonly pipeline;
72
- /** Every registered API by name: what resolves a request's name to its definition ahead of the pipeline, and what apiSignatures() digests. */
72
+ /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
73
73
  private readonly apiDefinitions;
74
- /** The signature of each endpoint as this server serves it, digested once per endpoint on first use. */
75
- private readonly signatureDigests;
74
+ /** The guards map given at creation, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
75
+ private readonly guards;
76
76
  private hookList;
77
77
  private createdHooks;
78
78
  private initPromise;
@@ -172,11 +172,13 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
172
172
  getSessionManager(): LambderSessionManager<TSessionData>;
173
173
  /**
174
174
  * Every registered endpoint's signature, keyed by its hashed name: the
175
- * LambderApiSignatureMap a client build ships with. A generator imports
176
- * the finished instance, awaits this, and writes the result to a file the
177
- * frontend passes to LambderCaller as apiSignatures; at request time the
178
- * server compares each call's signature against these same digests. Keys
179
- * are sorted, so the generated file diffs by endpoint.
175
+ * LambderApiSignatureMap both sides ship with. A generator imports the
176
+ * finished instance, awaits this, and writes the result to a file the
177
+ * frontend passes to LambderCaller as apiSignatures and the server passes
178
+ * to create() as apiSignatures; at request time the pipeline compares a
179
+ * call's signature with the server's copy of the same map. This is the
180
+ * one place a digest is computed, so it has nothing to agree with but
181
+ * itself. Keys are sorted, so the generated file diffs by endpoint.
180
182
  */
181
183
  apiSignatures(): Promise<LambderApiSignatureMap>;
182
184
  getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
@@ -10,7 +10,7 @@ import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
10
10
  import { LambderFiles } from "./LambderFiles.js";
11
11
  import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
12
12
  import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
13
- import { LambderApiSignatureDigests } from "../api/LambderApiSignature.js";
13
+ import { apiSignatureOf } from "../api/LambderApiSignature.js";
14
14
  import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
15
15
  import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
16
16
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
@@ -65,10 +65,10 @@ export default class Lambder {
65
65
  actionList = [];
66
66
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
67
67
  pipeline;
68
- /** Every registered API by name: what resolves a request's name to its definition ahead of the pipeline, and what apiSignatures() digests. */
68
+ /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
69
69
  apiDefinitions = new Map();
70
- /** The signature of each endpoint as this server serves it, digested once per endpoint on first use. */
71
- signatureDigests;
70
+ /** The guards map given at creation, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
71
+ guards;
72
72
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
73
73
  createdHooks = [];
74
74
  initPromise = null;
@@ -101,10 +101,11 @@ export default class Lambder {
101
101
  this.corsConfig = options.cors === true ? {} : options.cors;
102
102
  }
103
103
  const session = options.session;
104
- this.signatureDigests = new LambderApiSignatureDigests(options.guards);
104
+ this.guards = options.guards;
105
105
  this.pipeline = new LambderApiPipeline({
106
106
  apiVersion: this.apiVersion,
107
- signatures: this.signatureDigests,
107
+ minApiVersion: options.minApiVersion,
108
+ apiSignatures: options.apiSignatures,
108
109
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
109
110
  // The app's own validation handler is read at call time, since
110
111
  // setApiInputValidationErrorHandler runs after creation.
@@ -288,14 +289,16 @@ export default class Lambder {
288
289
  }
289
290
  /**
290
291
  * Every registered endpoint's signature, keyed by its hashed name: the
291
- * LambderApiSignatureMap a client build ships with. A generator imports
292
- * the finished instance, awaits this, and writes the result to a file the
293
- * frontend passes to LambderCaller as apiSignatures; at request time the
294
- * server compares each call's signature against these same digests. Keys
295
- * are sorted, so the generated file diffs by endpoint.
292
+ * LambderApiSignatureMap both sides ship with. A generator imports the
293
+ * finished instance, awaits this, and writes the result to a file the
294
+ * frontend passes to LambderCaller as apiSignatures and the server passes
295
+ * to create() as apiSignatures; at request time the pipeline compares a
296
+ * call's signature with the server's copy of the same map. This is the
297
+ * one place a digest is computed, so it has nothing to agree with but
298
+ * itself. Keys are sorted, so the generated file diffs by endpoint.
296
299
  */
297
300
  async apiSignatures() {
298
- const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => [await apiNameKeyOf(definition.name), await this.signatureDigests.signatureOf(definition)]));
301
+ const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => [await apiNameKeyOf(definition.name), await apiSignatureOf(definition, this.guards)]));
299
302
  entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
300
303
  return Object.fromEntries(entries);
301
304
  }
@@ -424,9 +427,8 @@ export default class Lambder {
424
427
  // The protocol's own pre-pass, run here rather than left to the
425
428
  // pipeline so that hooks and route matching see a plain payload,
426
429
  // and so a stale client is answered before any of them, whether or
427
- // not the name it asked for exists: the gate is handed the
428
- // definition the name resolves to, or null.
429
- const prepared = await this.pipeline.prepare(ctx.api, this.apiDefinitions.get(ctx.api.apiName) ?? null);
430
+ // not the name it asked for exists.
431
+ const prepared = await this.pipeline.prepare(ctx.api);
430
432
  if (prepared)
431
433
  return responseFromAnswer(prepared);
432
434
  // ctx.post is the raw body view; it shows the restored payload and
@@ -1,4 +1,5 @@
1
1
  import type { z } from "zod";
2
+ import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
2
3
  import type { Context } from "aws-lambda";
3
4
  import type LambderResolver from "./LambderResolver.js";
4
5
  import type LambderResponseBuilder from "./LambderResponseBuilder.js";
@@ -101,11 +102,33 @@ export type LambderCreateOptions<TSessionData = any> = {
101
102
  apiPath?: string;
102
103
  /**
103
104
  * Stamped on every API answer's envelope as `apiVersion`, so a client can
104
- * tell which build answered. Informational: whether a client is stale is
105
- * decided per endpoint by the signature it sends (see
106
- * Lambder.apiSignatures()), not by this string.
105
+ * tell which build answered. Whether a client is stale is decided per
106
+ * endpoint by the signature it sends (see Lambder.apiSignatures()), not
107
+ * by this string; `minApiVersion` is the one thing that reads it. Dotted
108
+ * numbers ("1.2.10"), since that is how the floor compares it, so a
109
+ * commit sha or a build date is refused rather than read as zero.
107
110
  */
108
111
  apiVersion?: string;
112
+ /**
113
+ * The oldest client build still served: a call naming a `version` below
114
+ * it answers `versionExpired` whatever its signature says. The lever for
115
+ * a change the signatures cannot see (a security fix, a field whose
116
+ * meaning changed under the same shape). Dotted numbers ("1.2.10"),
117
+ * compared segment by segment; a call naming no version is not judged.
118
+ * A floor above `apiVersion` is taken as `apiVersion`, with a warning,
119
+ * so a mistaken floor cannot refuse this build's own clients. Default:
120
+ * none.
121
+ */
122
+ minApiVersion?: string;
123
+ /**
124
+ * The generated signature map (Lambder.apiSignatures()), the same file
125
+ * the frontend ships with. Enables the signature gate: a call carrying a
126
+ * signature that is not this map's entry for its endpoint answers
127
+ * `versionExpired`. Generated once, at build time, and handed to both
128
+ * sides, so nothing is digested at request time and the two sides cannot
129
+ * disagree on a digest. Default: none, and no gate.
130
+ */
131
+ apiSignatures?: LambderApiSignatureMap;
109
132
  /**
110
133
  * Automatic compression for compressible responses. `true` (the default)
111
134
  * is `{ minBytes: 860, encodings: ["br", "gzip"], quality: 5 }`; `false`
package/dist/index.d.ts CHANGED
@@ -25,11 +25,11 @@ export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader
25
25
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
26
26
  export type { LambderApiCallContext, LambderApiCallTrace } from "./api/LambderApiCallContext.js";
27
27
  export type { LambderApiDefinition } from "./api/LambderApiDefinition.js";
28
- export { apiSignatureOf, LambderApiSignatureDigests } from "./api/LambderApiSignature.js";
29
- export type { LambderApiSignatureSource } from "./api/LambderApiSignature.js";
28
+ export { apiSignatureOf } from "./api/LambderApiSignature.js";
30
29
  export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
31
30
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
32
31
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
32
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
33
33
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
34
34
  export type { LambderApiEnvelopeConfig, LambderValidationAnswerBody } from "./api/LambderApiEnvelope.js";
35
35
  export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
package/dist/index.js CHANGED
@@ -17,9 +17,10 @@ export { toHttpAnswer } from "./api/LambderApiAnswer.js";
17
17
  export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader } from "./shared/wire/LambderAnswerHeaders.js";
18
18
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
19
19
  // Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
20
- export { apiSignatureOf, LambderApiSignatureDigests } from "./api/LambderApiSignature.js";
20
+ export { apiSignatureOf } from "./api/LambderApiSignature.js";
21
21
  export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
22
22
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
23
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
23
24
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
24
25
  export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
25
26
  // Calling a Lambder app from another lambda (server-only: the Lambda SDK, zlib)
@@ -1,4 +1,3 @@
1
- import { lookupApiSignature } from "../shared/wire/LambderApiSignature.js";
2
1
  import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
3
2
  import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
4
3
  import { createApiCallContext } from "../api/LambderApiCallContext.js";
@@ -117,13 +116,10 @@ export class LambderMockApp {
117
116
  const idempotencyOptions = options.idempotency === true ? {} : options.idempotency || null;
118
117
  const memoryIdempotency = idempotencyOptions && !idempotencyOptions.store ? new LambderMemoryIdempotencyStore() : null;
119
118
  this.idempotencyStore = memoryIdempotency;
120
- // The generated map stands in for the server's schemas: the runtime
121
- // cannot digest what it does not hold, so it answers with the map's
122
- // entry for the name and the pipeline compares, as on the server.
123
- const apiSignatures = options.apiSignatures;
124
119
  this.pipeline = new LambderApiPipeline({
125
120
  apiVersion: this.apiVersion,
126
- signatures: apiSignatures ? { expectedSignatureOf: (apiName) => lookupApiSignature(apiSignatures, apiName) } : undefined,
121
+ minApiVersion: options.minApiVersion,
122
+ apiSignatures: options.apiSignatures,
127
123
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
128
124
  sessions: sessionOptions
129
125
  ? {
@@ -599,18 +595,16 @@ export class LambderMockApp {
599
595
  let outcome;
600
596
  let error;
601
597
  try {
602
- // The protocol's pre-pass, run ahead of dispatch with the
603
- // definition the name resolved to (null for a name nothing
604
- // registered), which is where the server runs it. Two things
605
- // depended on it: an unknown name reached the notFound refusal
606
- // without the signature gate or the payload restore, so a stale
607
- // client or a malformed compressed payload was answered
608
- // differently here than on the server; and the request event
609
- // carried the wire fields instead of the payload, so a dev panel
610
- // watching calls in flight showed nothing for exactly the
611
- // compressed calls someone opens a panel for. run() calls prepare
612
- // again, which is safe by construction.
613
- const prepared = await this.pipeline.prepare(request, registered?.definition ?? null);
598
+ // The protocol's pre-pass, run before the name is resolved, which
599
+ // is where the server runs it. Two things depended on it: an
600
+ // unknown name reached the notFound refusal without the signature
601
+ // gate or the payload restore, so a stale client or a malformed
602
+ // compressed payload was answered differently here than on the
603
+ // server; and the request event carried the wire fields instead of
604
+ // the payload, so a dev panel watching calls in flight showed
605
+ // nothing for exactly the compressed calls someone opens a panel
606
+ // for. run() calls prepare again, which is safe by construction.
607
+ const prepared = await this.pipeline.prepare(request);
614
608
  this.emit(this.requestEvent(id, request, mode, startedAt));
615
609
  await this.failures.wait(this.failures.latencyFor(request.apiName), request.signal);
616
610
  if (this.failures.offline)
@@ -102,13 +102,9 @@ type LambderMockGuardShapes<S, G> = {
102
102
  export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicies<S> = LambderMockRateLimitPolicies<S>, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>> = LambderMockGuardsOption<C, S, G> & {
103
103
  /** Stamped on every answer's envelope as apiVersion, as the server's option is. */
104
104
  apiVersion?: string;
105
- /**
106
- * The generated signature map the caller carries, so the runtime refuses a
107
- * stale signature exactly as the server would: a call whose signature is
108
- * not the map's entry for its endpoint answers versionExpired. Without it
109
- * every signature passes, since the runtime holds no server schema to
110
- * digest.
111
- */
105
+ /** The version floor, as on the server: a call naming a lower `version` answers versionExpired whatever its signature says. */
106
+ minApiVersion?: string;
107
+ /** The generated signature map, as the server's option is: a call whose signature is not the map's entry for its endpoint answers versionExpired. Without it every signature passes. */
112
108
  apiSignatures?: LambderApiSignatureMap;
113
109
  /** Artificial latency per call; off by default. */
114
110
  latency?: LambderMockLatency;
@@ -20,7 +20,20 @@ export type LambderApiSignatureMap = Record<string, string>;
20
20
  * the shapes one endpoint takes over its life, and the map stays small.
21
21
  */
22
22
  export declare const API_SIGNATURE_HEX_LENGTH = 16;
23
- /** The key an endpoint's signature is stored under: SHA-256 over the prefixed name, cut to API_SIGNATURE_HEX_LENGTH hex characters. */
23
+ /**
24
+ * The key an endpoint's signature is stored under: SHA-256 over the prefixed
25
+ * name, cut to API_SIGNATURE_HEX_LENGTH hex characters. Async because
26
+ * WebCrypto's digest is, and it is the only SHA-256 a browser has.
27
+ *
28
+ * Computed on the spot, every time, and nothing is kept. The digest that
29
+ * actually describes an endpoint is the generator's, computed once at build
30
+ * time; what is left here is one hash of a short name against a map already
31
+ * in memory, which is nothing beside the request it belongs to. A cache of
32
+ * it would have to be keyed by name, and on the server the name comes off
33
+ * the wire before anything has checked that it is an endpoint at all, so it
34
+ * would grow by an entry for every name a request cared to invent and never
35
+ * shrink.
36
+ */
24
37
  export declare const apiNameKeyOf: (apiName: string) => Promise<string>;
25
38
  /** The map's signature for one endpoint, or null when the map holds none for it. */
26
39
  export declare const lookupApiSignature: (signatures: LambderApiSignatureMap, apiName: string) => Promise<string | null>;
@@ -7,20 +7,21 @@ import { sha256HexOf } from "../util/LambderTextDigest.js";
7
7
  export const API_SIGNATURE_HEX_LENGTH = 16;
8
8
  /** Domain-separated, so a name's key can never equal a signature computed over a description that happens to read the same. */
9
9
  const API_NAME_KEY_PREFIX = "lambder-api-name:";
10
- /** Memoized per name: a caller hashes each endpoint it calls once per process. */
11
- const nameKeys = new Map();
12
- /** The key an endpoint's signature is stored under: SHA-256 over the prefixed name, cut to API_SIGNATURE_HEX_LENGTH hex characters. */
13
- export const apiNameKeyOf = (apiName) => {
14
- let pending = nameKeys.get(apiName);
15
- if (!pending) {
16
- pending = sha256HexOf(API_NAME_KEY_PREFIX + apiName).then((hex) => hex.slice(0, API_SIGNATURE_HEX_LENGTH));
17
- nameKeys.set(apiName, pending);
18
- // A failed digest (no WebCrypto) is not kept, so a later call in a
19
- // context that has it succeeds instead of replaying the rejection.
20
- pending.catch(() => nameKeys.delete(apiName));
21
- }
22
- return pending;
23
- };
10
+ /**
11
+ * The key an endpoint's signature is stored under: SHA-256 over the prefixed
12
+ * name, cut to API_SIGNATURE_HEX_LENGTH hex characters. Async because
13
+ * WebCrypto's digest is, and it is the only SHA-256 a browser has.
14
+ *
15
+ * Computed on the spot, every time, and nothing is kept. The digest that
16
+ * actually describes an endpoint is the generator's, computed once at build
17
+ * time; what is left here is one hash of a short name against a map already
18
+ * in memory, which is nothing beside the request it belongs to. A cache of
19
+ * it would have to be keyed by name, and on the server the name comes off
20
+ * the wire before anything has checked that it is an endpoint at all, so it
21
+ * would grow by an entry for every name a request cared to invent and never
22
+ * shrink.
23
+ */
24
+ export const apiNameKeyOf = async (apiName) => (await sha256HexOf(API_NAME_KEY_PREFIX + apiName)).slice(0, API_SIGNATURE_HEX_LENGTH);
24
25
  /** The map's signature for one endpoint, or null when the map holds none for it. */
25
26
  export const lookupApiSignature = async (signatures, apiName) => {
26
27
  const key = await apiNameKeyOf(apiName);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Dotted version strings ("1.2.10"), compared segment by segment as numbers,
3
+ * so "1.2.10" sorts after "1.2.9" where a string comparison would put it
4
+ * first. The server's version floor (minApiVersion) reads a caller's version
5
+ * this way, and an app deciding whether a client is behind can read the
6
+ * envelope's apiVersion the same way.
7
+ */
8
+ /** True for one or more decimal segments joined by dots: "7", "1.2", "1.2.10". */
9
+ export declare const isDottedVersion: (value: string) => boolean;
10
+ /** -1 when `a` is older than `b`, 1 when newer, 0 when equal. A missing segment counts as 0, so "1.2" equals "1.2.0". */
11
+ export declare const compareDottedVersions: (a: string, b: string) => -1 | 0 | 1;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Dotted version strings ("1.2.10"), compared segment by segment as numbers,
3
+ * so "1.2.10" sorts after "1.2.9" where a string comparison would put it
4
+ * first. The server's version floor (minApiVersion) reads a caller's version
5
+ * this way, and an app deciding whether a client is behind can read the
6
+ * envelope's apiVersion the same way.
7
+ */
8
+ /** True for one or more decimal segments joined by dots: "7", "1.2", "1.2.10". */
9
+ export const isDottedVersion = (value) => /^\d+(\.\d+)*$/.test(value);
10
+ /** A segment as a number; anything that is not one counts as 0, so a version nothing can read sorts below every real one. */
11
+ const segmentOf = (text) => {
12
+ const parsed = parseInt(text, 10);
13
+ return Number.isFinite(parsed) ? parsed : 0;
14
+ };
15
+ /** -1 when `a` is older than `b`, 1 when newer, 0 when equal. A missing segment counts as 0, so "1.2" equals "1.2.0". */
16
+ export const compareDottedVersions = (a, b) => {
17
+ const left = a.split(".").map(segmentOf);
18
+ const right = b.split(".").map(segmentOf);
19
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
20
+ const x = left[i] ?? 0;
21
+ const y = right[i] ?? 0;
22
+ if (x < y)
23
+ return -1;
24
+ if (x > y)
25
+ return 1;
26
+ }
27
+ return 0;
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "7.1.4",
3
+ "version": "7.2.0",
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": [