lambder 12.0.1 → 14.0.1
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 +119 -0
- package/README.md +7 -2
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +32 -8
- package/dist/core/Lambder.js +135 -17
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +14 -1
- package/dist/core/LambderContext.js +19 -4
- package/dist/core/LambderCreateOptions.d.ts +50 -25
- package/dist/core/LambderCreateOptions.js +37 -18
- package/dist/core/LambderInvocationScope.d.ts +25 -0
- package/dist/core/LambderInvocationScope.js +21 -0
- package/dist/core/LambderResponse.d.ts +11 -0
- package/dist/core/LambderResponse.js +12 -0
- package/dist/core/LambderResponseBuilder.js +5 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +2 -2
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
- package/dist/invoke/LambderLambdaEvent.js +8 -2
- package/dist/mock/LambderMockApp.d.ts +8 -8
- package/dist/mock/LambderMockApp.js +14 -10
- package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
- package/dist/session/LambderSessionManager.d.ts +46 -3
- package/dist/session/LambderSessionManager.js +62 -11
- package/dist/shared/LambderI18n.js +49 -4
- package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
- package/dist/shared/contracts/LambderRateLimiter.js +2 -2
- package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
- package/dist/shared/util/LambderInvocationScope.js +5 -0
- package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
- package/dist/shared/util/LambderNamedMaps.js +19 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
- package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
- package/dist/shared/wire/LambderCallOutcome.js +47 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
- package/dist/shared/wire/LambderInvokeApiId.js +10 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
- package/dist/stores/LambderDdbRateLimiter.js +64 -45
- package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
- package/dist/stores/LambderMemoryRateLimiter.js +20 -15
- package/dist/testing/LambderTestApp.d.ts +13 -4
- package/dist/testing/LambderTestApp.js +21 -5
- package/package.json +1 -1
package/dist/core/Lambder.js
CHANGED
|
@@ -13,7 +13,7 @@ import { LambderFiles } from "./LambderFiles.js";
|
|
|
13
13
|
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
14
14
|
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
15
15
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
16
|
-
import { checkedRefusal, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
16
|
+
import { checkedRefusal, declaredRefusalVocabulary, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
17
17
|
import { refuse } from "../shared/wire/LambderApiRefusal.js";
|
|
18
18
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
19
19
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
|
|
@@ -28,10 +28,31 @@ import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContex
|
|
|
28
28
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
29
29
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
30
30
|
import { LambderCrashHandling } from "./LambderCrashHandling.js";
|
|
31
|
+
import { currentInvocation, runInvocation } from "./LambderInvocationScope.js";
|
|
32
|
+
import { roundedMilliseconds, writeCallSummaryLine } from "./LambderCallSummary.js";
|
|
33
|
+
import { outcomeOfAnswerText } from "../shared/wire/LambderCallOutcome.js";
|
|
34
|
+
import { LAMBDER_INVOKE_API_ID, LAMBDER_PARENT_REQUEST_HEADER } from "../shared/wire/LambderInvokeApiId.js";
|
|
31
35
|
import { policyBuildersFor } from "./LambderPolicyBuilders.js";
|
|
32
36
|
import { assertCreateOptions, mergeNamedMaps, } from "./LambderCreateOptions.js";
|
|
33
37
|
/** The refusals of a name no API is registered under: no code, and none required. */
|
|
34
38
|
const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
|
|
39
|
+
/** Longest endpoint name a call summary carries: a page built before paths posts the name in its body, and a line is no place for a long one. */
|
|
40
|
+
const MAX_SUMMARY_API_NAME_CHARS = 200;
|
|
41
|
+
/** A request id as the parent header may carry one: what Lambda's own ids look like, and no longer than any of them. */
|
|
42
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
|
|
43
|
+
/**
|
|
44
|
+
* The request id of the invocation that invoked this one, from an invoke's
|
|
45
|
+
* event alone: over HTTP the header is whatever a client wrote, and an
|
|
46
|
+
* invoke's apiId is one no gateway lets a client set.
|
|
47
|
+
*/
|
|
48
|
+
const parentRequestIdOf = (event) => {
|
|
49
|
+
if (event.requestContext?.apiId !== LAMBDER_INVOKE_API_ID)
|
|
50
|
+
return null;
|
|
51
|
+
const value = Object.entries(event.headers ?? {}).find(([name]) => name.toLowerCase() === LAMBDER_PARENT_REQUEST_HEADER)?.[1];
|
|
52
|
+
return typeof value === "string" && REQUEST_ID_PATTERN.test(value) ? value : null;
|
|
53
|
+
};
|
|
54
|
+
/** The API call a request is, as its invocation records it; null for a request that is no API call. */
|
|
55
|
+
const apiCallOf = (ctx, event) => ctx.api ? { api: ctx.api.apiName, parentRequestId: parentRequestIdOf(event) } : null;
|
|
35
56
|
/**
|
|
36
57
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
37
58
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
@@ -130,6 +151,9 @@ export default class Lambder {
|
|
|
130
151
|
contextTools;
|
|
131
152
|
trustedClientIpHeaders;
|
|
132
153
|
trustedHostHeaders;
|
|
154
|
+
originProof;
|
|
155
|
+
/** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
|
|
156
|
+
callSummaryWriter;
|
|
133
157
|
constructor(given = {}) {
|
|
134
158
|
// The guards and the rate-limit policies as one map each, whether
|
|
135
159
|
// they were given as one or as a list; a name two maps declare
|
|
@@ -174,7 +198,9 @@ export default class Lambder {
|
|
|
174
198
|
sessionSalt: session.sessionSalt,
|
|
175
199
|
enableSlidingExpiration: session.enableSlidingExpiration,
|
|
176
200
|
slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
|
|
177
|
-
|
|
201
|
+
// As given: the manager refuses dataRefresh without
|
|
202
|
+
// the schema, which an untyped caller can still pass.
|
|
203
|
+
...{ dataRefresh: session.dataRefresh, dataSchema: session.dataSchema },
|
|
178
204
|
crypto: session.crypto,
|
|
179
205
|
}),
|
|
180
206
|
tokenCookieKey: session.tokenCookieKey,
|
|
@@ -188,6 +214,8 @@ export default class Lambder {
|
|
|
188
214
|
});
|
|
189
215
|
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
190
216
|
this.trustedHostHeaders = options.trustedHostHeaders ?? [];
|
|
217
|
+
this.originProof = options.originProof ?? null;
|
|
218
|
+
this.callSummaryWriter = options.callSummary === false ? null : options.callSummary ?? writeCallSummaryLine;
|
|
191
219
|
this.requireApiGuards = options.requireApiGuards ?? false;
|
|
192
220
|
this.requireRefusalCodes = options.requireRefusalCodes ?? false;
|
|
193
221
|
this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
|
|
@@ -579,6 +607,8 @@ export default class Lambder {
|
|
|
579
607
|
[LAMBDER_BACKEND_SWAP](backends) {
|
|
580
608
|
if (this.files && backends.fileSource)
|
|
581
609
|
this.files[LAMBDER_BACKEND_SWAP](backends.fileSource);
|
|
610
|
+
if (backends.callSummary !== undefined)
|
|
611
|
+
this.callSummaryWriter = backends.callSummary;
|
|
582
612
|
return { ...this.pipeline[LAMBDER_BACKEND_SWAP](backends), files: this.files !== null };
|
|
583
613
|
}
|
|
584
614
|
/**
|
|
@@ -872,7 +902,19 @@ export default class Lambder {
|
|
|
872
902
|
return beforeRenderResult;
|
|
873
903
|
return await matched.action.actionFn(beforeRenderResult, resolver);
|
|
874
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* One HTTP invocation, from the event to the finalized response, under
|
|
907
|
+
* an invocation record of its own (see LambderInvocationScope): what an
|
|
908
|
+
* API call's summary line is written from once the response is final.
|
|
909
|
+
*/
|
|
875
910
|
async render(event, lambdaContext) {
|
|
911
|
+
return await runInvocation(lambdaContext, async (invocation) => {
|
|
912
|
+
const response = await this.renderRequest(event, lambdaContext, invocation);
|
|
913
|
+
this.writeCallSummary(invocation, response.statusCode);
|
|
914
|
+
return response;
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
async renderRequest(event, lambdaContext, invocation) {
|
|
876
918
|
let ctx = null;
|
|
877
919
|
let started = false;
|
|
878
920
|
// Settled as soon as the context exists and reused by every answer,
|
|
@@ -881,11 +923,8 @@ export default class Lambder {
|
|
|
881
923
|
try {
|
|
882
924
|
await this.ensureInitialized();
|
|
883
925
|
started = true;
|
|
884
|
-
ctx = bindContextTools(createContext(event, lambdaContext,
|
|
885
|
-
|
|
886
|
-
trustedClientIpHeaders: this.trustedClientIpHeaders,
|
|
887
|
-
trustedHostHeaders: this.trustedHostHeaders,
|
|
888
|
-
}), this.contextTools);
|
|
926
|
+
ctx = bindContextTools(createContext(event, lambdaContext, this.contextOptions()), this.contextTools);
|
|
927
|
+
invocation.call = apiCallOf(ctx, event);
|
|
889
928
|
if (this.corsConfig)
|
|
890
929
|
allowedOrigin = allowedCorsOriginOf(this.corsConfig, ctx);
|
|
891
930
|
const resolver = this.getResolver(ctx);
|
|
@@ -934,12 +973,33 @@ export default class Lambder {
|
|
|
934
973
|
// reach it.
|
|
935
974
|
ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
|
|
936
975
|
this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
|
|
976
|
+
invocation.outcome = response.callOutcome ?? { outcome: "other", code: null };
|
|
937
977
|
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
|
|
938
978
|
}
|
|
939
979
|
catch (err) {
|
|
980
|
+
// A crash before the context existed (a created hook that failed)
|
|
981
|
+
// still answers an API call, and its summary line is the one a
|
|
982
|
+
// dashboard counts the crash by. The context is read from the
|
|
983
|
+
// event and the instance's options alone, so it can be read here;
|
|
984
|
+
// an event it cannot be read from has no call to name.
|
|
985
|
+
if (!ctx) {
|
|
986
|
+
try {
|
|
987
|
+
invocation.call = apiCallOf(createContext(event, lambdaContext, this.contextOptions()), event);
|
|
988
|
+
}
|
|
989
|
+
catch { /* no call to name */ }
|
|
990
|
+
}
|
|
940
991
|
return await this.answerCrash(err, ctx, allowedOrigin, started, event, lambdaContext);
|
|
941
992
|
}
|
|
942
993
|
}
|
|
994
|
+
/** What createContext reads this instance's requests with. */
|
|
995
|
+
contextOptions() {
|
|
996
|
+
return {
|
|
997
|
+
apiPath: this.apiPath,
|
|
998
|
+
trustedClientIpHeaders: this.trustedClientIpHeaders,
|
|
999
|
+
trustedHostHeaders: this.trustedHostHeaders,
|
|
1000
|
+
originProof: this.originProof,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
943
1003
|
/**
|
|
944
1004
|
* A thrown value that is an answer rather than a crash, as the response;
|
|
945
1005
|
* anything else is rethrown to the crash path.
|
|
@@ -978,6 +1038,10 @@ export default class Lambder {
|
|
|
978
1038
|
// invocation would reject with a 502 no client can parse.
|
|
979
1039
|
const error = coerceToError(thrown, "an unstringifiable thrown value");
|
|
980
1040
|
this.crashWatcher?.(error);
|
|
1041
|
+
// A crash, whatever the global error handler then answers with.
|
|
1042
|
+
const invocation = currentInvocation();
|
|
1043
|
+
if (invocation)
|
|
1044
|
+
invocation.outcome = { outcome: "crash", code: null };
|
|
981
1045
|
const site = !started ? { kind: "startup", lambdaContext }
|
|
982
1046
|
: ctx?.api ? { kind: "api", ctx, lambdaContext }
|
|
983
1047
|
: { kind: "route", ctx, lambdaContext };
|
|
@@ -1014,6 +1078,37 @@ export default class Lambder {
|
|
|
1014
1078
|
const crashResponse = this.withCallHeaders(ctx, allowedOrigin, await this.crashHandling.frameworkResponse(error, ctx, errorHandlerCrash));
|
|
1015
1079
|
return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
|
|
1016
1080
|
}
|
|
1081
|
+
/**
|
|
1082
|
+
* The summary line of an API call, once its response is final: nothing
|
|
1083
|
+
* for an invocation that served no API call. A writer that throws costs
|
|
1084
|
+
* the call its line and nothing else.
|
|
1085
|
+
*/
|
|
1086
|
+
writeCallSummary(invocation, status) {
|
|
1087
|
+
const { call } = invocation;
|
|
1088
|
+
if (!call || !this.callSummaryWriter)
|
|
1089
|
+
return;
|
|
1090
|
+
const summary = {
|
|
1091
|
+
kind: "lambder.call",
|
|
1092
|
+
// The path form is two identifiers; a page built before paths
|
|
1093
|
+
// posts the name in its body, which is whatever it sent.
|
|
1094
|
+
api: call.api.slice(0, MAX_SUMMARY_API_NAME_CHARS),
|
|
1095
|
+
outcome: invocation.outcome?.outcome ?? "other",
|
|
1096
|
+
code: invocation.outcome?.code ?? null,
|
|
1097
|
+
status,
|
|
1098
|
+
durationMs: roundedMilliseconds(performance.now() - invocation.startedAt),
|
|
1099
|
+
handlerMs: invocation.handlerMs === null ? null : roundedMilliseconds(invocation.handlerMs),
|
|
1100
|
+
replayed: invocation.replayed,
|
|
1101
|
+
coldStart: invocation.coldStart,
|
|
1102
|
+
requestId: invocation.requestId,
|
|
1103
|
+
parentRequestId: call.parentRequestId,
|
|
1104
|
+
};
|
|
1105
|
+
try {
|
|
1106
|
+
this.callSummaryWriter(summary);
|
|
1107
|
+
}
|
|
1108
|
+
catch (err) {
|
|
1109
|
+
console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api}" was not written. ${coerceToError(err).message}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1017
1112
|
/**
|
|
1018
1113
|
* An answer to a crash, carrying what the call wrote and its CORS headers.
|
|
1019
1114
|
* As on the success path, headers belong to the call: a call that wrote a
|
|
@@ -1070,6 +1165,11 @@ export default class Lambder {
|
|
|
1070
1165
|
* so Lambda's retries and dead-letter queues still see the failure.
|
|
1071
1166
|
*/
|
|
1072
1167
|
async renderEvent(event, lambdaContext) {
|
|
1168
|
+
// Under an invocation record, like an HTTP invocation, so the calls an
|
|
1169
|
+
// action makes over invoke carry its request id.
|
|
1170
|
+
return await runInvocation(lambdaContext, () => this.renderEventAction(event, lambdaContext));
|
|
1171
|
+
}
|
|
1172
|
+
async renderEventAction(event, lambdaContext) {
|
|
1073
1173
|
let started = false;
|
|
1074
1174
|
try {
|
|
1075
1175
|
await this.ensureInitialized();
|
|
@@ -1101,7 +1201,8 @@ export default class Lambder {
|
|
|
1101
1201
|
*/
|
|
1102
1202
|
async inputValidationRefusal(ctx, zodError) {
|
|
1103
1203
|
if (this.apiInputValidationErrorHandler) {
|
|
1104
|
-
|
|
1204
|
+
// A validation refusal, however the app words it.
|
|
1205
|
+
return { ...answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError)), outcome: { outcome: "validation", code: null } };
|
|
1105
1206
|
}
|
|
1106
1207
|
// null asks the pipeline for the standard 422, so that answer is
|
|
1107
1208
|
// written once, in the core, rather than here as well.
|
|
@@ -1140,9 +1241,18 @@ export default class Lambder {
|
|
|
1140
1241
|
const request = ctx.api;
|
|
1141
1242
|
if (!request)
|
|
1142
1243
|
throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
|
|
1143
|
-
const
|
|
1244
|
+
const invocation = currentInvocation();
|
|
1245
|
+
const { answer, replayed } = await this.pipeline.run(request, ctx, definition, async () => {
|
|
1144
1246
|
ctx.apiPayload = request.payload;
|
|
1145
|
-
const
|
|
1247
|
+
const handlerStarted = performance.now();
|
|
1248
|
+
let returned;
|
|
1249
|
+
try {
|
|
1250
|
+
returned = await handler(ctx);
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
if (invocation)
|
|
1254
|
+
invocation.handlerMs = performance.now() - handlerStarted;
|
|
1255
|
+
}
|
|
1146
1256
|
let parsed;
|
|
1147
1257
|
try {
|
|
1148
1258
|
parsed = output.safeParse(returned);
|
|
@@ -1157,8 +1267,11 @@ export default class Lambder {
|
|
|
1157
1267
|
return envelopeAnswer(successEnvelope(this.apiVersion, parsed.data, ctx.logList));
|
|
1158
1268
|
});
|
|
1159
1269
|
// The API's compress option, on whatever answer the call ended with:
|
|
1160
|
-
// a replayed one comes back from its store without the
|
|
1161
|
-
|
|
1270
|
+
// a replayed one comes back from its store without the hints, and
|
|
1271
|
+
// its outcome is read back from its text, which only a replay costs.
|
|
1272
|
+
if (invocation)
|
|
1273
|
+
invocation.replayed = replayed;
|
|
1274
|
+
return responseFromAnswer({ ...answer, compress, outcome: answer.outcome ?? outcomeOfAnswerText(answer.statusCode, answer.body) });
|
|
1162
1275
|
}
|
|
1163
1276
|
/**
|
|
1164
1277
|
* A thrown LambderApiRefusal (from a hook, say) as the structured API
|
|
@@ -1204,7 +1317,10 @@ export const initLambder = () => ({
|
|
|
1204
1317
|
* Declares the app's refusal vocabulary: every code once, with the schema
|
|
1205
1318
|
* of its data (`{ data: schema }`) or none (`{}`), the status every
|
|
1206
1319
|
* refusal with it leaves with (`status`, 200 by default) and whether it
|
|
1207
|
-
* sets the notAuthorized flag (`notAuthorized: true`).
|
|
1320
|
+
* sets the notAuthorized flag (`notAuthorized: true`). One map of codes,
|
|
1321
|
+
* or a list of them, as guards and rate-limit policies are declared, so
|
|
1322
|
+
* each part of an app declares its own codes; a code two maps declare is
|
|
1323
|
+
* refused, at compile time and at the call. Returns the init
|
|
1208
1324
|
* bound to it: its guard() types ctx.refuse to a guard's refusals and
|
|
1209
1325
|
* refuses a guard naming a code outside the vocabulary, its refuse is
|
|
1210
1326
|
* typed to the whole vocabulary, and its create() hands the vocabulary to
|
|
@@ -1213,9 +1329,11 @@ export const initLambder = () => ({
|
|
|
1213
1329
|
* "no" a client reads names a code.
|
|
1214
1330
|
*/
|
|
1215
1331
|
declareRefusals(refusals, options = {}) {
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1332
|
+
const declared = declaredRefusalVocabulary(refusals, "Lambder");
|
|
1333
|
+
return lambderInitOf({
|
|
1334
|
+
refusals: declared.refusals,
|
|
1335
|
+
vocabulary: declared.vocabulary,
|
|
1336
|
+
requireCodes: (options.requireCodes ?? false),
|
|
1337
|
+
});
|
|
1220
1338
|
},
|
|
1221
1339
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { LambderCallOutcome } from "../shared/wire/LambderCallOutcome.js";
|
|
2
|
+
/**
|
|
3
|
+
* One API call as its summary line records it: what was called, how it
|
|
4
|
+
* ended, how long it took, and the ids that join it to the rest of the
|
|
5
|
+
* logs. Nothing from the call's input, its session, its cookies or its
|
|
6
|
+
* caller's address is in it, so the line can be kept and queried as long as
|
|
7
|
+
* the app likes.
|
|
8
|
+
*/
|
|
9
|
+
export type LambderCallSummary = {
|
|
10
|
+
/** Always "lambder.call": what a log query picks the lines out by. */
|
|
11
|
+
kind: "lambder.call";
|
|
12
|
+
/** The endpoint the call's path named (`group.action`), whether or not one is registered under it. */
|
|
13
|
+
api: string;
|
|
14
|
+
/** How the call ended; see LambderCallOutcome. */
|
|
15
|
+
outcome: LambderCallOutcome;
|
|
16
|
+
/** The refusal's code, the framework's (`lambder/rate-limited`) or the app's; null when the answer carries none. */
|
|
17
|
+
code: string | null;
|
|
18
|
+
/** The HTTP status the call was answered with. */
|
|
19
|
+
status: number;
|
|
20
|
+
/** From the invocation's start to the answer, in milliseconds. */
|
|
21
|
+
durationMs: number;
|
|
22
|
+
/** How long the API handler itself ran, in milliseconds; null when it did not run (refused before it, or replayed). */
|
|
23
|
+
handlerMs: number | null;
|
|
24
|
+
/** True when a stored idempotent answer was replayed and no handler ran. */
|
|
25
|
+
replayed: boolean;
|
|
26
|
+
/** True on the first invocation of this process, whose duration includes loading the app. */
|
|
27
|
+
coldStart: boolean;
|
|
28
|
+
/** The invocation's request id, the one Lambda's own log lines for it carry. */
|
|
29
|
+
requestId: string | null;
|
|
30
|
+
/** The request id of the invocation that called this one over a direct invoke (LambderInvokeCaller), which carries it; null otherwise. */
|
|
31
|
+
parentRequestId: string | null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Where the summaries go: the default writes each as one JSON line on
|
|
35
|
+
* stdout, where a Lambda function's log group keeps it and CloudWatch Logs
|
|
36
|
+
* Insights reads its fields without a parse step; a function receives each
|
|
37
|
+
* instead; false writes none.
|
|
38
|
+
*/
|
|
39
|
+
export type LambderCallSummaryOption = false | ((summary: LambderCallSummary) => void);
|
|
40
|
+
/** One JSON line on stdout, written directly rather than through console.log, which on Lambda prefixes a timestamp and a level that would stop the line reading as JSON. */
|
|
41
|
+
export declare const writeCallSummaryLine: (summary: LambderCallSummary) => void;
|
|
42
|
+
/** Milliseconds as the line records them: to a tenth, which is finer than a call can be told apart by and keeps the line short. */
|
|
43
|
+
export declare const roundedMilliseconds: (milliseconds: number) => number;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One JSON line on stdout, written directly rather than through console.log, which on Lambda prefixes a timestamp and a level that would stop the line reading as JSON. */
|
|
2
|
+
export const writeCallSummaryLine = (summary) => {
|
|
3
|
+
const line = `${JSON.stringify(summary)}\n`;
|
|
4
|
+
if (typeof process !== "undefined" && typeof process.stdout?.write === "function")
|
|
5
|
+
process.stdout.write(line);
|
|
6
|
+
else
|
|
7
|
+
console.log(line.trimEnd());
|
|
8
|
+
};
|
|
9
|
+
/** Milliseconds as the line records them: to a tenth, which is finer than a call can be told apart by and keeps the line short. */
|
|
10
|
+
export const roundedMilliseconds = (milliseconds) => Math.round(milliseconds * 10) / 10;
|
|
@@ -147,6 +147,17 @@ export type LambderContextTools = {
|
|
|
147
147
|
* again by the instance on whatever context a beforeRender hook hands back.
|
|
148
148
|
*/
|
|
149
149
|
export declare const bindContextTools: (ctx: Omit<LambderRenderContext, LambderContextToolName> | LambderRenderContext, tools: LambderContextTools) => LambderRenderContext;
|
|
150
|
+
/**
|
|
151
|
+
* Proof that a request came through the proxy in front of the app: a header
|
|
152
|
+
* the proxy sets to a secret on every request it forwards, which a request
|
|
153
|
+
* sent to the origin directly cannot carry. See `originProof` at create().
|
|
154
|
+
*/
|
|
155
|
+
export type LambderOriginProof = {
|
|
156
|
+
/** The header the proxy sets, such as "x-origin-proof". */
|
|
157
|
+
header: string;
|
|
158
|
+
/** The values it may carry: the current secret, and during a rotation the one before it. */
|
|
159
|
+
secrets: readonly string[];
|
|
160
|
+
};
|
|
150
161
|
/** What createContext reads a request with: the instance's own settings for where an API call goes and which forwarded headers it trusts. */
|
|
151
162
|
export type LambderContextOptions = {
|
|
152
163
|
/** See `apiPath` at create(). Default: "/api", as there. */
|
|
@@ -155,7 +166,9 @@ export type LambderContextOptions = {
|
|
|
155
166
|
trustedClientIpHeaders?: readonly string[];
|
|
156
167
|
/** See `trustedHostHeaders` at create(). Default: none. */
|
|
157
168
|
trustedHostHeaders?: readonly string[];
|
|
169
|
+
/** See `originProof` at create(). Default: none, so the trusted headers are read on every request. */
|
|
170
|
+
originProof?: LambderOriginProof | null;
|
|
158
171
|
};
|
|
159
172
|
/** The render context for one request: everything a route handler, an API handler, a hook or a guard reads about it, built once from the Lambda event. */
|
|
160
|
-
export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, { apiPath, trustedClientIpHeaders, trustedHostHeaders }?: LambderContextOptions) => LambderRenderContext;
|
|
173
|
+
export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, { apiPath, trustedClientIpHeaders, trustedHostHeaders, originProof }?: LambderContextOptions) => LambderRenderContext;
|
|
161
174
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
|
|
2
2
|
import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
|
|
3
3
|
import { resolveClientIp } from "../shared/util/LambderClientIp.js";
|
|
4
|
+
import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
|
|
4
5
|
import { base64ToText } from "../shared/util/LambderBase64.js";
|
|
5
6
|
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
6
7
|
import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
|
|
@@ -69,7 +70,7 @@ const withoutStagePrefix = (path, stage) => stage && stage !== "$default" && (pa
|
|
|
69
70
|
? path.slice(stage.length + 1) || "/"
|
|
70
71
|
: path;
|
|
71
72
|
/** The render context for one request: everything a route handler, an API handler, a hook or a guard reads about it, built once from the Lambda event. */
|
|
72
|
-
export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH, trustedClientIpHeaders = [], trustedHostHeaders = [] } = {}) => {
|
|
73
|
+
export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH, trustedClientIpHeaders = [], trustedHostHeaders = [], originProof = null } = {}) => {
|
|
73
74
|
// Normalize the two API Gateway payload formats into one shape.
|
|
74
75
|
const eventFormat = isV2HttpEvent(event) ? "v2" : "v1";
|
|
75
76
|
let host;
|
|
@@ -82,7 +83,7 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
82
83
|
let get;
|
|
83
84
|
let cookiePairs;
|
|
84
85
|
let sourceIp;
|
|
85
|
-
|
|
86
|
+
let headers = event.headers ?? {};
|
|
86
87
|
if (isV2HttpEvent(event)) {
|
|
87
88
|
host = headers.host || event.requestContext.domainName || "";
|
|
88
89
|
const apiId = event.requestContext.apiId;
|
|
@@ -127,6 +128,19 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
127
128
|
for (const [name, values] of Object.entries(cookieList))
|
|
128
129
|
cookie[name] = values[0];
|
|
129
130
|
const lowercasedHeaders = lowercaseHeaderNames(headers);
|
|
131
|
+
// The origin proof is read, then taken off the headers the app reads
|
|
132
|
+
// (ctx.headers, ctx.header()), so no handler, hook or log of them meets
|
|
133
|
+
// the secret; the raw event keeps it. A request that does not carry it
|
|
134
|
+
// came to the origin some other way than through the proxy, and the
|
|
135
|
+
// proxy's headers on it are whatever its sender wrote.
|
|
136
|
+
let proven = true;
|
|
137
|
+
if (originProof) {
|
|
138
|
+
const proofHeader = originProof.header.toLowerCase();
|
|
139
|
+
const presented = lowercasedHeaders[proofHeader] ?? "";
|
|
140
|
+
proven = originProof.secrets.some((secret) => constantTimeEquals(presented, secret));
|
|
141
|
+
delete lowercasedHeaders[proofHeader];
|
|
142
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== proofHeader));
|
|
143
|
+
}
|
|
130
144
|
const header = (name) => lowercasedHeaders[name.toLowerCase()];
|
|
131
145
|
// A trusted forwarding header is trusted because a proxy in front of this
|
|
132
146
|
// function writes it. A direct invoke has no such proxy: its headers are
|
|
@@ -135,19 +149,20 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
135
149
|
// address and the host are the ones the invoker named (clientIp and host,
|
|
136
150
|
// delivered as sourceIp and Host), and no header is read for either.
|
|
137
151
|
const invokedDirectly = event.requestContext?.apiId === LAMBDER_INVOKE_API_ID;
|
|
152
|
+
const readsForwarding = proven && !invokedDirectly;
|
|
138
153
|
// The first trusted header carrying a well-formed host, leftmost entry,
|
|
139
154
|
// else the host the gateway saw. Behind CloudFront a Function URL sees
|
|
140
155
|
// its own lambda-url domain, since CloudFront sends an origin its own
|
|
141
156
|
// Host, so cookie domains and host routing need the viewer's host from a
|
|
142
157
|
// header the distribution writes.
|
|
143
|
-
for (const name of
|
|
158
|
+
for (const name of readsForwarding ? trustedHostHeaders : []) {
|
|
144
159
|
const forwarded = (lowercasedHeaders[name.toLowerCase()] ?? "").split(",")[0].trim();
|
|
145
160
|
if (HOST_VALUE_PATTERN.test(forwarded)) {
|
|
146
161
|
host = forwarded;
|
|
147
162
|
break;
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
|
-
const ip = resolveClientIp(lowercasedHeaders, sourceIp,
|
|
165
|
+
const ip = resolveClientIp(lowercasedHeaders, sourceIp, readsForwarding ? trustedClientIpHeaders : []);
|
|
151
166
|
// Decode body: keep the raw string, then parse as JSON with urlencoded fallback.
|
|
152
167
|
const rawBody = event.isBase64Encoded
|
|
153
168
|
? (event.body ? base64ToText(event.body) : "")
|
|
@@ -4,7 +4,8 @@ import type { Context } from "aws-lambda";
|
|
|
4
4
|
import type LambderResolver from "./LambderResolver.js";
|
|
5
5
|
import type LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
6
6
|
import type { LambderResponse, LambderHttpResponse, LambderResponseCompressionOption, LambderResponseCompressionSettings } from "./LambderResponse.js";
|
|
7
|
-
import type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./LambderContext.js";
|
|
7
|
+
import type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent, LambderOriginProof } from "./LambderContext.js";
|
|
8
|
+
import type { LambderCallSummaryOption } from "./LambderCallSummary.js";
|
|
8
9
|
import type { LambderFilesOption } from "./LambderFiles.js";
|
|
9
10
|
import type { LambderCorsConfig } from "./LambderCors.js";
|
|
10
11
|
import type { LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
|
|
@@ -112,26 +113,8 @@ export type LambderHandler = {
|
|
|
112
113
|
(event: LambderHttpEvent, context: Context): Promise<LambderHttpResponse>;
|
|
113
114
|
(event: unknown, context: Context): Promise<unknown>;
|
|
114
115
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
* each declaring its own guards or rate-limit policies beside its APIs, hands
|
|
118
|
-
* create() the list, and the instance declares every name in it. A name two
|
|
119
|
-
* maps declare is refused rather than one quietly replacing the other.
|
|
120
|
-
*/
|
|
121
|
-
export type LambderNamedMapsOption<TMap> = TMap | readonly TMap[];
|
|
122
|
-
/** The one map a named-maps option declares: a list's members intersected, a lone map as it is. */
|
|
123
|
-
export type LambderMergedNamedMaps<T> = T extends readonly [] ? {} : T extends readonly [infer TFirst, ...infer TRest] ? TFirst & LambderMergedNamedMaps<TRest> : T extends readonly (infer TMember)[] ? TMember : T;
|
|
124
|
-
/** The names a list declares in more than one of its maps: never for a lone map, or for a list whose members are not known one by one. */
|
|
125
|
-
type LambderRepeatedNames<T, TSeen extends PropertyKey = never> = T extends readonly [infer TFirst, ...infer TRest] ? (keyof TFirst & TSeen) | LambderRepeatedNames<TRest, TSeen | keyof TFirst> : never;
|
|
126
|
-
/** Intersected onto a list: a name declared in two of its maps is a compile error, and the property name is the message. */
|
|
127
|
-
type LambderNoRepeatedNames<T> = [LambderRepeatedNames<T>] extends [never] ? unknown : {
|
|
128
|
-
readonly "lambder: a name is declared in more than one of these maps. Declare each guard and each rate-limit policy once.": LambderRepeatedNames<T>;
|
|
129
|
-
};
|
|
130
|
-
/**
|
|
131
|
-
* A named-maps option as one map: a lone map as it is, a list merged in
|
|
132
|
-
* order. A name declared in two maps of the list is refused.
|
|
133
|
-
*/
|
|
134
|
-
export declare const mergeNamedMaps: <TMap extends Record<string, unknown>>(option: LambderNamedMapsOption<TMap>, what: "guard" | "rate-limit policy") => TMap;
|
|
116
|
+
export { mergeNamedMaps, type LambderMergedNamedMaps, type LambderNamedMapsOption } from "../shared/util/LambderNamedMaps.js";
|
|
117
|
+
import type { LambderNamedMapsOption, LambderNoRepeatedNames } from "../shared/util/LambderNamedMaps.js";
|
|
135
118
|
/** Session configuration (the `session` option of create/new): where sessions rest, and how their cookies are scoped. */
|
|
136
119
|
export type LambderSessionOptions<TSessionData = any> = {
|
|
137
120
|
/**
|
|
@@ -155,6 +138,15 @@ export type LambderSessionOptions<TSessionData = any> = {
|
|
|
155
138
|
/** Session cookie names. Defaults: LMDRSESSIONTKID / LMDRSESSIONCSTK. */
|
|
156
139
|
tokenCookieKey?: string;
|
|
157
140
|
csrfCookieKey?: string;
|
|
141
|
+
/** Hashing and randomness for the session tokens. Default: WebCrypto. */
|
|
142
|
+
crypto?: LambderSessionCrypto;
|
|
143
|
+
} & ({
|
|
144
|
+
/** The schema of session.data, checked on every read; data that does not match ends the session. See LambderSessionDataOptions. */
|
|
145
|
+
dataSchema?: z.ZodType<TSessionData>;
|
|
146
|
+
dataRefresh?: undefined;
|
|
147
|
+
} | {
|
|
148
|
+
/** The schema of session.data, checked on every read and on every refresh's output; data that does not match refreshes at once. Required beside dataRefresh. See LambderSessionDataOptions. */
|
|
149
|
+
dataSchema: z.ZodType<TSessionData>;
|
|
158
150
|
/**
|
|
159
151
|
* Opt-in freshness for session.data derived from external state (roles,
|
|
160
152
|
* permissions, feature flags...). Every session read renews data past
|
|
@@ -163,10 +155,8 @@ export type LambderSessionOptions<TSessionData = any> = {
|
|
|
163
155
|
* end the session. See LambderSessionDataRefreshConfig for the exact
|
|
164
156
|
* semantics.
|
|
165
157
|
*/
|
|
166
|
-
dataRefresh
|
|
167
|
-
|
|
168
|
-
crypto?: LambderSessionCrypto;
|
|
169
|
-
};
|
|
158
|
+
dataRefresh: LambderSessionDataRefreshConfig<TSessionData>;
|
|
159
|
+
});
|
|
170
160
|
/** One map of the server's guards, each pinned to the render contexts of the app's session type. */
|
|
171
161
|
export type LambderGuardsMap<TSessionData = any> = Record<string, LambderApiGuard<any, any, any, LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>>>;
|
|
172
162
|
/** One map of the server's rate-limit policies. */
|
|
@@ -274,6 +264,38 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
274
264
|
* invoker's `host`.
|
|
275
265
|
*/
|
|
276
266
|
trustedHostHeaders?: readonly string[];
|
|
267
|
+
/**
|
|
268
|
+
* Proof that a request came through the proxy the trusted headers are
|
|
269
|
+
* trusted for: `{ header, secrets }`, a header the proxy sets on every
|
|
270
|
+
* request it forwards (a Cloudflare transform rule, a CloudFront origin
|
|
271
|
+
* custom header) and the secret it carries, with the previous one beside
|
|
272
|
+
* it during a rotation. The trusted client address and host headers are
|
|
273
|
+
* read only from a request that carries it. One sent to the origin
|
|
274
|
+
* directly (an API Gateway's own execute-api URL, a Function URL) has
|
|
275
|
+
* its address and host from the gateway, whatever headers it wrote, so
|
|
276
|
+
* a `per: "ip"` limit counts it by the address it really came from. The
|
|
277
|
+
* header is taken off `ctx.headers` and `ctx.header()`, so no handler or
|
|
278
|
+
* hook meets the secret. Default: none, so the trusted headers are read
|
|
279
|
+
* on every request, which is right only for an origin nothing but the
|
|
280
|
+
* proxy can reach.
|
|
281
|
+
*/
|
|
282
|
+
originProof?: LambderOriginProof;
|
|
283
|
+
/**
|
|
284
|
+
* One summary per API call, written when the call is answered: the
|
|
285
|
+
* endpoint, how it ended (success, a refusal and its code, a flag, a
|
|
286
|
+
* validation refusal, a crash), the status, the duration and the
|
|
287
|
+
* handler's own time, whether an idempotent answer was replayed, whether
|
|
288
|
+
* the invocation was the process's first, and its request id with the
|
|
289
|
+
* id of the invocation that called it over invoke. Nothing from the
|
|
290
|
+
* input, the session, the cookies or the caller's address. See
|
|
291
|
+
* LambderCallSummary.
|
|
292
|
+
*
|
|
293
|
+
* Default: each summary as one JSON line on stdout, which a Lambda
|
|
294
|
+
* function's log group keeps and CloudWatch Logs Insights reads field by
|
|
295
|
+
* field. A function receives each summary instead (another logger, a
|
|
296
|
+
* metrics client); false writes none.
|
|
297
|
+
*/
|
|
298
|
+
callSummary?: LambderCallSummaryOption;
|
|
277
299
|
/** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
|
|
278
300
|
cors?: boolean | LambderCorsConfig;
|
|
279
301
|
/** Sessions over a store of your choosing; required for a guard that needs a session, and for addSessionRoute. */
|
|
@@ -474,6 +496,7 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
|
|
|
474
496
|
};
|
|
475
497
|
idempotency?: LambderNoExtraKeys<NonNullable<TOptions["idempotency"]>, LambderOptionShape<TSessionData, "idempotency">>;
|
|
476
498
|
crashes?: LambderNoExtraKeys<NonNullable<TOptions["crashes"]>, LambderOptionShape<TSessionData, "crashes">>;
|
|
499
|
+
originProof?: LambderNoExtraKeys<NonNullable<TOptions["originProof"]>, LambderOptionShape<TSessionData, "originProof">>;
|
|
477
500
|
rateLimits?: LambderNoExtraKeys<NonNullable<TOptions["rateLimits"]>, LambderOptionShape<TSessionData, "rateLimits">> & {
|
|
478
501
|
policies?: LambderRateLimitPolicyChecks<NonNullable<TOptions["rateLimits"]>["policies"]>;
|
|
479
502
|
};
|
|
@@ -493,3 +516,5 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
|
|
|
493
516
|
* response (maxResponseBytes: 0) that an app discovers in production.
|
|
494
517
|
*/
|
|
495
518
|
export declare const assertCreateOptions: (options: LambderMergedCreateOptions<any>) => void;
|
|
519
|
+
/** Shortest secret an origin proof takes: one a sender could guess is no proof. 32 characters of hex or base64 is 128 bits or more. */
|
|
520
|
+
export declare const MIN_ORIGIN_PROOF_SECRET_LENGTH = 32;
|
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
import { assertRefusalCodesDeclared, readRefusalVocabulary } from "../api/LambderApiRefusals.js";
|
|
2
2
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
for (const map of option) {
|
|
12
|
-
for (const [name, declaration] of Object.entries(map)) {
|
|
13
|
-
if (Object.prototype.hasOwnProperty.call(merged, name)) {
|
|
14
|
-
throw new Error(`Lambder: the ${what} "${name}" is declared in two of the maps given to create(). Declare each name once.`);
|
|
15
|
-
}
|
|
16
|
-
merged[name] = declaration;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return merged;
|
|
20
|
-
};
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Named maps: the guards and the rate-limit policies, each declared in one
|
|
5
|
+
// map or in a list of maps (one per part of an app), merged in order.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The named-maps option (one map, or a list of them) lives with the shared
|
|
8
|
+
// utilities, since the refusal vocabulary takes it too; re-exported here,
|
|
9
|
+
// where its users import it.
|
|
10
|
+
export { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
|
|
21
11
|
/**
|
|
22
12
|
* Everything create() refuses before an instance exists, in one place: a
|
|
23
13
|
* value that cannot work is a startup error naming the option, not a 404 on
|
|
@@ -56,4 +46,33 @@ export const assertCreateOptions = (options) => {
|
|
|
56
46
|
if (options.requireApiGuards && !options.guards) {
|
|
57
47
|
throw new Error("Lambder: requireApiGuards needs a guards map at creation for APIs to declare from.");
|
|
58
48
|
}
|
|
49
|
+
if (options.originProof !== undefined)
|
|
50
|
+
assertOriginProof(options);
|
|
51
|
+
if (options.callSummary !== undefined && options.callSummary !== false && typeof options.callSummary !== "function") {
|
|
52
|
+
throw new Error("Lambder: callSummary is false, or a function that receives each call's summary.");
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** Shortest secret an origin proof takes: one a sender could guess is no proof. 32 characters of hex or base64 is 128 bits or more. */
|
|
56
|
+
export const MIN_ORIGIN_PROOF_SECRET_LENGTH = 32;
|
|
57
|
+
/** An origin proof that proves something: a header name, secrets long enough not to guess, and trusted headers for it to guard. */
|
|
58
|
+
const assertOriginProof = (options) => {
|
|
59
|
+
const proof = options.originProof;
|
|
60
|
+
if (typeof proof?.header !== "string" || !/^[A-Za-z0-9-]+$/.test(proof.header)) {
|
|
61
|
+
throw new Error("Lambder: originProof.header must be a header name, such as \"x-origin-proof\".");
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(proof.secrets) || proof.secrets.length === 0) {
|
|
64
|
+
throw new Error("Lambder: originProof.secrets must list the secret the proxy sets, with the previous one beside it during a rotation.");
|
|
65
|
+
}
|
|
66
|
+
for (const secret of proof.secrets) {
|
|
67
|
+
if (typeof secret !== "string" || secret.length < MIN_ORIGIN_PROOF_SECRET_LENGTH) {
|
|
68
|
+
throw new Error(`Lambder: every originProof secret must be a string of at least ${MIN_ORIGIN_PROOF_SECRET_LENGTH} characters; a shorter one could be guessed, and a guessed proof proves nothing.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const trusted = [...(options.trustedClientIpHeaders ?? []), ...(options.trustedHostHeaders ?? [])];
|
|
72
|
+
if (trusted.length === 0) {
|
|
73
|
+
throw new Error("Lambder: originProof guards the trustedClientIpHeaders and trustedHostHeaders, and this instance trusts none, so it would prove nothing.");
|
|
74
|
+
}
|
|
75
|
+
if (trusted.some((name) => name.toLowerCase() === proof.header.toLowerCase())) {
|
|
76
|
+
throw new Error(`Lambder: originProof.header "${proof.header}" is also a trusted header; the proof is its own header, which the app never reads.`);
|
|
77
|
+
}
|
|
59
78
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from "aws-lambda";
|
|
2
|
+
import type { LambderCallOutcomeHint } from "../shared/wire/LambderCallOutcome.js";
|
|
3
|
+
import { type LambderInvocation } from "../shared/util/LambderInvocationScope.js";
|
|
4
|
+
/** What one invocation records about itself while it runs, for its call summary and for the calls it makes. */
|
|
5
|
+
export type LambderInvocationRecord = LambderInvocation & {
|
|
6
|
+
/** When the invocation started, on performance.now()'s clock. */
|
|
7
|
+
readonly startedAt: number;
|
|
8
|
+
/** Whether this is the first invocation this process has run. */
|
|
9
|
+
readonly coldStart: boolean;
|
|
10
|
+
/** How long the API handler itself ran, in milliseconds; null until it has. */
|
|
11
|
+
handlerMs: number | null;
|
|
12
|
+
/** Whether an idempotent answer was replayed, so no handler ran. */
|
|
13
|
+
replayed: boolean;
|
|
14
|
+
/** The API call the invocation serves: the endpoint its path named, and the invocation that invoked it, when one did. Null for anything else. */
|
|
15
|
+
call: {
|
|
16
|
+
api: string;
|
|
17
|
+
parentRequestId: string | null;
|
|
18
|
+
} | null;
|
|
19
|
+
/** What the answer is, once the call has one. */
|
|
20
|
+
outcome: LambderCallOutcomeHint | null;
|
|
21
|
+
};
|
|
22
|
+
/** Runs one invocation under a record of its own, which `currentInvocation()` finds from anything it runs. */
|
|
23
|
+
export declare const runInvocation: <T>(lambdaContext: Context | null | undefined, run: (record: LambderInvocationRecord) => Promise<T>) => Promise<T>;
|
|
24
|
+
/** The invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
|
|
25
|
+
export declare const currentInvocation: () => LambderInvocationRecord | null;
|