lambder 7.0.2 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/dist/api/LambderApiDefinition.d.ts +6 -3
- package/dist/api/LambderApiEnvelope.d.ts +1 -1
- package/dist/api/LambderApiEnvelope.js +1 -1
- package/dist/api/LambderApiGuards.d.ts +5 -0
- package/dist/api/LambderApiGuards.js +2 -2
- package/dist/api/LambderApiPipeline.d.ts +41 -11
- package/dist/api/LambderApiPipeline.js +55 -13
- package/dist/api/LambderApiRequest.d.ts +3 -1
- package/dist/api/LambderApiRequest.js +1 -0
- package/dist/api/LambderApiSignature.d.ts +19 -0
- package/dist/api/LambderApiSignature.js +96 -0
- package/dist/client/LambderCaller.d.ts +13 -0
- package/dist/client/LambderCaller.js +21 -1
- package/dist/client/LambderReloadLoopBreaker.d.ts +38 -0
- package/dist/client/LambderReloadLoopBreaker.js +71 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +17 -1
- package/dist/core/Lambder.js +30 -5
- package/dist/core/LambderCreateOptions.d.ts +29 -0
- package/dist/core/LambderCreateOptions.js +0 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/invoke/LambderInvokeCaller.d.ts +12 -1
- package/dist/invoke/LambderInvokeCaller.js +10 -1
- package/dist/invoke/LambderLambdaEvent.d.ts +1 -0
- package/dist/invoke/LambderLambdaEvent.js +1 -0
- package/dist/mock/LambderMockApp.d.ts +2 -2
- package/dist/mock/LambderMockApp.js +6 -4
- package/dist/mock/LambderMockCreateOptions.d.ts +6 -1
- package/dist/mock/LambderMockCreateOptions.js +0 -8
- package/dist/mock/LambderMockTypes.d.ts +2 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -0
- package/dist/shared/transport/LambderApiTransport.js +2 -0
- package/dist/shared/wire/LambderApiSignature.d.ts +46 -0
- package/dist/shared/wire/LambderApiSignature.js +45 -0
- package/dist/shared/wire/LambderVersionOrder.d.ts +11 -0
- package/dist/shared/wire/LambderVersionOrder.js +28 -0
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@ import { createCallAbort } from '../shared/util/LambderCallAbort.js';
|
|
|
7
7
|
import { coerceToError } from '../shared/wire/LambderCrashDetail.js';
|
|
8
8
|
import { isLambderTransportFailure } from '../shared/transport/LambderApiTransport.js';
|
|
9
9
|
import { DEFAULT_SESSION_TOKEN_COOKIE_KEY, DEFAULT_SESSION_CSRF_COOKIE_KEY } from '../shared/wire/LambderSessionCookieNames.js';
|
|
10
|
+
import { readApiSignature } from '../shared/wire/LambderApiSignature.js';
|
|
11
|
+
import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReloadLoopBreaker.js';
|
|
10
12
|
import { lambderFetchTransport } from './lambderFetchTransport.js';
|
|
11
13
|
/**
|
|
12
14
|
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
@@ -16,7 +18,10 @@ export default class LambderCaller {
|
|
|
16
18
|
isCorsEnabled;
|
|
17
19
|
apiPath;
|
|
18
20
|
apiVersion;
|
|
21
|
+
apiSignatures;
|
|
19
22
|
timeoutMs;
|
|
23
|
+
/** What keeps a stale bundle from reloading itself forever; see the class. */
|
|
24
|
+
reloadLoopBreaker = new LambderReloadLoopBreaker();
|
|
20
25
|
/** The calls currently in flight, in the order they started. */
|
|
21
26
|
fetchTrackerList = [];
|
|
22
27
|
/** Whether any call is in flight. Derived, so it cannot drift from the list the way a separate flag did. */
|
|
@@ -40,9 +45,10 @@ export default class LambderCaller {
|
|
|
40
45
|
constructor(options) {
|
|
41
46
|
// The conditional provider option is resolved per instantiation;
|
|
42
47
|
// inside the class it is read through the plain shape.
|
|
43
|
-
const { apiPath, apiVersion, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
|
|
48
|
+
const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
|
|
44
49
|
this.apiPath = apiPath;
|
|
45
50
|
this.apiVersion = apiVersion;
|
|
51
|
+
this.apiSignatures = apiSignatures;
|
|
46
52
|
this.isCorsEnabled = isCorsEnabled;
|
|
47
53
|
this.timeoutMs = timeoutMs;
|
|
48
54
|
this.sessionCookieDomain = sessionCookieDomain;
|
|
@@ -198,6 +204,10 @@ export default class LambderCaller {
|
|
|
198
204
|
activeFetchList: [...this.fetchTrackerList],
|
|
199
205
|
});
|
|
200
206
|
const version = this.apiVersion;
|
|
207
|
+
// The server's signature for this endpoint, when this build
|
|
208
|
+
// carries the map. A name the map lacks fails the call here, as a
|
|
209
|
+
// provider that threw would: the map predates the endpoint.
|
|
210
|
+
const signature = this.apiSignatures ? await readApiSignature(this.apiSignatures, apiName) : undefined;
|
|
201
211
|
// js-cookie reads nothing without a document, and there is no
|
|
202
212
|
// location outside a page: both are "" then, and a transport that
|
|
203
213
|
// carries a cookie jar fills the token in from it.
|
|
@@ -228,6 +238,7 @@ export default class LambderCaller {
|
|
|
228
238
|
answer = await this.transport({
|
|
229
239
|
apiPath: this.apiPath,
|
|
230
240
|
apiName, version, token, siteHost,
|
|
241
|
+
...(signature !== undefined ? { signature } : {}),
|
|
231
242
|
csrfCookieKey: this.sessionCsrfCookieKey,
|
|
232
243
|
...(compressedPayload ? { compressed: compressedPayload } : { payload }),
|
|
233
244
|
...(guardInputs !== undefined ? { guardInputs } : {}),
|
|
@@ -292,6 +303,15 @@ export default class LambderCaller {
|
|
|
292
303
|
const data = outcome.response;
|
|
293
304
|
await fetchEnded(data);
|
|
294
305
|
if (!outcome.ok && outcome.reason === 'versionExpired') {
|
|
306
|
+
// A repeat of a recent versionExpired for the same endpoint and
|
|
307
|
+
// signature means the reload the handler performed brought the
|
|
308
|
+
// same bundle back, and reloading again would loop. The
|
|
309
|
+
// handler is not called; the failure is reported instead, and
|
|
310
|
+
// the outcome still says versionExpired.
|
|
311
|
+
if (this.reloadLoopBreaker.isRepeat(apiName, signature ?? "")) {
|
|
312
|
+
await reportError(new Error(`Version expired again for API "${apiName}" within ${RELOAD_LOOP_WINDOW_MS / 60000} minutes with the same signature: the bundle being served is still the stale one, so versionExpiredHandler was not called again.`));
|
|
313
|
+
return outcome;
|
|
314
|
+
}
|
|
295
315
|
if (versionExpiredHandler) {
|
|
296
316
|
await versionExpiredHandler();
|
|
297
317
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops a stale client from reloading forever.
|
|
3
|
+
*
|
|
4
|
+
* A versionExpired answer means "this client's signature for the endpoint is
|
|
5
|
+
* not the one the server holds", and the ordinary response is to reload and
|
|
6
|
+
* get the current bundle. When the bundle being served is itself the stale
|
|
7
|
+
* one (a frontend deployed with a signature map the server does not match, a
|
|
8
|
+
* cached bundle, a server deploy that failed behind a fresh frontend), the
|
|
9
|
+
* reload brings back the same signature, the same call fails the same way,
|
|
10
|
+
* and the page reloads again, indefinitely.
|
|
11
|
+
*
|
|
12
|
+
* The evidence of that loop is a versionExpired for the same endpoint with
|
|
13
|
+
* the same signature shortly after the last one: a bundle that had actually
|
|
14
|
+
* changed the endpoint would carry a different signature. The record lives
|
|
15
|
+
* in sessionStorage, which is per tab and survives a reload, so the new page
|
|
16
|
+
* instance sees what the previous one saw; without sessionStorage (a test, a
|
|
17
|
+
* non-browser runtime) an in-memory record does the same within one page.
|
|
18
|
+
*
|
|
19
|
+
* Once a repeat is confirmed, every versionExpired within the window from
|
|
20
|
+
* the first one counts as a repeat too, whichever endpoint it names: a stale
|
|
21
|
+
* bundle is usually stale for several endpoints, and one reload per endpoint
|
|
22
|
+
* is still a loop, only a slower one. After the window a reload is allowed
|
|
23
|
+
* again, so a client stuck on a stale bundle retries a few times an hour and
|
|
24
|
+
* recovers by itself once the deploy is fixed.
|
|
25
|
+
*/
|
|
26
|
+
/** How long after the first versionExpired a repeat counts as the same loop. */
|
|
27
|
+
export declare const RELOAD_LOOP_WINDOW_MS: number;
|
|
28
|
+
export declare class LambderReloadLoopBreaker {
|
|
29
|
+
/** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
|
|
30
|
+
private memory;
|
|
31
|
+
/**
|
|
32
|
+
* Records this versionExpired and says whether it repeats a recent one,
|
|
33
|
+
* in which case the caller must not invoke versionExpiredHandler again.
|
|
34
|
+
*/
|
|
35
|
+
isRepeat(apiName: string, signature: string, now?: number): boolean;
|
|
36
|
+
private read;
|
|
37
|
+
private write;
|
|
38
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops a stale client from reloading forever.
|
|
3
|
+
*
|
|
4
|
+
* A versionExpired answer means "this client's signature for the endpoint is
|
|
5
|
+
* not the one the server holds", and the ordinary response is to reload and
|
|
6
|
+
* get the current bundle. When the bundle being served is itself the stale
|
|
7
|
+
* one (a frontend deployed with a signature map the server does not match, a
|
|
8
|
+
* cached bundle, a server deploy that failed behind a fresh frontend), the
|
|
9
|
+
* reload brings back the same signature, the same call fails the same way,
|
|
10
|
+
* and the page reloads again, indefinitely.
|
|
11
|
+
*
|
|
12
|
+
* The evidence of that loop is a versionExpired for the same endpoint with
|
|
13
|
+
* the same signature shortly after the last one: a bundle that had actually
|
|
14
|
+
* changed the endpoint would carry a different signature. The record lives
|
|
15
|
+
* in sessionStorage, which is per tab and survives a reload, so the new page
|
|
16
|
+
* instance sees what the previous one saw; without sessionStorage (a test, a
|
|
17
|
+
* non-browser runtime) an in-memory record does the same within one page.
|
|
18
|
+
*
|
|
19
|
+
* Once a repeat is confirmed, every versionExpired within the window from
|
|
20
|
+
* the first one counts as a repeat too, whichever endpoint it names: a stale
|
|
21
|
+
* bundle is usually stale for several endpoints, and one reload per endpoint
|
|
22
|
+
* is still a loop, only a slower one. After the window a reload is allowed
|
|
23
|
+
* again, so a client stuck on a stale bundle retries a few times an hour and
|
|
24
|
+
* recovers by itself once the deploy is fixed.
|
|
25
|
+
*/
|
|
26
|
+
/** How long after the first versionExpired a repeat counts as the same loop. */
|
|
27
|
+
export const RELOAD_LOOP_WINDOW_MS = 5 * 60 * 1000;
|
|
28
|
+
const STORAGE_KEY = "lambder:version-expired";
|
|
29
|
+
const isExpiredRecord = (value) => typeof value === "object" && value !== null
|
|
30
|
+
&& typeof value.apiName === "string"
|
|
31
|
+
&& typeof value.signature === "string"
|
|
32
|
+
&& typeof value.at === "number"
|
|
33
|
+
&& typeof value.confirmed === "boolean";
|
|
34
|
+
export class LambderReloadLoopBreaker {
|
|
35
|
+
/** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
|
|
36
|
+
memory = null;
|
|
37
|
+
/**
|
|
38
|
+
* Records this versionExpired and says whether it repeats a recent one,
|
|
39
|
+
* in which case the caller must not invoke versionExpiredHandler again.
|
|
40
|
+
*/
|
|
41
|
+
isRepeat(apiName, signature, now = Date.now()) {
|
|
42
|
+
const last = this.read();
|
|
43
|
+
if (last && now - last.at < RELOAD_LOOP_WINDOW_MS && (last.confirmed || (last.apiName === apiName && last.signature === signature))) {
|
|
44
|
+
// `at` stays the first event's, so the window runs from the start
|
|
45
|
+
// of the loop rather than being pushed forward by every repeat.
|
|
46
|
+
this.write({ apiName, signature, at: last.at, confirmed: true });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
this.write({ apiName, signature, at: now, confirmed: false });
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
read() {
|
|
53
|
+
try {
|
|
54
|
+
const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY);
|
|
55
|
+
if (raw) {
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (isExpiredRecord(parsed))
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch { /* a private window or blocked storage: the in-memory record stands in */ }
|
|
62
|
+
return this.memory;
|
|
63
|
+
}
|
|
64
|
+
write(record) {
|
|
65
|
+
this.memory = record;
|
|
66
|
+
try {
|
|
67
|
+
globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
68
|
+
}
|
|
69
|
+
catch { /* same: the in-memory record stands in */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export type { LambderApiTransport, LambderApiTransportRequest, LambderTransportF
|
|
|
15
15
|
export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
|
|
16
16
|
export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
|
|
17
17
|
export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
|
18
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
|
|
19
|
+
export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
|
|
20
|
+
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
21
|
+
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
18
22
|
export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
|
|
19
23
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, LambderLogListHandler, } from "./client/LambderCaller.js";
|
|
20
24
|
export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
package/dist/client.js
CHANGED
|
@@ -14,6 +14,10 @@ export { buildTransportEnvelope, LambderTransportFailure, isLambderTransportFail
|
|
|
14
14
|
export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
|
|
15
15
|
export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
|
|
16
16
|
export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
|
17
|
+
// The per-endpoint signature map a build ships with, how a caller reads it, and the reload-loop window.
|
|
18
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
|
|
19
|
+
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
20
|
+
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
17
21
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere;
|
|
18
22
|
// in the browser they are plain Errors).
|
|
19
23
|
export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type LambderSessionController from "../session/LambderSessionController.j
|
|
|
9
9
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
10
10
|
import { type LambderIndexHtmlOptions } from "./LambderIndexHtml.js";
|
|
11
11
|
import { LambderFiles } from "./LambderFiles.js";
|
|
12
|
+
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
|
|
12
13
|
import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
|
|
13
14
|
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../api/LambderApiGuards.js";
|
|
14
15
|
import type { LambderApiRateLimitPolicyConfig, LambderRateLimitOption } from "../api/LambderApiRateLimits.js";
|
|
@@ -50,6 +51,7 @@ export type LambderCreatedHook = (lambderInstance: Lambder<any, any, any, any, a
|
|
|
50
51
|
*/
|
|
51
52
|
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false, _TPublicGuardsRequired extends boolean = false, _TSessionsEnabled extends boolean = true> {
|
|
52
53
|
apiPath: string;
|
|
54
|
+
/** Stamped on every API answer's envelope as apiVersion. Informational: a client's staleness is judged per endpoint by its signature, see apiSignatures(). */
|
|
53
55
|
apiVersion: null | string;
|
|
54
56
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
55
57
|
files: LambderFiles | null;
|
|
@@ -67,7 +69,10 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
67
69
|
private actionList;
|
|
68
70
|
/** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
|
|
69
71
|
private readonly pipeline;
|
|
70
|
-
|
|
72
|
+
/** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
|
|
73
|
+
private readonly apiDefinitions;
|
|
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;
|
|
71
76
|
private hookList;
|
|
72
77
|
private createdHooks;
|
|
73
78
|
private initPromise;
|
|
@@ -165,6 +170,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
165
170
|
getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
|
|
166
171
|
/** The session manager, for code that works on sessions outside a request (maintenance, tests). */
|
|
167
172
|
getSessionManager(): LambderSessionManager<TSessionData>;
|
|
173
|
+
/**
|
|
174
|
+
* Every registered endpoint's signature, keyed by its hashed name: the
|
|
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.
|
|
182
|
+
*/
|
|
183
|
+
apiSignatures(): Promise<LambderApiSignatureMap>;
|
|
168
184
|
getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
|
|
169
185
|
private getResolver;
|
|
170
186
|
getHandler(): LambderHandler;
|
package/dist/core/Lambder.js
CHANGED
|
@@ -10,6 +10,8 @@ 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 { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
14
|
+
import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
|
|
13
15
|
import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
|
|
14
16
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
15
17
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
@@ -45,6 +47,7 @@ export default class Lambder {
|
|
|
45
47
|
// Everything an instance is, fixed before the first registration.
|
|
46
48
|
// =====================================================================
|
|
47
49
|
apiPath;
|
|
50
|
+
/** Stamped on every API answer's envelope as apiVersion. Informational: a client's staleness is judged per endpoint by its signature, see apiSignatures(). */
|
|
48
51
|
apiVersion;
|
|
49
52
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
50
53
|
files;
|
|
@@ -62,7 +65,10 @@ export default class Lambder {
|
|
|
62
65
|
actionList = [];
|
|
63
66
|
/** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
|
|
64
67
|
pipeline;
|
|
65
|
-
|
|
68
|
+
/** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
|
|
69
|
+
apiDefinitions = new Map();
|
|
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;
|
|
66
72
|
hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
|
|
67
73
|
createdHooks = [];
|
|
68
74
|
initPromise = null;
|
|
@@ -95,8 +101,11 @@ export default class Lambder {
|
|
|
95
101
|
this.corsConfig = options.cors === true ? {} : options.cors;
|
|
96
102
|
}
|
|
97
103
|
const session = options.session;
|
|
104
|
+
this.guards = options.guards;
|
|
98
105
|
this.pipeline = new LambderApiPipeline({
|
|
99
106
|
apiVersion: this.apiVersion,
|
|
107
|
+
minApiVersion: options.minApiVersion,
|
|
108
|
+
apiSignatures: options.apiSignatures,
|
|
100
109
|
maxRequestPayloadBytes: options.maxRequestPayloadBytes,
|
|
101
110
|
// The app's own validation handler is read at call time, since
|
|
102
111
|
// setApiInputValidationErrorHandler runs after creation.
|
|
@@ -200,7 +209,8 @@ export default class Lambder {
|
|
|
200
209
|
// Typed API with Zod
|
|
201
210
|
addApi(name, schema, handler) {
|
|
202
211
|
this.assertApiRegistration(name, "public", schema);
|
|
203
|
-
const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
|
|
212
|
+
const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
|
|
213
|
+
this.apiDefinitions.set(name, definition);
|
|
204
214
|
this.actionList.push({
|
|
205
215
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
206
216
|
actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
|
|
@@ -210,7 +220,8 @@ export default class Lambder {
|
|
|
210
220
|
// Typed Session API with Zod
|
|
211
221
|
addSessionApi(name, schema, handler) {
|
|
212
222
|
this.assertApiRegistration(name, "session", schema);
|
|
213
|
-
const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
|
|
223
|
+
const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
|
|
224
|
+
this.apiDefinitions.set(name, definition);
|
|
214
225
|
this.actionList.push({
|
|
215
226
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
216
227
|
actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
|
|
@@ -276,6 +287,21 @@ export default class Lambder {
|
|
|
276
287
|
getSessionManager() {
|
|
277
288
|
return this.pipeline.sessionManager;
|
|
278
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Every registered endpoint's signature, keyed by its hashed name: the
|
|
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.
|
|
299
|
+
*/
|
|
300
|
+
async apiSignatures() {
|
|
301
|
+
const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => [await apiNameKeyOf(definition.name), await apiSignatureOf(definition, this.guards)]));
|
|
302
|
+
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
303
|
+
return Object.fromEntries(entries);
|
|
304
|
+
}
|
|
279
305
|
getResponseBuilder(ctx) {
|
|
280
306
|
return new LambderResponseBuilder({
|
|
281
307
|
files: this.files,
|
|
@@ -583,7 +609,7 @@ export default class Lambder {
|
|
|
583
609
|
// =====================================================================
|
|
584
610
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
585
611
|
assertApiRegistration(name, mode, options) {
|
|
586
|
-
if (this.
|
|
612
|
+
if (this.apiDefinitions.has(name)) {
|
|
587
613
|
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
588
614
|
}
|
|
589
615
|
// Everything that can refuse this registration runs before the name is
|
|
@@ -603,7 +629,6 @@ export default class Lambder {
|
|
|
603
629
|
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
604
630
|
}
|
|
605
631
|
this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
|
|
606
|
-
this.registeredApiNames.add(name);
|
|
607
632
|
}
|
|
608
633
|
/**
|
|
609
634
|
* The answer for a rejected input: the app's
|
|
@@ -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";
|
|
@@ -99,7 +100,35 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
99
100
|
*/
|
|
100
101
|
files?: LambderFilesOption;
|
|
101
102
|
apiPath?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Stamped on every API answer's envelope as `apiVersion`, so a client can
|
|
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.
|
|
110
|
+
*/
|
|
102
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;
|
|
103
132
|
/**
|
|
104
133
|
* Automatic compression for compressible responses. `true` (the default)
|
|
105
134
|
* is `{ minBytes: 860, encodings: ["br", "gzip"], quality: 5 }`; `false`
|
|
@@ -23,11 +23,6 @@ export const assertCreateOptions = (options) => {
|
|
|
23
23
|
if (options.apiPath !== undefined && (options.apiPath === "" || !options.apiPath.startsWith("/"))) {
|
|
24
24
|
throw new Error(`Lambder: apiPath must be a path starting with "/", got ${JSON.stringify(options.apiPath)}.`);
|
|
25
25
|
}
|
|
26
|
-
// "" is the one string that turns the version gate off while looking like
|
|
27
|
-
// it was set; say so rather than accepting every version a client names.
|
|
28
|
-
if (options.apiVersion === "") {
|
|
29
|
-
throw new Error("Lambder: apiVersion must not be empty. Leave it out to run without the version gate.");
|
|
30
|
-
}
|
|
31
26
|
// 0 or a negative ceiling turned every response into the size guard's own 500.
|
|
32
27
|
if (options.maxResponseBytes !== undefined)
|
|
33
28
|
assertPositiveInteger(options.maxResponseBytes, "maxResponseBytes");
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +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 } from "./api/LambderApiSignature.js";
|
|
29
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
|
|
30
|
+
export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
|
|
31
|
+
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
32
|
+
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
28
33
|
export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
|
|
29
34
|
export type { LambderApiEnvelopeConfig, LambderValidationAnswerBody } from "./api/LambderApiEnvelope.js";
|
|
30
35
|
export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,11 @@ export { readApiEnvelope, restoreCompressedPayload } from "./api/LambderApiReque
|
|
|
16
16
|
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
|
+
// Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
|
|
20
|
+
export { apiSignatureOf } from "./api/LambderApiSignature.js";
|
|
21
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
|
|
22
|
+
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
23
|
+
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
19
24
|
export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
|
|
20
25
|
export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
|
|
21
26
|
// Calling a Lambder app from another lambda (server-only: the Lambda SDK, zlib)
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { APIGatewayProxyEventV2, Context } from "aws-lambda";
|
|
24
24
|
import { type LambderInvokeFailure, type LambderInvokeOutcome } from "./LambderInvokeOutcome.js";
|
|
25
|
+
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
|
|
25
26
|
import type { LambdaClient, LambdaClientConfig } from "@aws-sdk/client-lambda";
|
|
26
27
|
import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
|
|
27
28
|
import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from "../shared/wire/LambderCallOptions.js";
|
|
@@ -92,8 +93,15 @@ type LambderInvokeCallerBaseOptions = {
|
|
|
92
93
|
clientConfig?: LambdaClientConfig;
|
|
93
94
|
/** Must match the callee's apiPath. Default: "/api". */
|
|
94
95
|
apiPath?: string;
|
|
95
|
-
/** Sent as `version
|
|
96
|
+
/** Sent as `version`, informational: the callee stamps its own on every answer. Default: none. */
|
|
96
97
|
apiVersion?: string;
|
|
98
|
+
/**
|
|
99
|
+
* The callee's signature map, generated from its instance
|
|
100
|
+
* (Lambder.apiSignatures()) when this caller was built. Sent per call as
|
|
101
|
+
* `signature`, so the callee answers versionExpired to a call built
|
|
102
|
+
* against another shape of the endpoint. Default: none, and no gate.
|
|
103
|
+
*/
|
|
104
|
+
apiSignatures?: LambderApiSignatureMap;
|
|
97
105
|
/** The Host the callee sees (ctx.host). Default: functionName. */
|
|
98
106
|
host?: string;
|
|
99
107
|
/**
|
|
@@ -146,6 +154,8 @@ export type LambderInvokeEventInit = {
|
|
|
146
154
|
/** Default: "lambder-invoke". */
|
|
147
155
|
host?: string;
|
|
148
156
|
apiVersion?: string;
|
|
157
|
+
/** The caller's signature for the endpoint, out of the callee's map. */
|
|
158
|
+
signature?: string;
|
|
149
159
|
guardInputs?: Record<string, unknown>;
|
|
150
160
|
idempotencyKey?: string;
|
|
151
161
|
clientIp?: string;
|
|
@@ -161,6 +171,7 @@ export default class LambderInvokeCaller<TContract extends LambderApiContractSha
|
|
|
161
171
|
private readonly functionName;
|
|
162
172
|
private readonly apiPath;
|
|
163
173
|
private readonly apiVersion?;
|
|
174
|
+
private readonly apiSignatures?;
|
|
164
175
|
private readonly host;
|
|
165
176
|
private readonly requestCompression;
|
|
166
177
|
private readonly maxResponsePayloadBytes;
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { classifyDeliveryFailure, describeFailure, errorFromFunctionError, LambderInvokeError, parseFunctionError, } from "./LambderInvokeOutcome.js";
|
|
24
24
|
import { DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
|
|
25
|
+
import { readApiSignature } from "../shared/wire/LambderApiSignature.js";
|
|
25
26
|
import { resolveApiOutcome } from "../shared/wire/LambderApiOutcome.js";
|
|
26
27
|
import { mergeGuardInputs, } from "../shared/wire/LambderCallOptions.js";
|
|
27
28
|
import { createCallAbort, stopWaitingWhenAborted } from "../shared/util/LambderCallAbort.js";
|
|
@@ -44,6 +45,7 @@ export default class LambderInvokeCaller {
|
|
|
44
45
|
functionName;
|
|
45
46
|
apiPath;
|
|
46
47
|
apiVersion;
|
|
48
|
+
apiSignatures;
|
|
47
49
|
host;
|
|
48
50
|
requestCompression;
|
|
49
51
|
maxResponsePayloadBytes;
|
|
@@ -57,7 +59,7 @@ export default class LambderInvokeCaller {
|
|
|
57
59
|
client;
|
|
58
60
|
sdk;
|
|
59
61
|
constructor(options) {
|
|
60
|
-
const { functionName, client, clientConfig, apiPath, apiVersion, host, requestCompression, maxResponsePayloadBytes, timeoutMs, onLogList, onFailure, sessionTokenCookieKey, transport, guardInputsProvider, } = options;
|
|
62
|
+
const { functionName, client, clientConfig, apiPath, apiVersion, apiSignatures, host, requestCompression, maxResponsePayloadBytes, timeoutMs, onLogList, onFailure, sessionTokenCookieKey, transport, guardInputsProvider, } = options;
|
|
61
63
|
if (!functionName?.trim())
|
|
62
64
|
throw new Error("LambderInvokeCaller: functionName is required");
|
|
63
65
|
this.functionName = functionName;
|
|
@@ -65,6 +67,7 @@ export default class LambderInvokeCaller {
|
|
|
65
67
|
this.clientConfig = clientConfig;
|
|
66
68
|
this.apiPath = apiPath ?? "/api";
|
|
67
69
|
this.apiVersion = apiVersion;
|
|
70
|
+
this.apiSignatures = apiSignatures;
|
|
68
71
|
this.host = host ?? functionName;
|
|
69
72
|
// `?? false`: like the browser caller, off unless asked for.
|
|
70
73
|
this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS);
|
|
@@ -93,6 +96,7 @@ export default class LambderInvokeCaller {
|
|
|
93
96
|
body: buildEnvelopeJson({
|
|
94
97
|
apiName: init.apiName,
|
|
95
98
|
version: init.apiVersion,
|
|
99
|
+
signature: init.signature,
|
|
96
100
|
csrf: init.session?.csrf,
|
|
97
101
|
siteHost: host,
|
|
98
102
|
payloadJson: init.payload !== undefined ? JSON.stringify(init.payload) : undefined,
|
|
@@ -290,6 +294,10 @@ export default class LambderInvokeCaller {
|
|
|
290
294
|
let event;
|
|
291
295
|
let eventJson;
|
|
292
296
|
try {
|
|
297
|
+
// The callee's signature for this endpoint, when this caller was
|
|
298
|
+
// built with the callee's map. A name the map lacks fails here,
|
|
299
|
+
// as a provider that threw would: the map predates the endpoint.
|
|
300
|
+
const signature = this.apiSignatures ? await readApiSignature(this.apiSignatures, apiName) : undefined;
|
|
293
301
|
// Provider values underneath, per-call values on top.
|
|
294
302
|
const provided = this.guardInputsProvider
|
|
295
303
|
? await this.guardInputsProvider(apiName)
|
|
@@ -314,6 +322,7 @@ export default class LambderInvokeCaller {
|
|
|
314
322
|
body: buildEnvelopeJson({
|
|
315
323
|
apiName,
|
|
316
324
|
version: this.apiVersion,
|
|
325
|
+
signature,
|
|
317
326
|
csrf: options.session?.csrf,
|
|
318
327
|
siteHost: this.host,
|
|
319
328
|
payloadJson: compressed ? undefined : payloadJson,
|
|
@@ -57,6 +57,7 @@ export declare const synthesizeLambdaHttpEvent: (request: LambderSynthesizedRequ
|
|
|
57
57
|
export declare const buildEnvelopeJson: (fields: {
|
|
58
58
|
apiName: string;
|
|
59
59
|
version?: string;
|
|
60
|
+
signature?: string;
|
|
60
61
|
csrf?: string;
|
|
61
62
|
siteHost: string;
|
|
62
63
|
/** The payload's own JSON, when it goes plainly. */
|
|
@@ -117,6 +117,7 @@ export const buildEnvelopeJson = (fields) => {
|
|
|
117
117
|
const withoutPayload = JSON.stringify(buildEnvelopeFields({
|
|
118
118
|
apiName: fields.apiName,
|
|
119
119
|
version: fields.version,
|
|
120
|
+
signature: fields.signature,
|
|
120
121
|
token: fields.csrf ?? "",
|
|
121
122
|
siteHost: fields.siteHost,
|
|
122
123
|
compressed: fields.compressed,
|
|
@@ -16,7 +16,7 @@ import type { LambderMockCallContext, LambderMockCallRecord, LambderMockEntry, L
|
|
|
16
16
|
* Lambda server runs) over memory stores, with a registry of typed mock
|
|
17
17
|
* handlers where the server has app handlers, and mock guards where it has
|
|
18
18
|
* app guards. Everything the protocol does (envelope, refusals, sessions
|
|
19
|
-
* and their cookies, guards, rate limits, idempotency, the
|
|
19
|
+
* and their cookies, guards, rate limits, idempotency, the signature gate)
|
|
20
20
|
* happens in the core; this class only resolves a name to an entry, wraps
|
|
21
21
|
* the handler's return into the envelope, and adds what a mock needs on
|
|
22
22
|
* top: failure injection, latency, a subscription, a call log, reset.
|
|
@@ -176,7 +176,7 @@ export declare class LambderMockApp<C extends LambderApiContractShape, S = any,
|
|
|
176
176
|
*
|
|
177
177
|
* Public because the mode of an unregistered name cannot be recovered at
|
|
178
178
|
* runtime, the contract being a type. Everything that precedes dispatch
|
|
179
|
-
* still runs (the
|
|
179
|
+
* still runs (the signature gate, the payload restore); the session read is
|
|
180
180
|
* the one step this answer cannot have, which is the fidelity limit
|
|
181
181
|
* restNotMocked documents.
|
|
182
182
|
*/
|
|
@@ -40,7 +40,7 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
|
|
|
40
40
|
* Lambda server runs) over memory stores, with a registry of typed mock
|
|
41
41
|
* handlers where the server has app handlers, and mock guards where it has
|
|
42
42
|
* app guards. Everything the protocol does (envelope, refusals, sessions
|
|
43
|
-
* and their cookies, guards, rate limits, idempotency, the
|
|
43
|
+
* and their cookies, guards, rate limits, idempotency, the signature gate)
|
|
44
44
|
* happens in the core; this class only resolves a name to an entry, wraps
|
|
45
45
|
* the handler's return into the envelope, and adds what a mock needs on
|
|
46
46
|
* top: failure injection, latency, a subscription, a call log, reset.
|
|
@@ -118,6 +118,8 @@ export class LambderMockApp {
|
|
|
118
118
|
this.idempotencyStore = memoryIdempotency;
|
|
119
119
|
this.pipeline = new LambderApiPipeline({
|
|
120
120
|
apiVersion: this.apiVersion,
|
|
121
|
+
minApiVersion: options.minApiVersion,
|
|
122
|
+
apiSignatures: options.apiSignatures,
|
|
121
123
|
maxRequestPayloadBytes: options.maxRequestPayloadBytes,
|
|
122
124
|
sessions: sessionOptions
|
|
123
125
|
? {
|
|
@@ -351,7 +353,7 @@ export class LambderMockApp {
|
|
|
351
353
|
*
|
|
352
354
|
* Public because the mode of an unregistered name cannot be recovered at
|
|
353
355
|
* runtime, the contract being a type. Everything that precedes dispatch
|
|
354
|
-
* still runs (the
|
|
356
|
+
* still runs (the signature gate, the payload restore); the session read is
|
|
355
357
|
* the one step this answer cannot have, which is the fidelity limit
|
|
356
358
|
* restNotMocked documents.
|
|
357
359
|
*/
|
|
@@ -549,7 +551,7 @@ export class LambderMockApp {
|
|
|
549
551
|
return {
|
|
550
552
|
phase: "request", id, apiName: request.apiName, mode,
|
|
551
553
|
payload: request.payload, guardInputs: request.guardInputs, idempotencyKey: request.idempotencyKey,
|
|
552
|
-
version: request.version, headers: request.headers,
|
|
554
|
+
version: request.version, signature: request.signature, headers: request.headers,
|
|
553
555
|
hasSessionCookie: (request.cookies[this.tokenCookieKey]?.length ?? 0) > 0,
|
|
554
556
|
at,
|
|
555
557
|
};
|
|
@@ -595,7 +597,7 @@ export class LambderMockApp {
|
|
|
595
597
|
try {
|
|
596
598
|
// The protocol's pre-pass, run before the name is resolved, which
|
|
597
599
|
// is where the server runs it. Two things depended on it: an
|
|
598
|
-
// unknown name reached the notFound refusal without the
|
|
600
|
+
// unknown name reached the notFound refusal without the signature
|
|
599
601
|
// gate or the payload restore, so a stale client or a malformed
|
|
600
602
|
// compressed payload was answered differently here than on the
|
|
601
603
|
// server; and the request event carried the wire fields instead of
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
|
|
1
2
|
import type { LambderContractGuardNames } from "../shared/wire/LambderApiContract.js";
|
|
2
3
|
import type { LambderApiGuard } from "../api/LambderApiGuards.js";
|
|
3
4
|
import type { LambderApiRateLimitPolicyConfig } from "../api/LambderApiRateLimits.js";
|
|
@@ -99,8 +100,12 @@ type LambderMockGuardShapes<S, G> = {
|
|
|
99
100
|
[N in keyof G]: LambderMockSurplusKeys<G[N], LambderApiGuard<any, any, any, LambderMockCallContext<S>, LambderMockSessionCallContext<S>>>;
|
|
100
101
|
};
|
|
101
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> & {
|
|
102
|
-
/**
|
|
103
|
+
/** Stamped on every answer's envelope as apiVersion, as the server's option is. */
|
|
103
104
|
apiVersion?: string;
|
|
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. */
|
|
108
|
+
apiSignatures?: LambderApiSignatureMap;
|
|
104
109
|
/** Artificial latency per call; off by default. */
|
|
105
110
|
latency?: LambderMockLatency;
|
|
106
111
|
/** Sessions over the memory store: `true` for the defaults, or the options. Off by default: session endpoints then fail at registration. */
|