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 +52 -0
- package/dist/api/LambderApiPipeline.d.ts +38 -24
- package/dist/api/LambderApiPipeline.js +54 -22
- package/dist/api/LambderApiSignature.d.ts +4 -26
- package/dist/api/LambderApiSignature.js +37 -32
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/core/Lambder.d.ts +10 -8
- package/dist/core/Lambder.js +17 -15
- package/dist/core/LambderCreateOptions.d.ts +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/mock/LambderMockApp.js +12 -18
- package/dist/mock/LambderMockCreateOptions.d.ts +3 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +14 -1
- package/dist/shared/wire/LambderApiSignature.js +15 -14
- package/dist/shared/wire/LambderVersionOrder.d.ts +11 -0
- package/dist/shared/wire/LambderVersionOrder.js +28 -0
- package/package.json +1 -1
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
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
115
|
-
* reader (a rate-limit key slice, a guard, the input schema)
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
|
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
|
-
|
|
42
|
+
apiSignatures;
|
|
40
43
|
constructor(options = {}) {
|
|
41
44
|
this.apiVersion = options.apiVersion ?? null;
|
|
42
|
-
|
|
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,
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
112
|
-
* reader (a rate-limit key slice, a guard, the input schema)
|
|
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
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
*
|
|
128
|
-
*
|
|
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
|
|
134
|
-
if (request.
|
|
135
|
-
|
|
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
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -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:
|
|
72
|
+
/** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
|
|
73
73
|
private readonly apiDefinitions;
|
|
74
|
-
/** The
|
|
75
|
-
private readonly
|
|
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
|
|
176
|
-
*
|
|
177
|
-
* frontend passes to LambderCaller as apiSignatures
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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>;
|
package/dist/core/Lambder.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
68
|
+
/** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
|
|
69
69
|
apiDefinitions = new Map();
|
|
70
|
-
/** The
|
|
71
|
-
|
|
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.
|
|
104
|
+
this.guards = options.guards;
|
|
105
105
|
this.pipeline = new LambderApiPipeline({
|
|
106
106
|
apiVersion: this.apiVersion,
|
|
107
|
-
|
|
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
|
|
292
|
-
*
|
|
293
|
-
* frontend passes to LambderCaller as apiSignatures
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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.
|
|
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
|
|
428
|
-
|
|
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.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
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": [
|