lambder 11.1.1 → 13.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +25 -15
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  5. package/dist/api/LambderApiDeclarations.js +52 -0
  6. package/dist/api/LambderApiDefinition.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +4 -1
  8. package/dist/api/LambderApiGuards.d.ts +21 -22
  9. package/dist/api/LambderApiGuards.js +1 -1
  10. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  11. package/dist/api/LambderApiRateLimits.js +1 -1
  12. package/dist/api/LambderApiRefusals.d.ts +22 -0
  13. package/dist/api/LambderApiRefusals.js +13 -0
  14. package/dist/api/LambderApiRequest.d.ts +15 -6
  15. package/dist/api/LambderApiRequest.js +8 -9
  16. package/dist/build/generateApiFiles.d.ts +60 -0
  17. package/dist/build/generateApiFiles.js +85 -0
  18. package/dist/build/generatedTables.d.ts +2 -1
  19. package/dist/build/generatedTables.js +1 -1
  20. package/dist/build.d.ts +4 -1
  21. package/dist/build.js +3 -1
  22. package/dist/client/LambderCaller.d.ts +36 -23
  23. package/dist/client/LambderCaller.js +88 -67
  24. package/dist/client/lambderFetchTransport.js +2 -1
  25. package/dist/client.d.ts +3 -1
  26. package/dist/client.js +2 -0
  27. package/dist/core/Lambder.d.ts +232 -92
  28. package/dist/core/Lambder.js +400 -103
  29. package/dist/core/LambderCallSummary.d.ts +43 -0
  30. package/dist/core/LambderCallSummary.js +10 -0
  31. package/dist/core/LambderContext.d.ts +15 -2
  32. package/dist/core/LambderContext.js +32 -9
  33. package/dist/core/LambderCreateOptions.d.ts +78 -64
  34. package/dist/core/LambderCreateOptions.js +39 -21
  35. package/dist/core/LambderInvocationScope.d.ts +25 -0
  36. package/dist/core/LambderInvocationScope.js +21 -0
  37. package/dist/core/LambderResponse.d.ts +11 -0
  38. package/dist/core/LambderResponse.js +12 -0
  39. package/dist/core/LambderResponseBuilder.js +5 -1
  40. package/dist/index.d.ts +13 -7
  41. package/dist/index.js +3 -1
  42. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  43. package/dist/invoke/LambderInvokeCaller.js +110 -94
  44. package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
  45. package/dist/invoke/LambderLambdaEvent.js +8 -3
  46. package/dist/invoke/lambderHandlerTransport.js +2 -1
  47. package/dist/mock/LambderMockApp.d.ts +39 -33
  48. package/dist/mock/LambderMockApp.js +66 -55
  49. package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
  50. package/dist/mock/LambderMockTypes.d.ts +44 -7
  51. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  52. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  53. package/dist/mock/lambderMockMswHandler.js +8 -4
  54. package/dist/mock.d.ts +2 -2
  55. package/dist/session/LambderSessionManager.d.ts +46 -3
  56. package/dist/session/LambderSessionManager.js +62 -11
  57. package/dist/shared/LambderI18n.js +49 -4
  58. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  59. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  60. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  61. package/dist/shared/transport/LambderApiTransport.js +2 -3
  62. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  63. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  64. package/dist/shared/util/LambderInvocationScope.js +5 -0
  65. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  66. package/dist/shared/util/LambderNamedMaps.js +19 -0
  67. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  68. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  69. package/dist/shared/wire/LambderApiContract.js +1 -1
  70. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  71. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  72. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  73. package/dist/shared/wire/LambderApiNames.js +81 -0
  74. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  75. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  76. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  77. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  78. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  79. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  80. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  81. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  82. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  83. package/dist/testing/LambderTestApp.d.ts +18 -5
  84. package/dist/testing/LambderTestApp.js +21 -5
  85. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  86. package/dist/testing/LambderTestVisitor.js +33 -25
  87. package/package.json +1 -1
@@ -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;
@@ -25,7 +25,7 @@ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxy
25
25
  * clearCookie) that write onto whatever answer the request ends with.
26
26
  *
27
27
  * TRateLimitPolicies is the app's policies map on a handler registered with
28
- * addApi, addSessionApi, addRoute or addSessionRoute, so a policy name is
28
+ * defineApi, addRoute or addSessionRoute, so a policy name is
29
29
  * checked where it is charged; anywhere else (a hook, a guard) the names are
30
30
  * any string.
31
31
  */
@@ -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,5 +1,7 @@
1
1
  import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
2
+ import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
2
3
  import { resolveClientIp } from "../shared/util/LambderClientIp.js";
4
+ import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
3
5
  import { base64ToText } from "../shared/util/LambderBase64.js";
4
6
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
5
7
  import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
@@ -68,7 +70,7 @@ const withoutStagePrefix = (path, stage) => stage && stage !== "$default" && (pa
68
70
  ? path.slice(stage.length + 1) || "/"
69
71
  : path;
70
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. */
71
- 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 } = {}) => {
72
74
  // Normalize the two API Gateway payload formats into one shape.
73
75
  const eventFormat = isV2HttpEvent(event) ? "v2" : "v1";
74
76
  let host;
@@ -81,7 +83,7 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
81
83
  let get;
82
84
  let cookiePairs;
83
85
  let sourceIp;
84
- const headers = event.headers ?? {};
86
+ let headers = event.headers ?? {};
85
87
  if (isV2HttpEvent(event)) {
86
88
  host = headers.host || event.requestContext.domainName || "";
87
89
  const apiId = event.requestContext.apiId;
@@ -126,6 +128,19 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
126
128
  for (const [name, values] of Object.entries(cookieList))
127
129
  cookie[name] = values[0];
128
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
+ }
129
144
  const header = (name) => lowercasedHeaders[name.toLowerCase()];
130
145
  // A trusted forwarding header is trusted because a proxy in front of this
131
146
  // function writes it. A direct invoke has no such proxy: its headers are
@@ -134,19 +149,20 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
134
149
  // address and the host are the ones the invoker named (clientIp and host,
135
150
  // delivered as sourceIp and Host), and no header is read for either.
136
151
  const invokedDirectly = event.requestContext?.apiId === LAMBDER_INVOKE_API_ID;
152
+ const readsForwarding = proven && !invokedDirectly;
137
153
  // The first trusted header carrying a well-formed host, leftmost entry,
138
154
  // else the host the gateway saw. Behind CloudFront a Function URL sees
139
155
  // its own lambda-url domain, since CloudFront sends an origin its own
140
156
  // Host, so cookie domains and host routing need the viewer's host from a
141
157
  // header the distribution writes.
142
- for (const name of invokedDirectly ? [] : trustedHostHeaders) {
158
+ for (const name of readsForwarding ? trustedHostHeaders : []) {
143
159
  const forwarded = (lowercasedHeaders[name.toLowerCase()] ?? "").split(",")[0].trim();
144
160
  if (HOST_VALUE_PATTERN.test(forwarded)) {
145
161
  host = forwarded;
146
162
  break;
147
163
  }
148
164
  }
149
- const ip = resolveClientIp(lowercasedHeaders, sourceIp, invokedDirectly ? [] : trustedClientIpHeaders);
165
+ const ip = resolveClientIp(lowercasedHeaders, sourceIp, readsForwarding ? trustedClientIpHeaders : []);
150
166
  // Decode body: keep the raw string, then parse as JSON with urlencoded fallback.
151
167
  const rawBody = event.isBase64Encoded
152
168
  ? (event.body ? base64ToText(event.body) : "")
@@ -162,13 +178,20 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
162
178
  post[key] = value;
163
179
  }
164
180
  }
165
- // A JSON POST to the API path whose body names an API is an API call;
181
+ // A JSON POST to `{apiPath}/{group}/{action}` is a call to that endpoint;
166
182
  // the core reads the envelope, and everything downstream reads ctx.api.
167
183
  // JSON only (isApiCallContentType): any site can submit a plain HTML form
168
- // to this path, and enctype="text/plain" lays out a JSON body exactly.
169
- const api = method === "POST" && !!apiPath && path === apiPath && isApiCallContentType(lowercasedHeaders)
170
- ? readApiEnvelope(post, { headers: lowercasedHeaders, cookies: cookieList, ip, host })
171
- : null;
184
+ // to this path, and enctype="text/plain" lays out a JSON body exactly. A
185
+ // JSON POST to apiPath itself whose body names an endpoint comes from a
186
+ // page built before endpoints had paths, and is read as a call on the
187
+ // retired path, which the core answers with versionExpired.
188
+ const isJsonPost = method === "POST" && !!apiPath && isApiCallContentType(lowercasedHeaders);
189
+ const calledName = isJsonPost ? apiNameOfCallPath(apiPath, path) : null;
190
+ const requestInfo = { headers: lowercasedHeaders, cookies: cookieList, ip, host };
191
+ const api = calledName !== null ? readApiEnvelope(post, requestInfo, calledName)
192
+ : isJsonPost && path === apiPath && typeof post.apiName === "string" && post.apiName !== ""
193
+ ? readApiEnvelope(post, requestInfo, post.apiName, { retiredPath: true })
194
+ : null;
172
195
  return bindContextTools({
173
196
  host, path, rawPath, pathParams: {}, method,
174
197
  get, post, cookie, cookieList, event,
@@ -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
- * One map of named declarations, or a list of them. An app made of parts,
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,17 +155,15 @@ export type LambderSessionOptions<TSessionData = any> = {
163
155
  * end the session. See LambderSessionDataRefreshConfig for the exact
164
156
  * semantics.
165
157
  */
166
- dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
167
- /** Hashing and randomness for the session tokens. Default: WebCrypto. */
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. */
173
163
  export type LambderRateLimitPoliciesMap = Record<string, LambderApiRateLimitPolicyConfig<LambderRenderContext>>;
174
164
  /** The `rateLimits` option of create(): the engine's config, with `policies` as one map or a list of maps. */
175
165
  export type LambderCreateRateLimitsOption = Omit<LambderApiRateLimitsConfig<LambderRateLimitPoliciesMap>, "policies"> & {
176
- /** Named policies referenced (typed) from addApi/addSessionApi: one map, or a list of maps merged in order. */
166
+ /** Named policies referenced (typed) from defineApi: one map, or a list of maps merged in order. */
177
167
  policies: LambderNamedMapsOption<LambderRateLimitPoliciesMap>;
178
168
  };
179
169
  /** The options as the instance holds them: the guards and the rate-limit policies merged into one map each. */
@@ -274,9 +264,41 @@ 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
- /** Sessions over a store of your choosing; required for addSessionApi/addSessionRoute. */
301
+ /** Sessions over a store of your choosing; required for a guard that needs a session, and for addSessionRoute. */
280
302
  session?: LambderSessionOptions<TSessionData>;
281
303
  /**
282
304
  * Declarative per-API rate limiting: your limiter plus named policies APIs
@@ -318,58 +340,50 @@ export type LambderCreateOptions<TSessionData = any> = {
318
340
  */
319
341
  requireRefusalCodes?: boolean;
320
342
  /**
321
- * Make an authorization declaration part of registering a session API:
322
- * every addSessionApi must declare `guards`, at the type level (a missing
323
- * `guards` is a compile error) and at registration (a plain-JS caller
324
- * throws). An API whose session is the whole authorization (the
325
- * signed-in user's own account) declares a named no-op session guard, so
326
- * every opt-out is explicit and one grep lists them all. Needs a guards
327
- * map to pick from. Default: false.
328
- */
329
- requireSessionApiGuards?: boolean;
330
- /**
331
- * The same for public APIs: every addApi must declare `guards`, at the
332
- * type level and at registration.
343
+ * Make an authorization declaration part of declaring an endpoint: every
344
+ * defineApi must declare `guards`, at the type level (a missing `guards`
345
+ * is a compile error) and at registration (a plain-JS caller throws).
333
346
  *
334
- * Public APIs are open by default, which is the right default, so this is
335
- * off unless an app decides otherwise. Turned on, a public endpoint's
336
- * openness becomes a written decision rather than an omission: the ones
337
- * anybody may call declare a named no-op guard carrying the reason, and
338
- * the ones that authorize their caller some other way (a signature, a
339
- * device secret, a one-shot token) name where that happens. One grep over
340
- * the guard names then lists every public door and why it is open. Needs
341
- * a guards map to pick from. Default: false.
347
+ * An endpoint is open by default, which is the right default, so this is
348
+ * off unless an app decides otherwise. Turned on, every endpoint's
349
+ * authorization becomes a written decision rather than an omission: one
350
+ * anybody may call declares a named no-op guard carrying the reason, one
351
+ * whose session is the whole authorization (the signed-in user's own
352
+ * account) a named no-op session guard, and one that authorizes its
353
+ * caller some other way (a signature, a device secret, a one-shot token)
354
+ * names where that happens. One grep over the guard names then lists
355
+ * every door and why it opens. Needs a guards map to pick from. Default:
356
+ * false.
342
357
  */
343
- requirePublicApiGuards?: boolean;
358
+ requireApiGuards?: boolean;
344
359
  /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
345
360
  idempotency?: LambderApiIdempotencyConfig;
346
361
  /** Crash reporting on every path, and who may read a crash in the answer. See LambderCrashOptions. */
347
362
  crashes?: LambderCrashOptions;
348
363
  };
349
364
  /**
350
- * What the `guards` field asks for when an API on a require*ApiGuards
365
+ * What the `guards` field asks for when an API on a requireApiGuards
351
366
  * instance declares none. With nothing to infer from, the inference parameter
352
367
  * defaults to `never`, and "Property 'guards' is missing ... but required in
353
368
  * type { guards: never }" would read as though nothing could be written
354
369
  * there; the property name says what is wanted.
355
370
  */
356
371
  type LambderGuardsDeclarationRequired = {
357
- readonly "lambder: this instance requires every API of this kind to declare guards. Name the guard that authorizes this API, or the named no-op guard that records why anyone may call it.": never;
372
+ readonly "lambder: this instance requires every API to declare guards. Name the guard that authorizes this API, or the named no-op guard that records why it needs nothing more.": never;
358
373
  };
359
374
  /**
360
- * What addSessionApi and addSessionRoute need of the instance they are called
361
- * on. Nothing satisfies it without the session option, so registering a
362
- * session API on an instance that has no sessions is a compile error rather
363
- * than only the registration-time throw.
375
+ * What addSessionRoute needs of the instance it is called on. Nothing
376
+ * satisfies it without the session option, so registering a session route
377
+ * on an instance that has no sessions is a compile error rather than only a
378
+ * throw when it is first requested.
364
379
  */
365
380
  type LambderSessionOptionRequired = {
366
- readonly "lambder: sessions are not configured on this instance. Pass the session option to create() before registering a session API or a session route.": never;
381
+ readonly "lambder: sessions are not configured on this instance. Pass the session option to create() before registering a session route.": never;
367
382
  };
368
383
  /**
369
- * Intersected into what addSessionApi and addSessionRoute take, so an
370
- * instance created without the session option refuses the registration at
371
- * the call site. `unknown` once sessions are configured, which intersects
372
- * away to nothing.
384
+ * Intersected into what addSessionRoute takes, so an instance created
385
+ * without the session option refuses the registration at the call site.
386
+ * `unknown` once sessions are configured, which intersects away to nothing.
373
387
  */
374
388
  export type LambderSessionEnabledInstance<TSessionsEnabled extends boolean> = TSessionsEnabled extends true ? unknown : LambderSessionOptionRequired;
375
389
  /**
@@ -412,11 +426,8 @@ type LambderRateLimitSliceCheck<TLacking> = [TLacking] extends [never] ? unknown
412
426
  };
413
427
  /**
414
428
  * The `guards` field of an API's options: optional by default, required once
415
- * create() received the require*ApiGuards flag for that kind of API, so that
416
- * an authorization declaration cannot be forgotten at the type level.
417
- *
418
- * One type for both kinds: the requirement is the same shape either way, and
419
- * only which flag switches it on differs.
429
+ * create() received requireApiGuards, so that an authorization declaration
430
+ * cannot be forgotten at the type level.
420
431
  */
421
432
  export type LambderRequirableGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
422
433
  /** Named guards, run in declared order before input validation (after it for a guard declared runAt: "afterInputValidation"): a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Required on this instance: an API that needs no authorization declares a named no-op guard, so every opt-out is explicit and one grep lists them all. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
@@ -485,6 +496,7 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
485
496
  };
486
497
  idempotency?: LambderNoExtraKeys<NonNullable<TOptions["idempotency"]>, LambderOptionShape<TSessionData, "idempotency">>;
487
498
  crashes?: LambderNoExtraKeys<NonNullable<TOptions["crashes"]>, LambderOptionShape<TSessionData, "crashes">>;
499
+ originProof?: LambderNoExtraKeys<NonNullable<TOptions["originProof"]>, LambderOptionShape<TSessionData, "originProof">>;
488
500
  rateLimits?: LambderNoExtraKeys<NonNullable<TOptions["rateLimits"]>, LambderOptionShape<TSessionData, "rateLimits">> & {
489
501
  policies?: LambderRateLimitPolicyChecks<NonNullable<TOptions["rateLimits"]>["policies"]>;
490
502
  };
@@ -504,3 +516,5 @@ export type LambderNestedOptionChecks<TSessionData, TOptions extends LambderCrea
504
516
  * response (maxResponseBytes: 0) that an app discovers in production.
505
517
  */
506
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
- * A named-maps option as one map: a lone map as it is, a list merged in
5
- * order. A name declared in two maps of the list is refused.
6
- */
7
- export const mergeNamedMaps = (option, what) => {
8
- if (!Array.isArray(option))
9
- return option;
10
- const merged = {};
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
@@ -53,8 +43,36 @@ export const assertCreateOptions = (options) => {
53
43
  if (options.requireRefusalCodes && !vocabulary) {
54
44
  throw new Error("Lambder: requireRefusalCodes needs a refusals vocabulary to name codes from; declare one with initLambder().declareRefusals().");
55
45
  }
56
- if ((options.requireSessionApiGuards || options.requirePublicApiGuards) && !options.guards) {
57
- const requireFlag = options.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
58
- throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
46
+ if (options.requireApiGuards && !options.guards) {
47
+ throw new Error("Lambder: requireApiGuards needs a guards map at creation for APIs to declare from.");
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.`);
59
77
  }
60
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;
@@ -0,0 +1,21 @@
1
+ import { invocationScope } from "../shared/util/LambderInvocationScope.js";
2
+ /** Whether this process has started an invocation yet: the first one is its cold start. */
3
+ let processStarted = false;
4
+ /** Runs one invocation under a record of its own, which `currentInvocation()` finds from anything it runs. */
5
+ export const runInvocation = (lambdaContext, run) => {
6
+ const record = {
7
+ requestId: typeof lambdaContext?.awsRequestId === "string" && lambdaContext.awsRequestId ? lambdaContext.awsRequestId : null,
8
+ startedAt: performance.now(),
9
+ coldStart: !processStarted,
10
+ handlerMs: null,
11
+ replayed: false,
12
+ call: null,
13
+ outcome: null,
14
+ };
15
+ processStarted = true;
16
+ return invocationScope.run(record, () => run(record));
17
+ };
18
+ /** The invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
19
+ export const currentInvocation = () =>
20
+ // Only runInvocation opens the scope, and always with a whole record.
21
+ invocationScope.getStore() ?? null;
@@ -3,6 +3,7 @@ import type { LambderCompressionOption, LambderCompressionSettingsBase, LambderE
3
3
  import type { LambderRenderContext, LambderHttpEventFormat } from "./LambderContext.js";
4
4
  import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
5
5
  import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
6
+ import type { LambderCallOutcomeHint } from "../shared/wire/LambderCallOutcome.js";
6
7
  export type LambderHeadersInput = Record<string, string | string[]>;
7
8
  /**
8
9
  * Final Lambda response: v1 (REST API) uses multiValueHeaders, v2 (HTTP API /
@@ -31,6 +32,8 @@ type LambderResponseInit = {
31
32
  compress?: boolean | "auto";
32
33
  /** "auto": ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
33
34
  etag?: boolean | "auto";
35
+ /** What the response answers an API call with, when it was written as an API answer; see LambderResponse.callOutcome. */
36
+ callOutcome?: LambderCallOutcomeHint | null;
34
37
  };
35
38
  /**
36
39
  * Intermediate response object returned by all response builder methods and by
@@ -49,6 +52,14 @@ export declare class LambderResponse {
49
52
  isBodyBase64: boolean;
50
53
  compress: boolean | "auto";
51
54
  etag: boolean | "auto";
55
+ /**
56
+ * What this response answers an API call with (a success, a refusal and
57
+ * its code, a flag), when it was written as an API answer: the envelope
58
+ * builders and res.apiRefusal() set it, and the call's summary line reads
59
+ * it, so the line does not parse the body back. Null on any other
60
+ * response, which the line names `other`.
61
+ */
62
+ callOutcome: LambderCallOutcomeHint | null;
52
63
  constructor(init: LambderResponseInit);
53
64
  /**
54
65
  * A copy with its own header lists, for a request to write into. A
@@ -21,6 +21,14 @@ export class LambderResponse {
21
21
  isBodyBase64;
22
22
  compress;
23
23
  etag;
24
+ /**
25
+ * What this response answers an API call with (a success, a refusal and
26
+ * its code, a flag), when it was written as an API answer: the envelope
27
+ * builders and res.apiRefusal() set it, and the call's summary line reads
28
+ * it, so the line does not parse the body back. Null on any other
29
+ * response, which the line names `other`.
30
+ */
31
+ callOutcome;
24
32
  constructor(init) {
25
33
  this.statusCode = init.statusCode;
26
34
  this.headers = normalizeHeaders(init.headers);
@@ -28,6 +36,7 @@ export class LambderResponse {
28
36
  this.isBodyBase64 = init.isBodyBase64 ?? false;
29
37
  this.compress = init.compress ?? "auto";
30
38
  this.etag = init.etag ?? "auto";
39
+ this.callOutcome = init.callOutcome ?? null;
31
40
  }
32
41
  /**
33
42
  * A copy with its own header lists, for a request to write into. A
@@ -44,6 +53,7 @@ export class LambderResponse {
44
53
  isBodyBase64: this.isBodyBase64,
45
54
  compress: this.compress,
46
55
  etag: this.etag,
56
+ callOutcome: this.callOutcome,
47
57
  });
48
58
  }
49
59
  // The header methods delegate to the core's header helpers, so an answer
@@ -77,6 +87,7 @@ export const answerFromResponse = (response) => {
77
87
  isBodyBase64: response.isBodyBase64 || binary,
78
88
  compress: response.compress,
79
89
  etag: response.etag,
90
+ ...(response.callOutcome ? { outcome: response.callOutcome } : {}),
80
91
  };
81
92
  };
82
93
  /**
@@ -98,6 +109,7 @@ export const responseFromAnswer = (answer) => new LambderResponse({
98
109
  isBodyBase64: answer.isBodyBase64 ?? false,
99
110
  compress: answer.compress ?? "auto",
100
111
  etag: answer.etag ?? "auto",
112
+ callOutcome: answer.outcome ?? null,
101
113
  });
102
114
  const mimeOf = (contentType) => (contentType?.split(";")[0] ?? "").trim().toLowerCase();
103
115
  /** A content type whose body is text: sent as text when it is valid UTF-8 and not compressed. */