lambder 12.0.1 → 14.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -0
- package/README.md +7 -2
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +32 -8
- package/dist/core/Lambder.js +135 -17
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +14 -1
- package/dist/core/LambderContext.js +19 -4
- package/dist/core/LambderCreateOptions.d.ts +50 -25
- package/dist/core/LambderCreateOptions.js +37 -18
- package/dist/core/LambderInvocationScope.d.ts +25 -0
- package/dist/core/LambderInvocationScope.js +21 -0
- package/dist/core/LambderResponse.d.ts +11 -0
- package/dist/core/LambderResponse.js +12 -0
- package/dist/core/LambderResponseBuilder.js +5 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +2 -2
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
- package/dist/invoke/LambderLambdaEvent.js +8 -2
- package/dist/mock/LambderMockApp.d.ts +8 -8
- package/dist/mock/LambderMockApp.js +14 -10
- package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
- package/dist/session/LambderSessionManager.d.ts +46 -3
- package/dist/session/LambderSessionManager.js +62 -11
- package/dist/shared/LambderI18n.js +49 -4
- package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
- package/dist/shared/contracts/LambderRateLimiter.js +2 -2
- package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
- package/dist/shared/util/LambderInvocationScope.js +5 -0
- package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
- package/dist/shared/util/LambderNamedMaps.js +19 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
- package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
- package/dist/shared/wire/LambderCallOutcome.js +47 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
- package/dist/shared/wire/LambderInvokeApiId.js +10 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
- package/dist/stores/LambderDdbRateLimiter.js +64 -45
- package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
- package/dist/stores/LambderMemoryRateLimiter.js +20 -15
- package/dist/testing/LambderTestApp.d.ts +13 -4
- package/dist/testing/LambderTestApp.js +21 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
353
|
-
* and the same `requireCodes`, the server's init declares,
|
|
354
|
-
* shared code (codes, zod schemas, statuses and flags hold
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
961
|
-
* and the same `requireCodes`, the server's init declares,
|
|
962
|
-
* shared code (codes, zod schemas, statuses and flags hold
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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)
|
|
309
|
-
//
|
|
310
|
-
|
|
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
|
/**
|