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