lambder 7.3.1 → 8.1.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 +1047 -3
- package/README.md +46 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +114 -0
- package/dist/build/writeApiSignatures.js +217 -0
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +11 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +9 -3
- package/dist/client.js +6 -1
- package/dist/core/Lambder.d.ts +143 -79
- package/dist/core/Lambder.js +350 -231
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +25 -8
- package/dist/index.js +13 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +43 -33
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +6 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +98 -53
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
- package/dist/shared/wire/LambderApiRefusal.js +42 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +80 -38
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +23 -25
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +3 -0
- package/dist/testing.js +2 -0
- package/package.json +26 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
|
+
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
|
+
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
5
|
+
import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
|
|
6
|
+
/** The form field that names the ticket a post was signed with: the memory bucket's stand-in for S3's signed policy. */
|
|
7
|
+
const TICKET_FIELD = "x-lambder-upload-ticket";
|
|
8
|
+
/** The query parameter that names the link a download was issued with: the stand-in for S3's presigned query string. */
|
|
9
|
+
const LINK_PARAMETER = "x-lambder-download-link";
|
|
10
|
+
/**
|
|
11
|
+
* An upload bucket in memory (see LambderUploadBucket), for tests and the
|
|
12
|
+
* mock runtime.
|
|
13
|
+
*
|
|
14
|
+
* It holds a post to the rules S3 holds a presigned POST to: every field the
|
|
15
|
+
* ticket carries, each with the ticket's value, and no other, all ahead of
|
|
16
|
+
* the file (as S3 does, anything after the file is ignored); a ticket it
|
|
17
|
+
* issued and not yet expired; a body of exactly the size described; and bytes
|
|
18
|
+
* whose SHA-256 is the one described. It refuses otherwise with the status
|
|
19
|
+
* and the XML error S3 answers with (AccessDenied for a policy, and "Policy
|
|
20
|
+
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
21
|
+
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
22
|
+
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
23
|
+
* A download link reads the object until it expires.
|
|
24
|
+
*
|
|
25
|
+
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
26
|
+
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
27
|
+
* MSW, and a test can route a stubbed fetch to it directly. Everything it
|
|
28
|
+
* uses is a web API (fetch's Request and Response, FormData, WebCrypto), so it
|
|
29
|
+
* runs in a browser, a service worker and Node alike.
|
|
30
|
+
*/
|
|
31
|
+
export class LambderMemoryUploadBucket {
|
|
32
|
+
/** Where tickets and download links point, always ending in a slash. */
|
|
33
|
+
baseUrl;
|
|
34
|
+
ticketLifetimeSeconds;
|
|
35
|
+
downloadLifetimeSeconds;
|
|
36
|
+
now;
|
|
37
|
+
objects = new Map();
|
|
38
|
+
tickets = new Map();
|
|
39
|
+
links = new Map();
|
|
40
|
+
constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now } = {}) {
|
|
41
|
+
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
42
|
+
assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
|
|
43
|
+
const url = new URL(baseUrl ?? `https://upload-bucket-${crypto.randomUUID()}.invalid/`);
|
|
44
|
+
url.search = "";
|
|
45
|
+
url.hash = "";
|
|
46
|
+
this.baseUrl = url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
47
|
+
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
48
|
+
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
49
|
+
this.now = now;
|
|
50
|
+
}
|
|
51
|
+
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object = {} }) {
|
|
52
|
+
assertPinnedObjectKey(objectKey);
|
|
53
|
+
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
54
|
+
assertObjectOptions(object);
|
|
55
|
+
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
56
|
+
const ticketId = crypto.randomUUID();
|
|
57
|
+
const expiresAt = this.now() + lifetimeSeconds * 1000;
|
|
58
|
+
// The fields S3's ticket carries, so a client posts the same form to either.
|
|
59
|
+
const formFields = {
|
|
60
|
+
key: objectKey,
|
|
61
|
+
"Content-Type": fileFacts.mimeType,
|
|
62
|
+
"x-amz-checksum-algorithm": "SHA256",
|
|
63
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
64
|
+
...uploadObjectFormFields(object),
|
|
65
|
+
[TICKET_FIELD]: ticketId,
|
|
66
|
+
};
|
|
67
|
+
this.tickets.set(ticketId, { objectKey, mimeType: fileFacts.mimeType, byteSize: fileFacts.byteSize, sha256Base64: fileFacts.sha256Base64, expiresAt, formFields, object });
|
|
68
|
+
return { uploadUrl: this.baseUrl, formFields, expiresAt };
|
|
69
|
+
}
|
|
70
|
+
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
71
|
+
const object = this.objects.get(objectKey);
|
|
72
|
+
if (!object)
|
|
73
|
+
return { verified: false, reason: "objectMissing" };
|
|
74
|
+
const matches = object.body.byteLength === fileFacts.byteSize && object.sha256Base64 === fileFacts.sha256Base64;
|
|
75
|
+
return matches ? { verified: true } : { verified: false, reason: "factsMismatch" };
|
|
76
|
+
}
|
|
77
|
+
async issueDownloadUrl({ objectKey, lifetimeSeconds = this.downloadLifetimeSeconds, contentDisposition }) {
|
|
78
|
+
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
79
|
+
const linkId = crypto.randomUUID();
|
|
80
|
+
this.links.set(linkId, { objectKey, expiresAt: this.now() + lifetimeSeconds * 1000, contentDisposition });
|
|
81
|
+
// Appended rather than resolved against the base, so a key that starts
|
|
82
|
+
// with a slash stays under it.
|
|
83
|
+
const url = new URL(`${this.baseUrl}${encodeObjectKey(objectKey)}`);
|
|
84
|
+
url.searchParams.set(LINK_PARAMETER, linkId);
|
|
85
|
+
return url.href;
|
|
86
|
+
}
|
|
87
|
+
async readObject(objectKey) {
|
|
88
|
+
const object = this.objects.get(objectKey);
|
|
89
|
+
if (!object)
|
|
90
|
+
throw new Error(`LambderMemoryUploadBucket.readObject: nothing is stored under ${objectKey}`);
|
|
91
|
+
return object.body.slice();
|
|
92
|
+
}
|
|
93
|
+
async writeObject({ objectKey, body, mimeType, sha256Base64, object = {} }) {
|
|
94
|
+
assertObjectOptions(object);
|
|
95
|
+
const computed = await sha256Base64Of(body);
|
|
96
|
+
// S3 refuses a body whose checksum is not the one sent (BadDigest), and so does this.
|
|
97
|
+
if (sha256Base64 !== undefined && sha256Base64 !== computed) {
|
|
98
|
+
throw new Error(`LambderMemoryUploadBucket.writeObject: the SHA-256 given for ${objectKey} is not the body's`);
|
|
99
|
+
}
|
|
100
|
+
this.objects.set(objectKey, { body: body.slice(), mimeType, sha256Base64: computed, object });
|
|
101
|
+
}
|
|
102
|
+
async copyObject({ fromObjectKey, toObjectKey }) {
|
|
103
|
+
const object = this.objects.get(fromObjectKey);
|
|
104
|
+
if (!object)
|
|
105
|
+
throw new Error(`LambderMemoryUploadBucket.copyObject: nothing is stored under ${fromObjectKey}`);
|
|
106
|
+
this.objects.set(toObjectKey, { ...object, body: object.body.slice() });
|
|
107
|
+
}
|
|
108
|
+
async deleteObject(objectKey) {
|
|
109
|
+
this.objects.delete(objectKey);
|
|
110
|
+
}
|
|
111
|
+
/** The keys that hold an object, sorted, for a test to assert on. */
|
|
112
|
+
listObjectKeys() {
|
|
113
|
+
return [...this.objects.keys()].sort();
|
|
114
|
+
}
|
|
115
|
+
/** What is held under a key (its facts, tags, metadata and headers), for a test to assert on; null when nothing is. */
|
|
116
|
+
inspectObject(objectKey) {
|
|
117
|
+
const stored = this.objects.get(objectKey);
|
|
118
|
+
return stored ? { byteSize: stored.body.byteLength, mimeType: stored.mimeType, sha256Base64: stored.sha256Base64, ...stored.object } : null;
|
|
119
|
+
}
|
|
120
|
+
/** Forgets every object, ticket and link. */
|
|
121
|
+
reset() {
|
|
122
|
+
this.objects.clear();
|
|
123
|
+
this.tickets.clear();
|
|
124
|
+
this.links.clear();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Answers a request to storage the way S3 answers it: a post under a
|
|
128
|
+
* ticket stores its file, a GET or HEAD through a download link reads an
|
|
129
|
+
* object. A request outside baseUrl answers null, for the caller to hand
|
|
130
|
+
* on.
|
|
131
|
+
*/
|
|
132
|
+
async handleStorageRequest(request) {
|
|
133
|
+
const url = new URL(request.url);
|
|
134
|
+
if (!`${url.origin}${url.pathname}`.startsWith(this.baseUrl))
|
|
135
|
+
return null;
|
|
136
|
+
let objectKey;
|
|
137
|
+
try {
|
|
138
|
+
objectKey = decodeObjectKey(url.pathname.slice(new URL(this.baseUrl).pathname.length));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return storageError(400, "InvalidURI", "Couldn't parse the specified URI.");
|
|
142
|
+
}
|
|
143
|
+
if (request.method === "POST" && objectKey === "")
|
|
144
|
+
return this.acceptUpload(request);
|
|
145
|
+
if (request.method === "GET" || request.method === "HEAD")
|
|
146
|
+
return this.serveDownload(objectKey, url.searchParams.get(LINK_PARAMETER), request.method === "HEAD");
|
|
147
|
+
return storageError(405, "MethodNotAllowed", "The specified method is not allowed against this resource.");
|
|
148
|
+
}
|
|
149
|
+
async acceptUpload(request) {
|
|
150
|
+
let form;
|
|
151
|
+
try {
|
|
152
|
+
form = await request.formData();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return storageError(400, "MalformedPOSTRequest", "The body of your POST request is not well-formed multipart/form-data.");
|
|
156
|
+
}
|
|
157
|
+
// S3 reads the form up to the file and ignores everything after it.
|
|
158
|
+
const fields = new Map();
|
|
159
|
+
let file;
|
|
160
|
+
for (const [name, value] of form.entries()) {
|
|
161
|
+
if (typeof value !== "string") {
|
|
162
|
+
if (name === "file") {
|
|
163
|
+
file = value;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
fields.set(name, value);
|
|
169
|
+
}
|
|
170
|
+
if (!file)
|
|
171
|
+
return storageError(400, "InvalidArgument", "POST requires exactly one file upload per request.");
|
|
172
|
+
const ticketId = fields.get(TICKET_FIELD);
|
|
173
|
+
const ticket = ticketId === undefined ? undefined : this.tickets.get(ticketId);
|
|
174
|
+
if (!ticket)
|
|
175
|
+
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
176
|
+
if (this.now() >= ticket.expiresAt)
|
|
177
|
+
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy expired.");
|
|
178
|
+
const extra = [...fields.keys()].filter((name) => !(name in ticket.formFields));
|
|
179
|
+
if (extra.length)
|
|
180
|
+
return storageError(403, "AccessDenied", `Invalid according to Policy: Extra input fields: ${extra.join(", ")}`);
|
|
181
|
+
const pinned = Object.entries(ticket.formFields).every(([name, value]) => fields.get(name) === value);
|
|
182
|
+
if (!pinned)
|
|
183
|
+
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
184
|
+
const body = new Uint8Array(await file.arrayBuffer());
|
|
185
|
+
if (body.byteLength < ticket.byteSize)
|
|
186
|
+
return storageError(400, "EntityTooSmall", "Your proposed upload is smaller than the minimum allowed size");
|
|
187
|
+
if (body.byteLength > ticket.byteSize)
|
|
188
|
+
return storageError(400, "EntityTooLarge", "Your proposed upload exceeds the maximum allowed size");
|
|
189
|
+
const sha256Base64 = await sha256Base64Of(body);
|
|
190
|
+
if (sha256Base64 !== ticket.sha256Base64)
|
|
191
|
+
return storageError(400, "BadDigest", "The SHA256 you specified did not match the calculated checksum.");
|
|
192
|
+
this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
|
|
193
|
+
return new Response(null, { status: 204 });
|
|
194
|
+
}
|
|
195
|
+
serveDownload(objectKey, linkId, headOnly) {
|
|
196
|
+
const link = linkId === null ? undefined : this.links.get(linkId);
|
|
197
|
+
if (!link || link.objectKey !== objectKey)
|
|
198
|
+
return storageError(403, "AccessDenied", "Access Denied");
|
|
199
|
+
if (this.now() >= link.expiresAt)
|
|
200
|
+
return storageError(403, "AccessDenied", "Request has expired");
|
|
201
|
+
const stored = this.objects.get(objectKey);
|
|
202
|
+
if (!stored)
|
|
203
|
+
return storageError(404, "NoSuchKey", "The specified key does not exist.");
|
|
204
|
+
const headers = { "content-type": stored.mimeType, "content-length": String(stored.body.byteLength) };
|
|
205
|
+
if (stored.object.cacheControl !== undefined)
|
|
206
|
+
headers["cache-control"] = stored.object.cacheControl;
|
|
207
|
+
// The link's own disposition over the object's, as S3 answers a presigned read.
|
|
208
|
+
const disposition = link.contentDisposition ?? stored.object.contentDisposition;
|
|
209
|
+
if (disposition)
|
|
210
|
+
headers["content-disposition"] = contentDispositionHeader(disposition);
|
|
211
|
+
return new Response(headOnly ? null : new Blob([stored.body]), { status: 200, headers });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** A key as a URL path: each segment escaped, the slashes kept, as S3 addresses an object. */
|
|
215
|
+
const encodeObjectKey = (objectKey) => objectKey.split("/").map(encodeURIComponent).join("/");
|
|
216
|
+
const decodeObjectKey = (path) => path.split("/").map(decodeURIComponent).join("/");
|
|
217
|
+
const escapeXml = (text) => text.replace(/[<>&'"]/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
218
|
+
/** An error the way S3 writes one: the status, and `<Error><Code/><Message/></Error>` as XML. */
|
|
219
|
+
const storageError = (status, code, message) => new Response(`<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>${escapeXml(code)}</Code><Message>${escapeXml(message)}</Message></Error>`, { status, headers: { "content-type": "application/xml" } });
|
|
@@ -12,24 +12,39 @@ export type LambderS3FileSourceOptions = {
|
|
|
12
12
|
* `{ region: "auto", endpoint, credentials }`.
|
|
13
13
|
*/
|
|
14
14
|
clientConfig?: S3ClientConfig;
|
|
15
|
+
/**
|
|
16
|
+
* The S3 error names that mean "no such file", read as null so the
|
|
17
|
+
* request falls through. Default: NoSuchKey and NotFound (the key does
|
|
18
|
+
* not exist), AccessDenied (what S3 answers a reader without
|
|
19
|
+
* s3:ListBucket for a missing key, since it will not say whether the key
|
|
20
|
+
* exists), and the refusals of a key S3 will not look up at all:
|
|
21
|
+
* KeyTooLongError and InvalidURI from S3, InvalidObjectName from R2. A
|
|
22
|
+
* reader granted s3:ListBucket gets NoSuchKey for a missing key, so a
|
|
23
|
+
* list without AccessDenied makes a refused credential surface as the
|
|
24
|
+
* error it is.
|
|
25
|
+
*/
|
|
26
|
+
notFoundErrorNames?: readonly string[];
|
|
15
27
|
};
|
|
16
28
|
/**
|
|
17
29
|
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
18
30
|
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
19
31
|
* optional peer dependency loaded on first read, so apps that serve from a
|
|
20
|
-
* folder never load it. A missing object reads as
|
|
21
|
-
* falls through;
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* folder never load it. A missing object (see `notFoundErrorNames`) reads as
|
|
33
|
+
* null and the request falls through; any other failure throws. A request
|
|
34
|
+
* path names the key, so a key that is missing or that S3 refuses to look
|
|
35
|
+
* up is the visitor's doing, and reading it as an error would answer every
|
|
36
|
+
* such path with a 500 before an SPA's shell could be served. An object's
|
|
37
|
+
* Content-Type is used unless it is a generic octet-stream, in which case
|
|
38
|
+
* the extension decides, as for local files.
|
|
25
39
|
*/
|
|
26
40
|
export declare class LambderS3FileSource implements LambderFileSource {
|
|
27
41
|
private readonly bucket;
|
|
28
42
|
private readonly prefix;
|
|
29
43
|
private readonly clientConfig;
|
|
44
|
+
private readonly notFoundErrorNames;
|
|
30
45
|
private client;
|
|
31
46
|
private sdk;
|
|
32
|
-
constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
|
|
47
|
+
constructor({ bucket, prefix, client, clientConfig, notFoundErrorNames }: LambderS3FileSourceOptions);
|
|
33
48
|
private loadSdk;
|
|
34
49
|
read(relativePath: string): Promise<LambderFile | null>;
|
|
35
50
|
}
|
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
|
|
2
|
+
const DEFAULT_NOT_FOUND_ERROR_NAMES = ["NoSuchKey", "NotFound", "AccessDenied", "KeyTooLongError", "InvalidURI", "InvalidObjectName"];
|
|
2
3
|
/**
|
|
3
4
|
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
4
5
|
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
5
6
|
* optional peer dependency loaded on first read, so apps that serve from a
|
|
6
|
-
* folder never load it. A missing object reads as
|
|
7
|
-
* falls through;
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* folder never load it. A missing object (see `notFoundErrorNames`) reads as
|
|
8
|
+
* null and the request falls through; any other failure throws. A request
|
|
9
|
+
* path names the key, so a key that is missing or that S3 refuses to look
|
|
10
|
+
* up is the visitor's doing, and reading it as an error would answer every
|
|
11
|
+
* such path with a 500 before an SPA's shell could be served. An object's
|
|
12
|
+
* Content-Type is used unless it is a generic octet-stream, in which case
|
|
13
|
+
* the extension decides, as for local files.
|
|
11
14
|
*/
|
|
12
15
|
export class LambderS3FileSource {
|
|
13
16
|
bucket;
|
|
14
17
|
prefix;
|
|
15
18
|
clientConfig;
|
|
19
|
+
notFoundErrorNames;
|
|
16
20
|
client;
|
|
17
21
|
sdk;
|
|
18
|
-
constructor({ bucket, prefix = "", client, clientConfig }) {
|
|
22
|
+
constructor({ bucket, prefix = "", client, clientConfig, notFoundErrorNames = DEFAULT_NOT_FOUND_ERROR_NAMES }) {
|
|
19
23
|
if (!bucket.trim())
|
|
20
24
|
throw new Error("bucket is required");
|
|
21
25
|
this.bucket = bucket;
|
|
22
26
|
this.prefix = prefix;
|
|
23
27
|
this.client = client;
|
|
24
28
|
this.clientConfig = clientConfig;
|
|
29
|
+
this.notFoundErrorNames = notFoundErrorNames;
|
|
25
30
|
}
|
|
26
31
|
loadSdk() {
|
|
27
32
|
if (!this.sdk) {
|
|
@@ -41,7 +46,7 @@ export class LambderS3FileSource {
|
|
|
41
46
|
}
|
|
42
47
|
catch (err) {
|
|
43
48
|
const name = err.name;
|
|
44
|
-
if (name === "
|
|
49
|
+
if (typeof name === "string" && this.notFoundErrorNames.includes(name))
|
|
45
50
|
return null;
|
|
46
51
|
throw err;
|
|
47
52
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
3
|
+
export type LambderS3UploadBucketOptions = {
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** A ready client, e.g. one shared with the rest of the app. */
|
|
6
|
+
client?: S3Client;
|
|
7
|
+
/** Otherwise the client is created from this on first use: `{ region }`. */
|
|
8
|
+
clientConfig?: S3ClientConfig;
|
|
9
|
+
/** How long a ticket stays usable, unless a ticket says otherwise. Default: 600 seconds, enough for a large file on a slow phone. */
|
|
10
|
+
ticketLifetimeSeconds?: number;
|
|
11
|
+
/** How long a download link reads the object, unless a link says otherwise. Default: 300 seconds. */
|
|
12
|
+
downloadLifetimeSeconds?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* An S3 bucket browsers upload to directly (see LambderUploadBucket).
|
|
16
|
+
*
|
|
17
|
+
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
18
|
+
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
19
|
+
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
20
|
+
* a store that implements them; Cloudflare R2 does not take presigned POSTs.
|
|
21
|
+
*
|
|
22
|
+
* Signing a ticket or a download link is arithmetic over the function's
|
|
23
|
+
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
24
|
+
* deleting are calls to the bucket. A signature lives at most seven days,
|
|
25
|
+
* and never past the credentials that made it: a Lambda's role credentials
|
|
26
|
+
* last hours, so a link meant to outlive them needs long-lived keys. Needs @aws-sdk/client-s3,
|
|
27
|
+
* @aws-sdk/s3-presigned-post and @aws-sdk/s3-request-presigner, optional peer
|
|
28
|
+
* dependencies each loaded the first time a call needs it, so an app that
|
|
29
|
+
* never uploads never loads them.
|
|
30
|
+
*/
|
|
31
|
+
export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
32
|
+
private readonly bucket;
|
|
33
|
+
private readonly ticketLifetimeSeconds;
|
|
34
|
+
private readonly downloadLifetimeSeconds;
|
|
35
|
+
private readonly clientConfig;
|
|
36
|
+
private client;
|
|
37
|
+
private clientSdk;
|
|
38
|
+
private presignedPostSdk;
|
|
39
|
+
private requestPresignerSdk;
|
|
40
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds }: LambderS3UploadBucketOptions);
|
|
41
|
+
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
42
|
+
objectKey: string;
|
|
43
|
+
fileFacts: LambderUploadFileFacts;
|
|
44
|
+
uploadRule: LambderUploadRule;
|
|
45
|
+
lifetimeSeconds?: number;
|
|
46
|
+
object?: LambderUploadObjectOptions;
|
|
47
|
+
}): Promise<LambderUploadTicket>;
|
|
48
|
+
verifyUploadedObject({ objectKey, fileFacts }: {
|
|
49
|
+
objectKey: string;
|
|
50
|
+
fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
|
|
51
|
+
}): Promise<LambderUploadVerdict>;
|
|
52
|
+
issueDownloadUrl({ objectKey, lifetimeSeconds, contentDisposition }: {
|
|
53
|
+
objectKey: string;
|
|
54
|
+
lifetimeSeconds?: number;
|
|
55
|
+
contentDisposition?: LambderUploadContentDisposition;
|
|
56
|
+
}): Promise<string>;
|
|
57
|
+
readObject(objectKey: string): Promise<Uint8Array>;
|
|
58
|
+
writeObject({ objectKey, body, mimeType, sha256Base64, object }: {
|
|
59
|
+
objectKey: string;
|
|
60
|
+
body: Uint8Array;
|
|
61
|
+
mimeType: string;
|
|
62
|
+
sha256Base64?: string;
|
|
63
|
+
object?: LambderUploadObjectOptions;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
copyObject({ fromObjectKey, toObjectKey }: {
|
|
66
|
+
fromObjectKey: string;
|
|
67
|
+
toObjectKey: string;
|
|
68
|
+
}): Promise<void>;
|
|
69
|
+
deleteObject(objectKey: string): Promise<void>;
|
|
70
|
+
private s3;
|
|
71
|
+
private loadPresignedPostSdk;
|
|
72
|
+
private loadRequestPresignerSdk;
|
|
73
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
|
+
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
|
+
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
5
|
+
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
6
|
+
/** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
|
|
7
|
+
const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
|
|
8
|
+
/**
|
|
9
|
+
* An S3 bucket browsers upload to directly (see LambderUploadBucket).
|
|
10
|
+
*
|
|
11
|
+
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
12
|
+
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
13
|
+
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
14
|
+
* a store that implements them; Cloudflare R2 does not take presigned POSTs.
|
|
15
|
+
*
|
|
16
|
+
* Signing a ticket or a download link is arithmetic over the function's
|
|
17
|
+
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
18
|
+
* deleting are calls to the bucket. A signature lives at most seven days,
|
|
19
|
+
* and never past the credentials that made it: a Lambda's role credentials
|
|
20
|
+
* last hours, so a link meant to outlive them needs long-lived keys. Needs @aws-sdk/client-s3,
|
|
21
|
+
* @aws-sdk/s3-presigned-post and @aws-sdk/s3-request-presigner, optional peer
|
|
22
|
+
* dependencies each loaded the first time a call needs it, so an app that
|
|
23
|
+
* never uploads never loads them.
|
|
24
|
+
*/
|
|
25
|
+
export class LambderS3UploadBucket {
|
|
26
|
+
bucket;
|
|
27
|
+
ticketLifetimeSeconds;
|
|
28
|
+
downloadLifetimeSeconds;
|
|
29
|
+
clientConfig;
|
|
30
|
+
client;
|
|
31
|
+
clientSdk;
|
|
32
|
+
presignedPostSdk;
|
|
33
|
+
requestPresignerSdk;
|
|
34
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300 }) {
|
|
35
|
+
if (!bucket.trim())
|
|
36
|
+
throw new Error("bucket is required");
|
|
37
|
+
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
38
|
+
assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
|
|
39
|
+
this.bucket = bucket;
|
|
40
|
+
this.client = client;
|
|
41
|
+
this.clientConfig = clientConfig;
|
|
42
|
+
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
43
|
+
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
44
|
+
}
|
|
45
|
+
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
|
|
46
|
+
assertPinnedObjectKey(objectKey);
|
|
47
|
+
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
48
|
+
assertObjectOptions(object);
|
|
49
|
+
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
50
|
+
const [{ client }, { createPresignedPost }] = await Promise.all([this.s3(), this.loadPresignedPostSdk()]);
|
|
51
|
+
const post = await createPresignedPost(client, {
|
|
52
|
+
Bucket: this.bucket,
|
|
53
|
+
Key: objectKey,
|
|
54
|
+
Expires: lifetimeSeconds,
|
|
55
|
+
// Each field is also an exact-match condition of the signed policy.
|
|
56
|
+
Fields: {
|
|
57
|
+
"Content-Type": fileFacts.mimeType,
|
|
58
|
+
"x-amz-checksum-algorithm": "SHA256",
|
|
59
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
60
|
+
...uploadObjectFormFields(object),
|
|
61
|
+
},
|
|
62
|
+
Conditions: [["content-length-range", fileFacts.byteSize, fileFacts.byteSize]],
|
|
63
|
+
});
|
|
64
|
+
return { uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
65
|
+
}
|
|
66
|
+
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
67
|
+
const { sdk, client } = await this.s3();
|
|
68
|
+
let head;
|
|
69
|
+
try {
|
|
70
|
+
head = await client.send(new sdk.HeadObjectCommand({ Bucket: this.bucket, Key: objectKey, ChecksumMode: "ENABLED" }));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (MISSING_OBJECT_ERROR_NAMES.includes(err.name ?? ""))
|
|
74
|
+
return { verified: false, reason: "objectMissing" };
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
const matches = head.ContentLength === fileFacts.byteSize && head.ChecksumSHA256 === fileFacts.sha256Base64;
|
|
78
|
+
return matches ? { verified: true } : { verified: false, reason: "factsMismatch" };
|
|
79
|
+
}
|
|
80
|
+
async issueDownloadUrl({ objectKey, lifetimeSeconds = this.downloadLifetimeSeconds, contentDisposition }) {
|
|
81
|
+
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
82
|
+
const [{ sdk, client }, { getSignedUrl }] = await Promise.all([this.s3(), this.loadRequestPresignerSdk()]);
|
|
83
|
+
return getSignedUrl(client, new sdk.GetObjectCommand({
|
|
84
|
+
Bucket: this.bucket,
|
|
85
|
+
Key: objectKey,
|
|
86
|
+
// Signed into the link: S3 answers with this header for its reads alone.
|
|
87
|
+
...(contentDisposition ? { ResponseContentDisposition: contentDispositionHeader(contentDisposition) } : {}),
|
|
88
|
+
}), { expiresIn: lifetimeSeconds });
|
|
89
|
+
}
|
|
90
|
+
async readObject(objectKey) {
|
|
91
|
+
const { sdk, client } = await this.s3();
|
|
92
|
+
const object = await client.send(new sdk.GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
93
|
+
if (!object.Body)
|
|
94
|
+
throw new Error(`LambderS3UploadBucket.readObject: no body under ${objectKey}`);
|
|
95
|
+
return object.Body.transformToByteArray();
|
|
96
|
+
}
|
|
97
|
+
async writeObject({ objectKey, body, mimeType, sha256Base64, object }) {
|
|
98
|
+
assertObjectOptions(object);
|
|
99
|
+
const { sdk, client } = await this.s3();
|
|
100
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
101
|
+
const metadata = Object.entries(object?.metadata ?? {});
|
|
102
|
+
await client.send(new sdk.PutObjectCommand({
|
|
103
|
+
Bucket: this.bucket,
|
|
104
|
+
Key: objectKey,
|
|
105
|
+
Body: body,
|
|
106
|
+
ContentType: mimeType,
|
|
107
|
+
...(tags.length ? { Tagging: new URLSearchParams(tags).toString() } : {}),
|
|
108
|
+
...(metadata.length ? { Metadata: Object.fromEntries(metadata.map(([name, value]) => [name.toLowerCase(), value])) } : {}),
|
|
109
|
+
...(object?.cacheControl !== undefined ? { CacheControl: object.cacheControl } : {}),
|
|
110
|
+
...(object?.contentDisposition ? { ContentDisposition: contentDispositionHeader(object.contentDisposition) } : {}),
|
|
111
|
+
// A digest the caller already has is sent as it is; otherwise the
|
|
112
|
+
// SDK computes it. Either way S3 checks the body against it and
|
|
113
|
+
// stores it, which is what verifyUploadedObject reads back.
|
|
114
|
+
...(sha256Base64 !== undefined ? { ChecksumSHA256: sha256Base64 } : { ChecksumAlgorithm: "SHA256" }),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
async copyObject({ fromObjectKey, toObjectKey }) {
|
|
118
|
+
const { sdk, client } = await this.s3();
|
|
119
|
+
await client.send(new sdk.CopyObjectCommand({
|
|
120
|
+
Bucket: this.bucket,
|
|
121
|
+
// The source is named as a URL path, so a key's own characters are escaped and its slashes kept.
|
|
122
|
+
CopySource: `${this.bucket}/${fromObjectKey.split("/").map(encodeURIComponent).join("/")}`,
|
|
123
|
+
Key: toObjectKey,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
async deleteObject(objectKey) {
|
|
127
|
+
const { sdk, client } = await this.s3();
|
|
128
|
+
await client.send(new sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
129
|
+
}
|
|
130
|
+
async s3() {
|
|
131
|
+
this.clientSdk ??= withInstallHint(import("@aws-sdk/client-s3"), "@aws-sdk/client-s3", "LambderS3UploadBucket", () => { this.clientSdk = undefined; });
|
|
132
|
+
const sdk = await this.clientSdk;
|
|
133
|
+
this.client ??= new sdk.S3Client(this.clientConfig ?? {});
|
|
134
|
+
return { sdk, client: this.client };
|
|
135
|
+
}
|
|
136
|
+
loadPresignedPostSdk() {
|
|
137
|
+
this.presignedPostSdk ??= withInstallHint(import("@aws-sdk/s3-presigned-post"), "@aws-sdk/s3-presigned-post", "LambderS3UploadBucket", () => { this.presignedPostSdk = undefined; });
|
|
138
|
+
return this.presignedPostSdk;
|
|
139
|
+
}
|
|
140
|
+
loadRequestPresignerSdk() {
|
|
141
|
+
this.requestPresignerSdk ??= withInstallHint(import("@aws-sdk/s3-request-presigner"), "@aws-sdk/s3-request-presigner", "LambderS3UploadBucket", () => { this.requestPresignerSdk = undefined; });
|
|
142
|
+
return this.requestPresignerSdk;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An optional peer package loaded on first use, failing with the install hint.
|
|
3
|
+
*
|
|
4
|
+
* The AWS SDK packages a store or bucket talks through are optional peer
|
|
5
|
+
* dependencies, so an app that never uses one neither installs it nor pays
|
|
6
|
+
* for loading it. A package that fails to load fails that first call with a
|
|
7
|
+
* message naming the class that needed it and the command that installs it.
|
|
8
|
+
* The failure is not remembered: the package may be installed later in the
|
|
9
|
+
* same process (tests do), and the next caller names itself.
|
|
10
|
+
*/
|
|
11
|
+
export declare const withInstallHint: <T>(loading: Promise<T>, packageName: string, user: string, reset: () => void) => Promise<T>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An optional peer package loaded on first use, failing with the install hint.
|
|
3
|
+
*
|
|
4
|
+
* The AWS SDK packages a store or bucket talks through are optional peer
|
|
5
|
+
* dependencies, so an app that never uses one neither installs it nor pays
|
|
6
|
+
* for loading it. A package that fails to load fails that first call with a
|
|
7
|
+
* message naming the class that needed it and the command that installs it.
|
|
8
|
+
* The failure is not remembered: the package may be installed later in the
|
|
9
|
+
* same process (tests do), and the next caller names itself.
|
|
10
|
+
*/
|
|
11
|
+
export const withInstallHint = (loading, packageName, user, reset) => loading.catch((cause) => {
|
|
12
|
+
reset();
|
|
13
|
+
throw new Error(`${user} requires ${packageName}: npm install ${packageName}`, { cause });
|
|
14
|
+
});
|
|
@@ -23,9 +23,9 @@ export type LambderTestAppOptions = {
|
|
|
23
23
|
/**
|
|
24
24
|
* The gateway shape the handler is called with: "v2" (an HTTP API, a
|
|
25
25
|
* Function URL) or "v1" (a REST API). Default: "v2". A handler answers
|
|
26
|
-
* both alike through its context, so this matters to code that reads
|
|
27
|
-
* raw `ctx.event`,
|
|
28
|
-
*
|
|
26
|
+
* both alike through its context, so this matters only to code that reads
|
|
27
|
+
* the raw `ctx.event`, or to a suite that should run on exactly what
|
|
28
|
+
* production delivers.
|
|
29
29
|
*/
|
|
30
30
|
eventFormat?: LambderHttpEventFormat;
|
|
31
31
|
/** Default: a fresh LambderMemorySessionStore. Pass your own to run the suite over another store (DynamoDB Local, say). */
|
|
@@ -46,10 +46,10 @@ export type LambderTestAppOptions = {
|
|
|
46
46
|
/**
|
|
47
47
|
* A Lambder instance as a test app takes it: any instance, read for its
|
|
48
48
|
* session data type and, through the ApiContract property rather than the
|
|
49
|
-
* class parameter, for its contract. The property
|
|
50
|
-
*
|
|
51
|
-
* (`lambderTestApp<SessionData, ApiContractType>(lambder)`)
|
|
52
|
-
*
|
|
49
|
+
* class parameter, for its contract. The property lets a large app name the
|
|
50
|
+
* contract writeApiContract generated for its clients
|
|
51
|
+
* (`lambderTestApp<SessionData, ApiContractType>(lambder)`), so the tests type
|
|
52
|
+
* their calls against plain members rather than the chained intersection.
|
|
53
53
|
*/
|
|
54
54
|
export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
|
|
55
55
|
readonly ApiContract: TContract;
|
|
@@ -59,17 +59,17 @@ export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionDat
|
|
|
59
59
|
* stores put under it in place, and as many simulated browsers in front of it
|
|
60
60
|
* as a test needs. No HTTP, no AWS, and nothing in the app restructured.
|
|
61
61
|
*
|
|
62
|
-
* The app's own declarations all run as written:
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
62
|
+
* The app's own declarations all run as written: guards, named rate-limit
|
|
63
|
+
* policies, idempotency settings, session salt, cookie options and
|
|
64
|
+
* dataRefresh, hooks and error handlers. Only where things rest is replaced:
|
|
65
|
+
* once this is created, the stores the app was configured with are out of the
|
|
66
|
+
* instance's reach, so a test cannot touch a production table by mistake.
|
|
67
|
+
* What the app reaches on its own (its database, a mailer) is the app's to
|
|
68
|
+
* replace.
|
|
69
69
|
*
|
|
70
70
|
* The sibling of LambderMockApp, which serves a contract from mock handlers:
|
|
71
71
|
* the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
|
|
72
|
-
* real handlers
|
|
72
|
+
* real handlers.
|
|
73
73
|
*
|
|
74
74
|
* Time is not this class's: fake `Date` with the test runner
|
|
75
75
|
* (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
|
|
@@ -97,22 +97,20 @@ export declare class LambderTestApp<TContract extends LambderApiContractShape =
|
|
|
97
97
|
private resetCount;
|
|
98
98
|
private readonly crashList;
|
|
99
99
|
/**
|
|
100
|
-
* The call a crash happened under. The app
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* async context says which call a crash belongs to.
|
|
100
|
+
* The call a crash happened under. The app's 500 says nothing about the
|
|
101
|
+
* crash, so the error travels beside the answer, and with concurrent
|
|
102
|
+
* calls (a duplicate sent while the original is in flight is an ordinary
|
|
103
|
+
* idempotency test) only the async context says which call it belongs to.
|
|
105
104
|
*/
|
|
106
105
|
private readonly crashScope;
|
|
107
106
|
constructor(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions);
|
|
108
107
|
/**
|
|
109
108
|
* Every error the app threw while answering a request since the last
|
|
110
109
|
* reset, in order: what reached its global error handler, or the
|
|
111
|
-
* framework's last-resort 500. The answers
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* with, which the test already holds.
|
|
110
|
+
* framework's last-resort 500. The answers say nothing about what was
|
|
111
|
+
* thrown, so a test reads it here; `expect(app.crashes).toEqual([])`
|
|
112
|
+
* says nothing crashed. A refusal is not a crash, and neither is an
|
|
113
|
+
* error an `event()` rejects with, which the test already holds.
|
|
116
114
|
*/
|
|
117
115
|
get crashes(): readonly Error[];
|
|
118
116
|
/** The session manager, for tests that inspect or manipulate sessions directly. Throws when the app has no sessions. */
|