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.
Files changed (57) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +7 -2
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiEnvelope.js +4 -1
  5. package/dist/api/LambderApiRefusals.d.ts +22 -0
  6. package/dist/api/LambderApiRefusals.js +13 -0
  7. package/dist/build/generateApiFiles.d.ts +60 -0
  8. package/dist/build/generateApiFiles.js +85 -0
  9. package/dist/build/generatedTables.d.ts +1 -1
  10. package/dist/build/writeApiGuardParams.d.ts +11 -10
  11. package/dist/build/writeApiGuardParams.js +135 -31
  12. package/dist/build.d.ts +4 -1
  13. package/dist/build.js +3 -1
  14. package/dist/client.d.ts +2 -2
  15. package/dist/client.js +1 -1
  16. package/dist/core/Lambder.d.ts +32 -8
  17. package/dist/core/Lambder.js +135 -17
  18. package/dist/core/LambderCallSummary.d.ts +43 -0
  19. package/dist/core/LambderCallSummary.js +10 -0
  20. package/dist/core/LambderContext.d.ts +14 -1
  21. package/dist/core/LambderContext.js +19 -4
  22. package/dist/core/LambderCreateOptions.d.ts +50 -25
  23. package/dist/core/LambderCreateOptions.js +37 -18
  24. package/dist/core/LambderInvocationScope.d.ts +25 -0
  25. package/dist/core/LambderInvocationScope.js +21 -0
  26. package/dist/core/LambderResponse.d.ts +11 -0
  27. package/dist/core/LambderResponse.js +12 -0
  28. package/dist/core/LambderResponseBuilder.js +5 -1
  29. package/dist/index.d.ts +8 -6
  30. package/dist/index.js +2 -2
  31. package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
  32. package/dist/invoke/LambderLambdaEvent.js +8 -2
  33. package/dist/mock/LambderMockApp.d.ts +8 -8
  34. package/dist/mock/LambderMockApp.js +14 -10
  35. package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
  36. package/dist/session/LambderSessionManager.d.ts +46 -3
  37. package/dist/session/LambderSessionManager.js +62 -11
  38. package/dist/shared/LambderI18n.js +49 -4
  39. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  40. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  41. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  42. package/dist/shared/util/LambderInvocationScope.js +5 -0
  43. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  44. package/dist/shared/util/LambderNamedMaps.js +19 -0
  45. package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
  46. package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
  47. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  48. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  49. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  50. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  51. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  52. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  53. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  54. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  55. package/dist/testing/LambderTestApp.d.ts +13 -4
  56. package/dist/testing/LambderTestApp.js +21 -5
  57. package/package.json +1 -1
@@ -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. */
@@ -1,3 +1,4 @@
1
+ import { outcomeOfEnvelope } from "../shared/wire/LambderCallOutcome.js";
1
2
  import { LambderResponse } from "./LambderResponse.js";
2
3
  import { plainRefusalEnvelope } from "../api/LambderApiEnvelope.js";
3
4
  /**
@@ -145,7 +146,10 @@ export default class LambderResponseBuilder {
145
146
  * request accumulated unless the config names its own.
146
147
  */
147
148
  apiRefusal(config, options) {
148
- return this.json(plainRefusalEnvelope(this.apiVersion, config, "res.apiRefusal()", this.ctx?.logList), options);
149
+ const envelope = plainRefusalEnvelope(this.apiVersion, config, "res.apiRefusal()", this.ctx?.logList);
150
+ const response = this.json(envelope, options);
151
+ response.callOutcome = outcomeOfEnvelope(envelope, response.statusCode);
152
+ return response;
149
153
  }
150
154
  ;
151
155
  }
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export { constantTimeEquals } from "./shared/util/LambderTextDigest.js";
24
24
  export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
25
25
  export { lambderHandlerTransport } from "./invoke/lambderHandlerTransport.js";
26
26
  export type { LambderHandlerTransportOptions } from "./invoke/lambderHandlerTransport.js";
27
- export { synthesizeLambdaHttpEvent, decodeLambdaHttpResult, localLambdaContext, LAMBDER_INVOKE_HEADER, LAMBDER_INVOKED_BY_HEADER, LAMBDER_INVOKE_PROTOCOL, } from "./invoke/LambderLambdaEvent.js";
27
+ export { synthesizeLambdaHttpEvent, decodeLambdaHttpResult, localLambdaContext, LAMBDER_INVOKE_HEADER, LAMBDER_INVOKED_BY_HEADER, LAMBDER_PARENT_REQUEST_HEADER, LAMBDER_INVOKE_PROTOCOL, } from "./invoke/LambderLambdaEvent.js";
28
28
  export type { LambderSynthesizedRequest, LambderLambdaHttpResult, LambderInvokeSession } from "./invoke/LambderLambdaEvent.js";
29
29
  export { LambderApiPipeline } from "./api/LambderApiPipeline.js";
30
30
  export type { LambderApiPipelineOptions, LambderApiSessionsConfig, LambderApiInputRefusal, LambderApiRunResult, LambderApiExec } from "./api/LambderApiPipeline.js";
@@ -100,7 +100,7 @@ export { refuseUnacceptedUpload } from "./shared/wire/LambderUploadRefusal.js";
100
100
  export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
101
101
  export type { LambderUploadRunnerOptions, LambderUploadProgress, LambderUploadPhase, LambderUploadFailureReason } from "./client/LambderUploadRunner.js";
102
102
  export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
103
- export type { LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
103
+ export type { LambderCreatedSession, LambderSessionDataOptions, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
104
104
  export { resolveCompressionOption, LAMBDER_ENCODINGS } from "./shared/wire/LambderCompressionOption.js";
105
105
  export type { LambderCompressionOption, LambderCompressionSettings, LambderCompressionSettingsBase, LambderEncoding, } from "./shared/wire/LambderCompressionOption.js";
106
106
  export { compressText, restoreBytes, restoreText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "./shared/wire/LambderCompressionCodec.js";
@@ -134,14 +134,14 @@ export { lambderGuard, lambderRateLimitKey } from "./core/LambderPolicyBuilders.
134
134
  export { lambderGuardBuilder } from "./api/LambderApiGuards.js";
135
135
  export type { LambderGuardBuilder, LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderGuardRunAt, LambderAllowedGuardNames, LambderGuardNamesInputLacks, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf, LambderGuardRefusals, LambderGuardRefusalNamesOf, } from "./api/LambderApiGuards.js";
136
136
  export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
137
- export type { LambderRefusalDeclaration, LambderRefusalVocabulary, LambderApiAllowedRefusal, LambderApiAllowedRefusals, LambderEndpointRefusals, LambderRefusalsOption, LambderRefusalNamesIn, LambderHandlerRefusalsOf, LambderWireRefusalsOf, } from "./api/LambderApiRefusals.js";
137
+ export type { LambderRefusalDeclaration, LambderRefusalVocabulary, LambderRefusalVocabularyOption, LambderMergedRefusalVocabulary, LambderApiAllowedRefusal, LambderApiAllowedRefusals, LambderEndpointRefusals, LambderRefusalsOption, LambderRefusalNamesIn, LambderHandlerRefusalsOf, LambderWireRefusalsOf, } from "./api/LambderApiRefusals.js";
138
138
  export { lambderRateLimitKeyBuilder, rateLimitRefusal, DEFAULT_RATE_LIMIT_REFUSAL } from "./api/LambderApiRateLimits.js";
139
139
  export type { LambderRateLimitKeyBuilder } from "./api/LambderApiRateLimits.js";
140
140
  export type { LambderContextRateLimit, LambderContextRateLimitCheck, LambderRateLimitCheckResult, LambderChargeablePolicyNames, LambderChargeKeyArgs, LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderRateLimitChargeAt, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderPolicyNamesInputLacks, LambderRateLimitOption, } from "./api/LambderApiRateLimits.js";
141
141
  export type { LambderApiIdempotencyConfig } from "./api/LambderApiIdempotency.js";
142
142
  export type { LambderGuardsOptionValue, LambderRateLimitMessage, LambderRateLimitOverride, LambderRateLimitOptionValue, LambderRefusalsOptionValue, LambderApiIdempotencyOption, } from "./shared/wire/LambderApiOptionValues.js";
143
- export { apiGuardParam } from "./shared/wire/LambderApiOptionEntries.js";
144
- export type { LambderApiOptionEntries, LambderApiOptionEntry, LambderRateLimitPolicyEntry, LambderGuardDeclarationEntry, LambderApisWithGuard, LambderApisGuardedBy, LambderApisWithMode, LambderGuardParamOf, } from "./shared/wire/LambderApiOptionEntries.js";
143
+ export { apiGuardParam, apiGuardParamExportName } from "./shared/wire/LambderApiOptionEntries.js";
144
+ export type { LambderApiOptionEntries, LambderApiOptionEntry, LambderRateLimitPolicyEntry, LambderGuardDeclarationEntry, LambderApisWithGuard, LambderApisGuardedBy, LambderApisWithMode, LambderGuardParamOf, LambderApiGuardParam, } from "./shared/wire/LambderApiOptionEntries.js";
145
145
  export { createLambderI18n } from "./shared/LambderI18n.js";
146
146
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
147
147
  export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiSuccessEnvelope, LambderApiRefusalEnvelope, LambderRefusalEnvelopeFields, LambderApiRefusalConfig, LambderContractEntry, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractKeysWithGuard, LambderJsonOf, LambderContractRefusalsOf, LambderContractRefusalMessage, LambderContractRefusals, LambderContractAnyRefusalMessage, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
@@ -149,7 +149,9 @@ export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEven
149
149
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
150
150
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
151
151
  export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
152
- export type { LambderContextOptions } from "./core/LambderContext.js";
152
+ export type { LambderContextOptions, LambderOriginProof } from "./core/LambderContext.js";
153
+ export type { LambderCallSummary, LambderCallSummaryOption } from "./core/LambderCallSummary.js";
154
+ export type { LambderCallOutcome, LambderCallOutcomeHint } from "./shared/wire/LambderCallOutcome.js";
153
155
  export { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS, compressPayloadBrotli, } from "./shared/wire/LambderRequestPayload.js";
154
156
  export type { LambderCompressedGzipPayload, LambderCompressedBrotliPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/wire/LambderRequestPayload.js";
155
157
  export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./shared/wire/LambderCookie.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { LambderBackoffTimer } from "./shared/util/LambderBackoffTimer.js";
17
17
  export { LambderSignedClaims, keyedDigest, randomSecret } from "./shared/util/LambderSignedClaims.js";
18
18
  export { constantTimeEquals } from "./shared/util/LambderTextDigest.js";
19
19
  export { lambderHandlerTransport } from "./invoke/lambderHandlerTransport.js";
20
- export { synthesizeLambdaHttpEvent, decodeLambdaHttpResult, localLambdaContext, LAMBDER_INVOKE_HEADER, LAMBDER_INVOKED_BY_HEADER, LAMBDER_INVOKE_PROTOCOL, } from "./invoke/LambderLambdaEvent.js";
20
+ export { synthesizeLambdaHttpEvent, decodeLambdaHttpResult, localLambdaContext, LAMBDER_INVOKE_HEADER, LAMBDER_INVOKED_BY_HEADER, LAMBDER_PARENT_REQUEST_HEADER, LAMBDER_INVOKE_PROTOCOL, } from "./invoke/LambderLambdaEvent.js";
21
21
  // The API core: the request, answer, envelope and pipeline both the server and the mock runtime run
22
22
  export { LambderApiPipeline } from "./api/LambderApiPipeline.js";
23
23
  export { readApiEnvelope, restoreCompressedPayload } from "./api/LambderApiRequest.js";
@@ -97,7 +97,7 @@ export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
97
97
  export { lambderRateLimitKeyBuilder, rateLimitRefusal, DEFAULT_RATE_LIMIT_REFUSAL } from "./api/LambderApiRateLimits.js";
98
98
  // The declared options as plain data: what apiOptionEntries() reports and
99
99
  // writeApiOptions writes, and the readers over the generated tables.
100
- export { apiGuardParam } from "./shared/wire/LambderApiOptionEntries.js";
100
+ export { apiGuardParam, apiGuardParamExportName } from "./shared/wire/LambderApiOptionEntries.js";
101
101
  // Typed translations (standalone, isomorphic)
102
102
  export { createLambderI18n } from "./shared/LambderI18n.js";
103
103
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, Context } from "aws-lambda";
12
12
  import type { LambderHttpEventFormat } from "../core/LambderContext.js";
13
+ import { LAMBDER_PARENT_REQUEST_HEADER } from "../shared/wire/LambderInvokeApiId.js";
13
14
  import type { LambderCompressedBrotliPayload, LambderCompressedGzipPayload } from "../shared/wire/LambderRequestPayload.js";
14
15
  /**
15
16
  * Marks a synthesized request as an invoke, for guards and hooks that want to
@@ -20,6 +21,7 @@ import type { LambderCompressedBrotliPayload, LambderCompressedGzipPayload } fro
20
21
  export declare const LAMBDER_INVOKE_HEADER = "x-lambder-invoke";
21
22
  /** The invoking function's name, when the caller runs in Lambda; for the callee's logs. */
22
23
  export declare const LAMBDER_INVOKED_BY_HEADER = "x-lambder-invoked-by";
24
+ export { LAMBDER_PARENT_REQUEST_HEADER };
23
25
  /** The value of the marker header; a future incompatible event shape would bump it. */
24
26
  export declare const LAMBDER_INVOKE_PROTOCOL = "1";
25
27
  /** A session carried on a user's behalf: the two values a browser holds. */
@@ -10,8 +10,9 @@
10
10
  */
11
11
  import { restoreBytes } from "../shared/wire/LambderCompressionCodec.js";
12
12
  import { bytesToBase64 } from "../shared/util/LambderBase64.js";
13
- import { LAMBDER_INVOKE_API_ID, LAMBDER_LOCAL_API_ID } from "../shared/wire/LambderInvokeApiId.js";
13
+ import { LAMBDER_INVOKE_API_ID, LAMBDER_LOCAL_API_ID, LAMBDER_PARENT_REQUEST_HEADER } from "../shared/wire/LambderInvokeApiId.js";
14
14
  import { buildEnvelopeFields } from "../shared/transport/LambderApiTransport.js";
15
+ import { currentRequestId } from "../shared/util/LambderInvocationScope.js";
15
16
  /**
16
17
  * Marks a synthesized request as an invoke, for guards and hooks that want to
17
18
  * tell. Not an authorization: over HTTP it is a header any client can send.
@@ -21,6 +22,7 @@ import { buildEnvelopeFields } from "../shared/transport/LambderApiTransport.js"
21
22
  export const LAMBDER_INVOKE_HEADER = "x-lambder-invoke";
22
23
  /** The invoking function's name, when the caller runs in Lambda; for the callee's logs. */
23
24
  export const LAMBDER_INVOKED_BY_HEADER = "x-lambder-invoked-by";
25
+ export { LAMBDER_PARENT_REQUEST_HEADER };
24
26
  /** The value of the marker header; a future incompatible event shape would bump it. */
25
27
  export const LAMBDER_INVOKE_PROTOCOL = "1";
26
28
  /**
@@ -47,9 +49,10 @@ export function synthesizeLambdaHttpEvent(request, options) {
47
49
  // what this event is. Forwarding a browser's headers wholesale is an
48
50
  // ordinary gateway-lambda pattern, and without these deletes it would let
49
51
  // a browser-shaped request claim to be an invoke, or an invoke name an
50
- // invoking function that did not send it.
52
+ // invoking function or invocation that did not send it.
51
53
  delete headers[LAMBDER_INVOKE_HEADER];
52
54
  delete headers[LAMBDER_INVOKED_BY_HEADER];
55
+ delete headers[LAMBDER_PARENT_REQUEST_HEADER];
53
56
  if (options.invoke)
54
57
  delete headers[FORWARDED_FOR_HEADER];
55
58
  headers.host = request.host;
@@ -59,6 +62,9 @@ export function synthesizeLambdaHttpEvent(request, options) {
59
62
  const invokedBy = typeof process !== "undefined" ? process.env?.AWS_LAMBDA_FUNCTION_NAME : undefined;
60
63
  if (invokedBy)
61
64
  headers[LAMBDER_INVOKED_BY_HEADER] = invokedBy;
65
+ const parentRequestId = currentRequestId();
66
+ if (parentRequestId)
67
+ headers[LAMBDER_PARENT_REQUEST_HEADER] = parentRequestId;
62
68
  }
63
69
  const isBinary = Buffer.isBuffer(request.body);
64
70
  if (request.contentType)
@@ -3,7 +3,7 @@ import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.
3
3
  import { type LambderApiRequest } from "../api/LambderApiRequest.js";
4
4
  import { type LambderApiAnswer } from "../api/LambderApiAnswer.js";
5
5
  import { type LambderDeclaredRefuse, type LambderRefusalMessage } from "../shared/wire/LambderApiRefusal.js";
6
- import { type LambderDeclaredVocabulary, type LambderHandlerRefusalsOf, type LambderRefusalVocabulary, type LambderRefusalVocabularyChecks } from "../api/LambderApiRefusals.js";
6
+ import { type LambderDeclaredVocabulary, type LambderHandlerRefusalsOf, type LambderRefusalVocabulary, type LambderRefusalVocabularyOption, type LambderRefusalVocabularyOptionChecks, type LambderMergedRefusalVocabulary } from "../api/LambderApiRefusals.js";
7
7
  import { type LambderApiTransport, type LambderApiTransportRequest } from "../shared/transport/LambderApiTransport.js";
8
8
  import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
9
9
  import { type LambderApiGuard, type LambderGuardBuilder } from "../api/LambderApiGuards.js";
@@ -349,16 +349,16 @@ export declare class LambderMockApp<C extends LambderApiContractShape, S = any,
349
349
  */
350
350
  export declare const initLambderMock: <C extends LambderApiContractShape, S = any>() => {
351
351
  /**
352
- * Declares the server's refusal vocabulary on the mock: the same object,
353
- * and the same `requireCodes`, the server's init declares, imported from
354
- * shared code (codes, zod schemas, statuses and flags hold nothing
355
- * secret). Given, every refusal an entry answers with is checked and sent
352
+ * Declares the server's refusal vocabulary on the mock: the same map, or
353
+ * list of maps, and the same `requireCodes`, the server's init declares,
354
+ * imported from shared code (codes, zod schemas, statuses and flags hold
355
+ * nothing secret). Given, every refusal an entry answers with is checked and sent
356
356
  * as the server would send it: the code among the codes the tables give
357
357
  * the entry, its data parsed through the code's schema from the input
358
358
  * form, and the declaration's status and flag. An entry whose tables name
359
359
  * a code needs it.
360
360
  */
361
- declareRefusals<const TVocabulary extends LambderRefusalVocabulary, const TRequireCodes extends boolean = false>(refusals: TVocabulary & LambderRefusalVocabularyChecks<TVocabulary>, options?: {
361
+ declareRefusals<const TVocabulary extends LambderRefusalVocabularyOption, const TRequireCodes extends boolean = false>(refusals: TVocabulary & LambderRefusalVocabularyOptionChecks<TVocabulary>, options?: {
362
362
  requireCodes?: TRequireCodes;
363
363
  }): {
364
364
  /** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
@@ -376,7 +376,7 @@ export declare const initLambderMock: <C extends LambderApiContractShape, S = an
376
376
  * codes and `data` the schema's input form, parsed as the server parses
377
377
  * it. The free refuse() where the mock declared none.
378
378
  */
379
- refuse: [TVocabulary] extends [never] ? (content: string, options?: import("../shared/wire/LambderApiRefusal.js").LambderRefuseOptions) => never : LambderDeclaredRefuse<LambderHandlerRefusalsOf<TVocabulary, keyof TVocabulary & string>, TRequireCodes>;
379
+ refuse: [LambderMergedRefusalVocabulary<TVocabulary>] extends [never] ? (content: string, options?: import("../shared/wire/LambderApiRefusal.js").LambderRefuseOptions) => never : LambderDeclaredRefuse<LambderHandlerRefusalsOf<LambderMergedRefusalVocabulary<TVocabulary>, keyof LambderMergedRefusalVocabulary<TVocabulary> & string>, TRequireCodes>;
380
380
  /**
381
381
  * The mock app, with the guard map and the rate-limit policies inferred
382
382
  * from the options.
@@ -388,7 +388,7 @@ export declare const initLambderMock: <C extends LambderApiContractShape, S = an
388
388
  * dropped. `I` exists for the same reason `P` does, and
389
389
  * LambderMockSurplusKeys puts the error back on the key.
390
390
  */
391
- create<const G extends Record<string, LambderApiGuard<any, any, any, LambderMockCallContext<S>, LambderMockSessionCallContext<S>>> = {}, const P extends LambderMockRateLimitPolicies<S> = {}, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>, const D extends Record<string, LambderGuardDeclarationEntry> = {}, const A extends Record<string, LambderApiOptionEntry> | undefined = undefined>(options: LambderMockAppOptions<C, S, G, P, I, D, A>): LambderMockApp<C, S, G, [A] extends [undefined] ? false : true, TVocabulary, TRequireCodes>;
391
+ create<const G extends Record<string, LambderApiGuard<any, any, any, LambderMockCallContext<S>, LambderMockSessionCallContext<S>>> = {}, const P extends LambderMockRateLimitPolicies<S> = {}, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>, const D extends Record<string, LambderGuardDeclarationEntry> = {}, const A extends Record<string, LambderApiOptionEntry> | undefined = undefined>(options: LambderMockAppOptions<C, S, G, P, I, D, A>): LambderMockApp<C, S, G, [A] extends [undefined] ? false : true, LambderMergedRefusalVocabulary<TVocabulary>, TRequireCodes>;
392
392
  };
393
393
  /** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
394
394
  guard: LambderGuardBuilder<LambderMockCallContext<S>, LambderMockSessionCallContext<S>>;
@@ -7,7 +7,7 @@ import { envelopeAnswer, crashAnswer, plainRefusalEnvelope, refusalEnvelope, suc
7
7
  import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
8
8
  import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
9
9
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, refuse as refuseApiCall } from "../shared/wire/LambderApiRefusal.js";
10
- import { allowedRefusalOf, readRefusalVocabulary, toRefusalCodes, } from "../api/LambderApiRefusals.js";
10
+ import { allowedRefusalOf, declaredRefusalVocabulary, toRefusalCodes, } from "../api/LambderApiRefusals.js";
11
11
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
12
12
  import { buildTransportEnvelope } from "../shared/transport/LambderApiTransport.js";
13
13
  import { lambderCookieJarTransport } from "../shared/transport/lambderCookieJarTransport.js";
@@ -163,7 +163,9 @@ export class LambderMockApp {
163
163
  sessionSalt: sessionOptions.sessionSalt ?? "lambder-mock",
164
164
  enableSlidingExpiration: sessionOptions.enableSlidingExpiration,
165
165
  slidingWriteIntervalSeconds: sessionOptions.slidingWriteIntervalSeconds,
166
- dataRefresh: sessionOptions.dataRefresh,
166
+ // As given: the manager refuses dataRefresh without
167
+ // the schema, which an untyped caller can still pass.
168
+ ...{ dataRefresh: sessionOptions.dataRefresh, dataSchema: sessionOptions.dataSchema },
167
169
  // A plain-http page (device testing on a LAN) has no
168
170
  // crypto.subtle, and a memory-only store is nothing
169
171
  // anyone can leak, so hashing there protects nothing.
@@ -957,19 +959,21 @@ const lambderMockInitOf = (declared) => ({
957
959
  export const initLambderMock = () => ({
958
960
  ...lambderMockInitOf(null),
959
961
  /**
960
- * Declares the server's refusal vocabulary on the mock: the same object,
961
- * and the same `requireCodes`, the server's init declares, imported from
962
- * shared code (codes, zod schemas, statuses and flags hold nothing
963
- * secret). Given, every refusal an entry answers with is checked and sent
962
+ * Declares the server's refusal vocabulary on the mock: the same map, or
963
+ * list of maps, and the same `requireCodes`, the server's init declares,
964
+ * imported from shared code (codes, zod schemas, statuses and flags hold
965
+ * nothing secret). Given, every refusal an entry answers with is checked and sent
964
966
  * as the server would send it: the code among the codes the tables give
965
967
  * the entry, its data parsed through the code's schema from the input
966
968
  * form, and the declaration's status and flag. An entry whose tables name
967
969
  * a code needs it.
968
970
  */
969
971
  declareRefusals(refusals, options = {}) {
970
- const vocabulary = readRefusalVocabulary(refusals);
971
- if (!vocabulary)
972
- throw new Error("LambderMockApp: declareRefusals() takes the vocabulary, an object of codes.");
973
- return lambderMockInitOf({ refusals, vocabulary, requireCodes: (options.requireCodes ?? false) });
972
+ const declared = declaredRefusalVocabulary(refusals, "LambderMockApp");
973
+ return lambderMockInitOf({
974
+ refusals: declared.refusals,
975
+ vocabulary: declared.vocabulary,
976
+ requireCodes: (options.requireCodes ?? false),
977
+ });
974
978
  },
975
979
  });
@@ -13,7 +13,7 @@ import type { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
13
13
  import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
14
14
  import type { LambderRateLimiter, LambderRateLimitWindow } from "../shared/contracts/LambderRateLimiter.js";
15
15
  import type { LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
16
- import type { LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
16
+ import type { LambderSessionDataOptions } from "../session/LambderSessionManager.js";
17
17
  import type { LambderSessionCookieOptions } from "../session/LambderSessionController.js";
18
18
  import type { LambderSessionCrypto } from "../session/LambderSessionCrypto.js";
19
19
  import type { LambderMockCallContext, LambderMockGuards, LambderMockLatency, LambderMockRateLimitPolicies, LambderMockSessionCallContext, LambderMockSessionGuardsCheck, LambderMockSurplusKeys } from "./LambderMockTypes.js";
@@ -26,13 +26,12 @@ export type LambderMockSessionsOptions<S> = {
26
26
  ttlSeconds?: number;
27
27
  /** Hashing and randomness. Default: WebCrypto where the runtime offers it, the plain stand-in over a memory-only store otherwise. */
28
28
  crypto?: LambderSessionCrypto;
29
- dataRefresh?: LambderSessionDataRefreshConfig<S>;
30
29
  enableSlidingExpiration?: boolean;
31
30
  slidingWriteIntervalSeconds?: number;
32
31
  tokenCookieKey?: string;
33
32
  csrfCookieKey?: string;
34
33
  cookieOptions?: LambderSessionCookieOptions;
35
- };
34
+ } & LambderSessionDataOptions<S>;
36
35
  /**
37
36
  * Idempotency in the mock: the same engine the server runs, over a memory
38
37
  * store unless one is given, and carrying every knob the server's own option
@@ -1,3 +1,4 @@
1
+ import type { z } from "zod";
1
2
  import type { LambderSessionCrypto } from "./LambderSessionCrypto.js";
2
3
  import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
3
4
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
@@ -60,6 +61,39 @@ export declare class LambderSessionDataRefreshError extends Error {
60
61
  export declare class LambderSessionReadError extends Error {
61
62
  constructor(cause: unknown);
62
63
  }
64
+ /**
65
+ * The shape session.data has, and how it is kept fresh: `dataSchema` alone,
66
+ * or with `dataRefresh`, which then requires it.
67
+ *
68
+ * A record outlives the code that wrote it, so a read checks its data
69
+ * against the schema, and hands the request the schema's output (keys it
70
+ * does not declare stripped). Data that does not match (a record written
71
+ * before a deploy changed the data's shape) is refreshed on that read when
72
+ * dataRefresh is configured, whatever its deadline; without dataRefresh
73
+ * nothing can rebuild it, and the session ends. A refresh's own output is
74
+ * held to the schema too, and one that does not match fails the read as a
75
+ * LambderSessionDataRefreshError.
76
+ *
77
+ * Data is held to the schema where it is written as well: createSession and
78
+ * updateSessionData throw on data it refuses, rather than store a session
79
+ * whose next read would end it. What is stored is the data as given, and
80
+ * what a request is served is the schema's output, so every read of a
81
+ * record parses exactly what its write was checked with.
82
+ *
83
+ * Data a refresh derives from the app's state changes shape as the app
84
+ * does, which is why dataRefresh needs the schema: without it, a record
85
+ * written before the change is served in its old shape until its refresh
86
+ * comes due.
87
+ */
88
+ export type LambderSessionDataOptions<SessionData = any> = {
89
+ /** The schema of session.data, checked on every read. */
90
+ dataSchema?: z.ZodType<SessionData>;
91
+ dataRefresh?: undefined;
92
+ } | {
93
+ /** The schema of session.data, checked on every read and on every refresh's output. Required beside dataRefresh. */
94
+ dataSchema: z.ZodType<SessionData>;
95
+ dataRefresh: LambderSessionDataRefreshConfig<SessionData>;
96
+ };
63
97
  export type LambderSessionManagerOptions<SessionData = any> = {
64
98
  /** Where sessions rest: LambderDdbSessionStore, LambderMemorySessionStore, or your own. */
65
99
  store: LambderSessionStore<SessionData>;
@@ -68,10 +102,9 @@ export type LambderSessionManagerOptions<SessionData = any> = {
68
102
  enableSlidingExpiration?: boolean;
69
103
  /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
70
104
  slidingWriteIntervalSeconds?: number;
71
- dataRefresh?: LambderSessionDataRefreshConfig<SessionData>;
72
105
  /** Hashing and randomness. Default: WebCrypto. */
73
106
  crypto?: LambderSessionCrypto;
74
- };
107
+ } & LambderSessionDataOptions<SessionData>;
75
108
  /**
76
109
  * Whether a string has the shape a minted session token has:
77
110
  * `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
@@ -105,8 +138,9 @@ export default class LambderSessionManager<SessionData = any> {
105
138
  private readonly enableSlidingExpiration;
106
139
  private readonly slidingWriteIntervalSeconds;
107
140
  private readonly dataRefresh;
141
+ private readonly dataSchema;
108
142
  private readonly crypto;
109
- constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, crypto, }: LambderSessionManagerOptions<SessionData>);
143
+ constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }: LambderSessionManagerOptions<SessionData>);
110
144
  /** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
111
145
  private assertCryptoFitsStore;
112
146
  /**
@@ -184,6 +218,15 @@ export default class LambderSessionManager<SessionData = any> {
184
218
  * logout, a password change), which a renewal must not undo.
185
219
  */
186
220
  renewSession(session: LambderSessionRecord<SessionData>): Promise<LambderSessionRecord<SessionData> | null>;
221
+ /**
222
+ * Data about to be stored, held to the schema: what a read of it will
223
+ * serve, or the error `refused` builds from where it does not match.
224
+ * Data the schema refuses is the app breaking its own declaration, and
225
+ * storing it would only move the failure to the next read, far from the
226
+ * code that wrote it. The data is stored as given, not as the schema's
227
+ * output, so every later read parses exactly what this check accepted.
228
+ */
229
+ private servedDataOf;
187
230
  /**
188
231
  * Runs the dataRefresh callback immediately, regardless of
189
232
  * dataExpiresAt, and persists the result onto the same record, over the
@@ -28,6 +28,11 @@ export class LambderSessionReadError extends Error {
28
28
  this.name = "LambderSessionReadError";
29
29
  }
30
30
  }
31
+ /** A schema's issues as a message may show them: each path and what was expected there, never the value found. */
32
+ const describeSchemaIssues = (error) => error.issues.slice(0, 5).map((issue) => `${issue.path.length ? issue.path.join(".") : "(the data)"}: ${issue.message}`).join("; ")
33
+ + (error.issues.length > 5 ? `; and ${error.issues.length - 5} more` : "");
34
+ /** A refresh's output the schema refuses, as the read fails with it. */
35
+ const refreshRefused = (issues) => new LambderSessionDataRefreshError(new Error(`the refreshed data does not match session.dataSchema (${issues})`));
31
36
  /**
32
37
  * The longest either half of a session token may be. A minted token is two
33
38
  * 64-character hex halves; sixteen times that leaves a custom
@@ -95,8 +100,9 @@ export default class LambderSessionManager {
95
100
  enableSlidingExpiration;
96
101
  slidingWriteIntervalSeconds;
97
102
  dataRefresh;
103
+ dataSchema;
98
104
  crypto;
99
- constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, crypto, }) {
105
+ constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }) {
100
106
  this.store = store;
101
107
  this.sessionSalt = sessionSalt;
102
108
  this.enableSlidingExpiration = enableSlidingExpiration;
@@ -105,7 +111,16 @@ export default class LambderSessionManager {
105
111
  : assertPositiveInteger(slidingWriteIntervalSeconds, "session.slidingWriteIntervalSeconds");
106
112
  if (dataRefresh)
107
113
  assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
114
+ if (dataRefresh && !dataSchema) {
115
+ throw new Error("Lambder: session.dataRefresh needs session.dataSchema, the schema session.data has. Data a refresh derives " +
116
+ "changes shape as the app does, and without the schema a record written before the change is served in its " +
117
+ "old shape until its refresh comes due; with it, such a record refreshes on its next read.");
118
+ }
119
+ if (dataSchema !== undefined && typeof dataSchema?.safeParse !== "function") {
120
+ throw new Error("Lambder: session.dataSchema is not a zod schema.");
121
+ }
108
122
  this.dataRefresh = dataRefresh ?? null;
123
+ this.dataSchema = dataSchema ?? null;
109
124
  this.crypto = crypto ?? new LambderWebCrypto();
110
125
  this.assertCryptoFitsStore(store);
111
126
  if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
@@ -167,6 +182,9 @@ export default class LambderSessionManager {
167
182
  // logout.
168
183
  if (!sessionKey)
169
184
  throw new Error("Lambder: createSession sessionKey is empty. It names the subject the session belongs to and partitions the store, so an empty one writes a record no read accepts.");
185
+ // Refused for the same reason: data the schema refuses would end the
186
+ // session on its first read, a sign-out right after the sign-in.
187
+ const served = this.servedDataOf(data, (issues) => new Error(`Lambder: createSession was given data that does not match session.dataSchema (${issues}).`));
170
188
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
171
189
  // The sort-key SECRET goes to the client; only its hash becomes the
172
190
  // store's range key, so the store never contains a usable token.
@@ -186,7 +204,7 @@ export default class LambderSessionManager {
186
204
  dataVersion: 0,
187
205
  };
188
206
  await this.store.create(session);
189
- return { session, sessionToken, csrfToken };
207
+ return { session: { ...session, data: served }, sessionToken, csrfToken };
190
208
  }
191
209
  /**
192
210
  * Writes new session data onto the record, and nothing else: the expiry
@@ -217,14 +235,17 @@ export default class LambderSessionManager {
217
235
  async updateSessionData(session, newData) {
218
236
  if (!session)
219
237
  throw new Error("Invalid session");
238
+ // Data the schema refuses would end the session on its next read
239
+ // (see createSession), so the write that brings it fails instead.
240
+ const served = this.servedDataOf(newData, (issues) => new Error(`Lambder: updateSessionData was given data that does not match session.dataSchema (${issues}).`));
220
241
  const { sessionKeyHash, secretHash } = session;
221
242
  if (!this.dataRefresh) {
222
243
  const result = await this.store.update(sessionKeyHash, secretHash, { data: newData });
223
- return result === "missing" ? null : { ...session, data: newData };
244
+ return result === "missing" ? null : { ...session, data: served };
224
245
  }
225
246
  const result = await this.store.update(sessionKeyHash, secretHash, { data: newData }, { dataVersion: session.dataVersion });
226
247
  if (result === "updated")
227
- return { ...session, data: newData, dataVersion: session.dataVersion + 1 };
248
+ return { ...session, data: served, dataVersion: session.dataVersion + 1 };
228
249
  if (result === "missing")
229
250
  return null;
230
251
  // The data or its deadline was written in between: a revocation was
@@ -235,7 +256,7 @@ export default class LambderSessionManager {
235
256
  const now = Math.floor(Date.now() / 1000);
236
257
  if (await this.store.update(sessionKeyHash, secretHash, { data: newData, dataExpiresAt: now }) === "missing")
237
258
  return null;
238
- return { ...session, data: newData, dataExpiresAt: now };
259
+ return { ...session, data: served, dataExpiresAt: now };
239
260
  }
240
261
  /**
241
262
  * The record a token names, read and structurally checked, with nothing
@@ -304,10 +325,22 @@ export default class LambderSessionManager {
304
325
  const now = Math.floor(Date.now() / 1000);
305
326
  const refreshed = {};
306
327
  const slid = {};
328
+ // The record's data checked against the schema (see
329
+ // LambderSessionDataOptions). Without dataRefresh nothing can rebuild
330
+ // data that does not match, so the session ends.
331
+ const read = this.dataSchema ? this.dataSchema.safeParse(session.data) : null;
332
+ if (read && !read.success && !this.dataRefresh) {
333
+ await this.deleteSession(session);
334
+ return null;
335
+ }
336
+ // What the request is served: the data as the schema reads it, or,
337
+ // once a refresh ran, the refresh's.
338
+ let served = read?.success ? read.data : session.data;
307
339
  // Renew session.data once its shelf life has passed (opt-in
308
- // dataRefresh). A record created while dataRefresh was off has no
309
- // dataExpiresAt, so it renews on its first read.
310
- if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
340
+ // dataRefresh), or at once when it does not match the schema. A
341
+ // record created while dataRefresh was off has no dataExpiresAt, so
342
+ // it renews on its first read.
343
+ if (this.dataRefresh && (read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
311
344
  let newData;
312
345
  try {
313
346
  newData = await this.dataRefresh.refresh(session);
@@ -321,6 +354,7 @@ export default class LambderSessionManager {
321
354
  await this.deleteSession(session);
322
355
  return null;
323
356
  }
357
+ served = this.servedDataOf(newData, refreshRefused);
324
358
  refreshed.data = newData;
325
359
  refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
326
360
  }
@@ -335,7 +369,7 @@ export default class LambderSessionManager {
335
369
  slid.expiresAt = now + session.ttlInSeconds;
336
370
  }
337
371
  }
338
- const renewed = { ...session, ...refreshed, ...slid };
372
+ const renewed = { ...session, ...refreshed, ...slid, data: served };
339
373
  if (refreshed.data === undefined && slid.expiresAt === undefined)
340
374
  return renewed;
341
375
  // Awaited, so it persists before Lambda freezes. A failing write is
@@ -361,11 +395,27 @@ export default class LambderSessionManager {
361
395
  }
362
396
  catch (err) {
363
397
  console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
364
- return { ...session, ...refreshed };
398
+ return { ...session, ...refreshed, data: served };
365
399
  }
366
400
  return renewed;
367
401
  }
368
402
  ;
403
+ /**
404
+ * Data about to be stored, held to the schema: what a read of it will
405
+ * serve, or the error `refused` builds from where it does not match.
406
+ * Data the schema refuses is the app breaking its own declaration, and
407
+ * storing it would only move the failure to the next read, far from the
408
+ * code that wrote it. The data is stored as given, not as the schema's
409
+ * output, so every later read parses exactly what this check accepted.
410
+ */
411
+ servedDataOf(data, refused) {
412
+ if (!this.dataSchema)
413
+ return data;
414
+ const checked = this.dataSchema.safeParse(data);
415
+ if (checked.success)
416
+ return checked.data;
417
+ throw refused(describeSchemaIssues(checked.error));
418
+ }
369
419
  /**
370
420
  * Runs the dataRefresh callback immediately, regardless of
371
421
  * dataExpiresAt, and persists the result onto the same record, over the
@@ -390,13 +440,14 @@ export default class LambderSessionManager {
390
440
  await this.deleteSession(session);
391
441
  return null;
392
442
  }
443
+ const served = this.servedDataOf(newData, refreshRefused);
393
444
  const changes = { data: newData, dataExpiresAt: Math.floor(Date.now() / 1000) + this.dataRefresh.ttlSeconds };
394
445
  const result = await this.store.update(session.sessionKeyHash, session.secretHash, changes, { dataVersion: session.dataVersion });
395
446
  if (result === "missing")
396
447
  return null;
397
448
  // "stale": served, not written, and the record keeps the version it
398
449
  // was read with (see updateSessionData).
399
- return { ...session, ...changes, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
450
+ return { ...session, ...changes, data: served, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
400
451
  }
401
452
  ;
402
453
  /**