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