lambder 9.0.2 → 10.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 +195 -0
- package/README.md +29 -14
- package/dist/api/LambderApiDefinition.d.ts +9 -0
- package/dist/api/LambderApiEnvelope.d.ts +22 -10
- package/dist/api/LambderApiEnvelope.js +57 -22
- package/dist/api/LambderApiGuards.d.ts +77 -22
- package/dist/api/LambderApiGuards.js +13 -1
- package/dist/api/LambderApiIdempotency.js +4 -4
- package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
- package/dist/api/LambderApiOutputValidationError.js +14 -6
- package/dist/api/LambderApiPipeline.d.ts +2 -1
- package/dist/api/LambderApiPipeline.js +10 -2
- package/dist/api/LambderApiRateLimits.d.ts +15 -11
- package/dist/api/LambderApiRateLimits.js +31 -7
- package/dist/api/LambderApiRefusals.d.ts +204 -0
- package/dist/api/LambderApiRefusals.js +223 -0
- package/dist/api/LambderApiSignature.d.ts +13 -4
- package/dist/api/LambderApiSignature.js +21 -4
- package/dist/build/writeApiOptions.js +7 -5
- package/dist/client/LambderCaller.d.ts +28 -17
- package/dist/client/LambderCaller.js +19 -15
- package/dist/client/LambderUploadRunner.d.ts +5 -5
- package/dist/client/LambderUploadRunner.js +25 -16
- package/dist/client.d.ts +6 -5
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +121 -23
- package/dist/core/Lambder.js +105 -51
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +5 -0
- package/dist/core/LambderCreateOptions.d.ts +58 -15
- package/dist/core/LambderCreateOptions.js +10 -0
- package/dist/core/LambderPolicyBuilders.d.ts +11 -8
- package/dist/core/LambderPolicyBuilders.js +10 -8
- package/dist/core/LambderResolver.d.ts +1 -1
- package/dist/core/LambderResolver.js +1 -1
- package/dist/core/LambderResponseBuilder.d.ts +11 -9
- package/dist/core/LambderResponseBuilder.js +12 -11
- package/dist/index.d.ts +13 -9
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
- package/dist/invoke/LambderInvokeCaller.js +13 -12
- package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
- package/dist/invoke/LambderInvokeOutcome.js +4 -4
- package/dist/mock/LambderMockApp.d.ts +86 -24
- package/dist/mock/LambderMockApp.js +108 -16
- package/dist/mock/LambderMockCallRecorder.js +6 -3
- package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
- package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
- package/dist/mock/LambderMockFailureInjector.js +18 -4
- package/dist/mock/LambderMockTypes.d.ts +34 -11
- package/dist/mock.d.ts +4 -3
- package/dist/mock.js +4 -3
- package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
- package/dist/shared/contracts/LambderUploadBucket.js +2 -2
- package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
- package/dist/shared/wire/LambderApiContract.d.ts +96 -31
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
- package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
- package/dist/shared/wire/LambderApiOptionValues.js +3 -3
- package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
- package/dist/shared/wire/LambderApiOutcome.js +28 -11
- package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
- package/dist/shared/wire/LambderApiRefusal.js +15 -7
- package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
- package/dist/shared/wire/LambderCrashDetail.js +1 -1
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
- package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
- package/dist/shared/wire/LambderObjectPayload.js +24 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
- package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
- package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
- package/dist/shared/wire/LambderUploadSchemas.js +18 -7
- package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
- package/dist/stores/LambderMemoryUploadBucket.js +60 -12
- package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
- package/dist/stores/LambderS3UploadBucket.js +51 -4
- package/dist/testing/LambderTestApp.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { contentDispositionHeader } from "../util/LambderContentDisposition.js";
|
|
2
2
|
import { escapeXmlText } from "../util/escapeXmlText.js";
|
|
3
3
|
/**
|
|
4
|
-
* What a ticket's form carries for the stored object, in the fields S3's
|
|
4
|
+
* What a POST ticket's form carries for the stored object, in the fields S3's
|
|
5
5
|
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
6
6
|
* each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
|
|
7
7
|
* `Cache-Control` and `Content-Disposition`. Both buckets build their
|
|
@@ -22,3 +22,24 @@ export const uploadObjectFormFields = (object) => {
|
|
|
22
22
|
fields["Content-Disposition"] = contentDispositionHeader(object.contentDisposition);
|
|
23
23
|
return fields;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* What a PUT ticket's headers carry for the stored object, as a presigned
|
|
27
|
+
* PUT sends them: the tag set as the URL-encoded `x-amz-tagging`, each
|
|
28
|
+
* metadata entry as `x-amz-meta-<name>` (lowercased), `cache-control` and
|
|
29
|
+
* `content-disposition`. Names are lowercase, as a signature lists them. Both
|
|
30
|
+
* buckets build their PUT tickets' headers with this, and every one is signed
|
|
31
|
+
* into the URL like the checksum.
|
|
32
|
+
*/
|
|
33
|
+
export const uploadObjectHeaders = (object) => {
|
|
34
|
+
const headers = {};
|
|
35
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
36
|
+
if (tags.length)
|
|
37
|
+
headers["x-amz-tagging"] = new URLSearchParams(tags).toString();
|
|
38
|
+
for (const [name, value] of Object.entries(object?.metadata ?? {}))
|
|
39
|
+
headers[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
40
|
+
if (object?.cacheControl !== undefined)
|
|
41
|
+
headers["cache-control"] = object.cacheControl;
|
|
42
|
+
if (object?.contentDisposition)
|
|
43
|
+
headers["content-disposition"] = contentDispositionHeader(object.contentDisposition);
|
|
44
|
+
return headers;
|
|
45
|
+
};
|
|
@@ -5,8 +5,14 @@ export declare const LambderUploadFileFactsSchema: z.ZodObject<{
|
|
|
5
5
|
byteSize: z.ZodNumber;
|
|
6
6
|
sha256Base64: z.ZodString;
|
|
7
7
|
}, z.core.$strip>;
|
|
8
|
-
export declare const LambderUploadTicketSchema: z.ZodObject<{
|
|
8
|
+
export declare const LambderUploadTicketSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
9
|
+
method: z.ZodLiteral<"POST">;
|
|
9
10
|
uploadUrl: z.ZodURL;
|
|
10
11
|
formFields: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
11
12
|
expiresAt: z.ZodNumber;
|
|
12
|
-
}, z.core.$strip
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
method: z.ZodLiteral<"PUT">;
|
|
15
|
+
uploadUrl: z.ZodURL;
|
|
16
|
+
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
17
|
+
expiresAt: z.ZodNumber;
|
|
18
|
+
}, z.core.$strip>], "method">;
|
|
@@ -21,10 +21,21 @@ export const LambderUploadFileFactsSchema = z.object({
|
|
|
21
21
|
/** SHA-256 of the file's bytes as base64: 32 bytes are always 43 characters and one pad. */
|
|
22
22
|
sha256Base64: z.string().regex(/^[A-Za-z0-9+/]{43}=$/),
|
|
23
23
|
});
|
|
24
|
-
export const LambderUploadTicketSchema = z.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
export const LambderUploadTicketSchema = z.discriminatedUnion("method", [
|
|
25
|
+
z.object({
|
|
26
|
+
method: z.literal("POST"),
|
|
27
|
+
uploadUrl: z.url(),
|
|
28
|
+
/** Sent as form fields ahead of the file, which storage wants last. */
|
|
29
|
+
formFields: z.record(z.string(), z.string()),
|
|
30
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
31
|
+
expiresAt: z.number().int(),
|
|
32
|
+
}),
|
|
33
|
+
z.object({
|
|
34
|
+
method: z.literal("PUT"),
|
|
35
|
+
uploadUrl: z.url(),
|
|
36
|
+
/** Sent exactly as given, each one signed into the URL. */
|
|
37
|
+
headers: z.record(z.string(), z.string()),
|
|
38
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
39
|
+
expiresAt: z.number().int(),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
1
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
export type LambderMemoryUploadBucketOptions = {
|
|
3
3
|
/**
|
|
4
4
|
* The URL tickets and download links point under, which a mock's MSW
|
|
@@ -14,6 +14,8 @@ export type LambderMemoryUploadBucketOptions = {
|
|
|
14
14
|
downloadLifetimeSeconds?: number;
|
|
15
15
|
/** The clock tickets and links expire by, injectable so a test can move past an expiry without waiting. Default: Date.now. */
|
|
16
16
|
now?: () => number;
|
|
17
|
+
/** How the browser sends a file, as LambderS3UploadBucket's option: a `POST` form or a `PUT`. Default: `POST`. */
|
|
18
|
+
uploadMethod?: LambderUploadMethod;
|
|
17
19
|
};
|
|
18
20
|
/** What the memory bucket holds under a key, for a test to assert on: the object's facts and what it carries. */
|
|
19
21
|
export type LambderMemoryUploadObject = {
|
|
@@ -34,7 +36,11 @@ export type LambderMemoryUploadObject = {
|
|
|
34
36
|
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
35
37
|
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
36
38
|
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
37
|
-
*
|
|
39
|
+
* With `uploadMethod: "PUT"` it holds a PUT to the rules a store holds a
|
|
40
|
+
* presigned PUT to: every header the ticket carries with its value, no other
|
|
41
|
+
* `x-amz-` header, a body of the signed length (SignatureDoesNotMatch
|
|
42
|
+
* otherwise), a URL not yet expired ("Request has expired"), and bytes with
|
|
43
|
+
* the SHA-256 (BadDigest). A download link reads the object until it expires.
|
|
38
44
|
*
|
|
39
45
|
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
40
46
|
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
@@ -47,11 +53,12 @@ export declare class LambderMemoryUploadBucket implements LambderUploadBucket {
|
|
|
47
53
|
readonly baseUrl: string;
|
|
48
54
|
private readonly ticketLifetimeSeconds;
|
|
49
55
|
private readonly downloadLifetimeSeconds;
|
|
56
|
+
private readonly uploadMethod;
|
|
50
57
|
private readonly now;
|
|
51
58
|
private readonly objects;
|
|
52
59
|
private readonly tickets;
|
|
53
60
|
private readonly links;
|
|
54
|
-
constructor({ baseUrl, ticketLifetimeSeconds, downloadLifetimeSeconds, now }?: LambderMemoryUploadBucketOptions);
|
|
61
|
+
constructor({ baseUrl, ticketLifetimeSeconds, downloadLifetimeSeconds, now, uploadMethod }?: LambderMemoryUploadBucketOptions);
|
|
55
62
|
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
56
63
|
objectKey: string;
|
|
57
64
|
fileFacts: LambderUploadFileFacts;
|
|
@@ -88,12 +95,19 @@ export declare class LambderMemoryUploadBucket implements LambderUploadBucket {
|
|
|
88
95
|
/** Forgets every object, ticket and link. */
|
|
89
96
|
reset(): void;
|
|
90
97
|
/**
|
|
91
|
-
* Answers a request to storage the way S3 answers it: a post
|
|
92
|
-
* ticket stores its file, a GET or HEAD through a download link
|
|
93
|
-
* object. A request outside baseUrl answers null, for the caller
|
|
94
|
-
* on.
|
|
98
|
+
* Answers a request to storage the way S3 answers it: a post or a PUT
|
|
99
|
+
* under a ticket stores its file, a GET or HEAD through a download link
|
|
100
|
+
* reads an object. A request outside baseUrl answers null, for the caller
|
|
101
|
+
* to hand on.
|
|
95
102
|
*/
|
|
96
103
|
handleStorageRequest(request: Request): Promise<Response | null>;
|
|
97
104
|
private acceptUpload;
|
|
105
|
+
/**
|
|
106
|
+
* A PUT under a ticket's signed URL. A header the signature covers with
|
|
107
|
+
* another value, or a body of another length, does not match the
|
|
108
|
+
* signature; an `x-amz-` header it does not cover is refused as a store
|
|
109
|
+
* refuses one; the checksum is held against the bytes.
|
|
110
|
+
*/
|
|
111
|
+
private acceptPut;
|
|
98
112
|
private serveDownload;
|
|
99
113
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
3
|
import { escapeXmlText } from "../shared/util/escapeXmlText.js";
|
|
4
|
-
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
|
+
import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
|
|
5
5
|
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
6
6
|
import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
|
|
7
|
-
/** The form field that names the ticket
|
|
7
|
+
/** The form field, or for a PUT the query parameter, that names the ticket an upload was signed with: the memory bucket's stand-in for S3's signature. */
|
|
8
8
|
const TICKET_FIELD = "x-lambder-upload-ticket";
|
|
9
9
|
/** The query parameter that names the link a download was issued with: the stand-in for S3's presigned query string. */
|
|
10
10
|
const LINK_PARAMETER = "x-lambder-download-link";
|
|
@@ -21,7 +21,11 @@ const LINK_PARAMETER = "x-lambder-download-link";
|
|
|
21
21
|
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
22
22
|
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
23
23
|
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
24
|
-
*
|
|
24
|
+
* With `uploadMethod: "PUT"` it holds a PUT to the rules a store holds a
|
|
25
|
+
* presigned PUT to: every header the ticket carries with its value, no other
|
|
26
|
+
* `x-amz-` header, a body of the signed length (SignatureDoesNotMatch
|
|
27
|
+
* otherwise), a URL not yet expired ("Request has expired"), and bytes with
|
|
28
|
+
* the SHA-256 (BadDigest). A download link reads the object until it expires.
|
|
25
29
|
*
|
|
26
30
|
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
27
31
|
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
@@ -34,11 +38,12 @@ export class LambderMemoryUploadBucket {
|
|
|
34
38
|
baseUrl;
|
|
35
39
|
ticketLifetimeSeconds;
|
|
36
40
|
downloadLifetimeSeconds;
|
|
41
|
+
uploadMethod;
|
|
37
42
|
now;
|
|
38
43
|
objects = new Map();
|
|
39
44
|
tickets = new Map();
|
|
40
45
|
links = new Map();
|
|
41
|
-
constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now } = {}) {
|
|
46
|
+
constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now, uploadMethod = "POST" } = {}) {
|
|
42
47
|
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
43
48
|
assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
|
|
44
49
|
const url = new URL(baseUrl ?? `https://upload-bucket-${crypto.randomUUID()}.invalid/`);
|
|
@@ -47,6 +52,7 @@ export class LambderMemoryUploadBucket {
|
|
|
47
52
|
this.baseUrl = url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
48
53
|
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
49
54
|
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
55
|
+
this.uploadMethod = uploadMethod;
|
|
50
56
|
this.now = now;
|
|
51
57
|
}
|
|
52
58
|
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object = {} }) {
|
|
@@ -56,6 +62,19 @@ export class LambderMemoryUploadBucket {
|
|
|
56
62
|
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
57
63
|
const ticketId = crypto.randomUUID();
|
|
58
64
|
const expiresAt = this.now() + lifetimeSeconds * 1000;
|
|
65
|
+
const issued = { objectKey, mimeType: fileFacts.mimeType, byteSize: fileFacts.byteSize, sha256Base64: fileFacts.sha256Base64, expiresAt, object };
|
|
66
|
+
if (this.uploadMethod === "PUT") {
|
|
67
|
+
// The headers a presigned PUT signs, so a client sends the same ones to either.
|
|
68
|
+
const headers = {
|
|
69
|
+
"content-type": fileFacts.mimeType,
|
|
70
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
71
|
+
...uploadObjectHeaders(object),
|
|
72
|
+
};
|
|
73
|
+
this.tickets.set(ticketId, { ...issued, pinned: headers });
|
|
74
|
+
const uploadUrl = new URL(`${this.baseUrl}${encodeObjectKey(objectKey)}`);
|
|
75
|
+
uploadUrl.searchParams.set(TICKET_FIELD, ticketId);
|
|
76
|
+
return { method: "PUT", uploadUrl: uploadUrl.href, headers, expiresAt };
|
|
77
|
+
}
|
|
59
78
|
// The fields S3's ticket carries, so a client posts the same form to either.
|
|
60
79
|
const formFields = {
|
|
61
80
|
key: objectKey,
|
|
@@ -65,8 +84,8 @@ export class LambderMemoryUploadBucket {
|
|
|
65
84
|
...uploadObjectFormFields(object),
|
|
66
85
|
[TICKET_FIELD]: ticketId,
|
|
67
86
|
};
|
|
68
|
-
this.tickets.set(ticketId, {
|
|
69
|
-
return { uploadUrl: this.baseUrl, formFields, expiresAt };
|
|
87
|
+
this.tickets.set(ticketId, { ...issued, pinned: formFields });
|
|
88
|
+
return { method: "POST", uploadUrl: this.baseUrl, formFields, expiresAt };
|
|
70
89
|
}
|
|
71
90
|
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
72
91
|
const object = this.objects.get(objectKey);
|
|
@@ -125,10 +144,10 @@ export class LambderMemoryUploadBucket {
|
|
|
125
144
|
this.links.clear();
|
|
126
145
|
}
|
|
127
146
|
/**
|
|
128
|
-
* Answers a request to storage the way S3 answers it: a post
|
|
129
|
-
* ticket stores its file, a GET or HEAD through a download link
|
|
130
|
-
* object. A request outside baseUrl answers null, for the caller
|
|
131
|
-
* on.
|
|
147
|
+
* Answers a request to storage the way S3 answers it: a post or a PUT
|
|
148
|
+
* under a ticket stores its file, a GET or HEAD through a download link
|
|
149
|
+
* reads an object. A request outside baseUrl answers null, for the caller
|
|
150
|
+
* to hand on.
|
|
132
151
|
*/
|
|
133
152
|
async handleStorageRequest(request) {
|
|
134
153
|
const url = new URL(request.url);
|
|
@@ -143,6 +162,8 @@ export class LambderMemoryUploadBucket {
|
|
|
143
162
|
}
|
|
144
163
|
if (request.method === "POST" && objectKey === "")
|
|
145
164
|
return this.acceptUpload(request);
|
|
165
|
+
if (request.method === "PUT")
|
|
166
|
+
return this.acceptPut(request, objectKey, url.searchParams.get(TICKET_FIELD));
|
|
146
167
|
if (request.method === "GET" || request.method === "HEAD")
|
|
147
168
|
return this.serveDownload(objectKey, url.searchParams.get(LINK_PARAMETER), request.method === "HEAD");
|
|
148
169
|
return storageError(405, "MethodNotAllowed", "The specified method is not allowed against this resource.");
|
|
@@ -176,10 +197,10 @@ export class LambderMemoryUploadBucket {
|
|
|
176
197
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
177
198
|
if (this.now() >= ticket.expiresAt)
|
|
178
199
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy expired.");
|
|
179
|
-
const extra = [...fields.keys()].filter((name) => !(name in ticket.
|
|
200
|
+
const extra = [...fields.keys()].filter((name) => !(name in ticket.pinned));
|
|
180
201
|
if (extra.length)
|
|
181
202
|
return storageError(403, "AccessDenied", `Invalid according to Policy: Extra input fields: ${extra.join(", ")}`);
|
|
182
|
-
const pinned = Object.entries(ticket.
|
|
203
|
+
const pinned = Object.entries(ticket.pinned).every(([name, value]) => fields.get(name) === value);
|
|
183
204
|
if (!pinned)
|
|
184
205
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
185
206
|
const body = new Uint8Array(await file.arrayBuffer());
|
|
@@ -193,6 +214,33 @@ export class LambderMemoryUploadBucket {
|
|
|
193
214
|
this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
|
|
194
215
|
return new Response(null, { status: 204 });
|
|
195
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* A PUT under a ticket's signed URL. A header the signature covers with
|
|
219
|
+
* another value, or a body of another length, does not match the
|
|
220
|
+
* signature; an `x-amz-` header it does not cover is refused as a store
|
|
221
|
+
* refuses one; the checksum is held against the bytes.
|
|
222
|
+
*/
|
|
223
|
+
async acceptPut(request, objectKey, ticketId) {
|
|
224
|
+
const ticket = ticketId === null ? undefined : this.tickets.get(ticketId);
|
|
225
|
+
const mismatch = () => storageError(403, "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your key and signing method.");
|
|
226
|
+
if (!ticket || ticket.objectKey !== objectKey)
|
|
227
|
+
return mismatch();
|
|
228
|
+
if (this.now() >= ticket.expiresAt)
|
|
229
|
+
return storageError(403, "AccessDenied", "Request has expired");
|
|
230
|
+
const unsigned = [...request.headers.keys()].filter((name) => name.startsWith("x-amz-") && !(name in ticket.pinned));
|
|
231
|
+
if (unsigned.length)
|
|
232
|
+
return storageError(403, "AccessDenied", `There were headers present in the request which were not signed: ${unsigned.join(", ")}`);
|
|
233
|
+
if (!Object.entries(ticket.pinned).every(([name, value]) => request.headers.get(name) === value))
|
|
234
|
+
return mismatch();
|
|
235
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
236
|
+
if (body.byteLength !== ticket.byteSize)
|
|
237
|
+
return mismatch();
|
|
238
|
+
const sha256Base64 = await sha256Base64Of(body);
|
|
239
|
+
if (sha256Base64 !== ticket.sha256Base64)
|
|
240
|
+
return storageError(400, "BadDigest", "The SHA256 you specified did not match the calculated checksum.");
|
|
241
|
+
this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
|
|
242
|
+
return new Response(null, { status: 200 });
|
|
243
|
+
}
|
|
196
244
|
serveDownload(objectKey, linkId, headOnly) {
|
|
197
245
|
const link = linkId === null ? undefined : this.links.get(linkId);
|
|
198
246
|
if (!link || link.objectKey !== objectKey)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
3
3
|
export type LambderS3UploadBucketOptions = {
|
|
4
4
|
bucket: string;
|
|
5
5
|
/** A ready client, e.g. one shared with the rest of the app. */
|
|
@@ -10,6 +10,14 @@ export type LambderS3UploadBucketOptions = {
|
|
|
10
10
|
ticketLifetimeSeconds?: number;
|
|
11
11
|
/** How long a download link reads the object, unless a link says otherwise. Default: 300 seconds. */
|
|
12
12
|
downloadLifetimeSeconds?: number;
|
|
13
|
+
/**
|
|
14
|
+
* How the browser sends a file: `POST`, a presigned POST, which S3 takes;
|
|
15
|
+
* or `PUT`, a presigned PUT, for a store that has no POST policies
|
|
16
|
+
* (Cloudflare R2). Both pin the same facts. Default: `POST`. The bucket
|
|
17
|
+
* cannot tell S3 from another store behind an endpoint, so a store that
|
|
18
|
+
* refuses POST is named here.
|
|
19
|
+
*/
|
|
20
|
+
uploadMethod?: LambderUploadMethod;
|
|
13
21
|
};
|
|
14
22
|
/**
|
|
15
23
|
* An S3 bucket browsers upload to directly (see LambderUploadBucket).
|
|
@@ -17,7 +25,10 @@ export type LambderS3UploadBucketOptions = {
|
|
|
17
25
|
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
18
26
|
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
19
27
|
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
20
|
-
* a store that implements them
|
|
28
|
+
* a store that implements them. A store without them (Cloudflare R2) takes
|
|
29
|
+
* `uploadMethod: "PUT"`: a presigned PUT whose signature covers the same
|
|
30
|
+
* facts as headers (the length, the type and the checksum), so the store
|
|
31
|
+
* refuses any other file just the same.
|
|
21
32
|
*
|
|
22
33
|
* Signing a ticket or a download link is arithmetic over the function's
|
|
23
34
|
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
@@ -32,12 +43,13 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
|
32
43
|
private readonly bucket;
|
|
33
44
|
private readonly ticketLifetimeSeconds;
|
|
34
45
|
private readonly downloadLifetimeSeconds;
|
|
46
|
+
private readonly uploadMethod;
|
|
35
47
|
private readonly clientConfig;
|
|
36
48
|
private client;
|
|
37
49
|
private clientSdk;
|
|
38
50
|
private presignedPostSdk;
|
|
39
51
|
private requestPresignerSdk;
|
|
40
|
-
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds }: LambderS3UploadBucketOptions);
|
|
52
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds, uploadMethod }: LambderS3UploadBucketOptions);
|
|
41
53
|
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
42
54
|
objectKey: string;
|
|
43
55
|
fileFacts: LambderUploadFileFacts;
|
|
@@ -45,6 +57,13 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
|
45
57
|
lifetimeSeconds?: number;
|
|
46
58
|
object?: LambderUploadObjectOptions;
|
|
47
59
|
}): Promise<LambderUploadTicket>;
|
|
60
|
+
/**
|
|
61
|
+
* A presigned PUT. Every header the ticket hands the browser is signed
|
|
62
|
+
* into the URL, and so is the length, which the browser sets from the
|
|
63
|
+
* body itself; the `x-amz-` ones are kept as headers rather than moved
|
|
64
|
+
* into the query, so the store checks the body against the checksum.
|
|
65
|
+
*/
|
|
66
|
+
private issuePutTicket;
|
|
48
67
|
verifyUploadedObject({ objectKey, fileFacts }: {
|
|
49
68
|
objectKey: string;
|
|
50
69
|
fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
|
-
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
3
|
+
import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
4
|
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
5
5
|
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
6
6
|
/** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
|
|
@@ -11,7 +11,10 @@ const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
|
|
|
11
11
|
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
12
12
|
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
13
13
|
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
14
|
-
* a store that implements them
|
|
14
|
+
* a store that implements them. A store without them (Cloudflare R2) takes
|
|
15
|
+
* `uploadMethod: "PUT"`: a presigned PUT whose signature covers the same
|
|
16
|
+
* facts as headers (the length, the type and the checksum), so the store
|
|
17
|
+
* refuses any other file just the same.
|
|
15
18
|
*
|
|
16
19
|
* Signing a ticket or a download link is arithmetic over the function's
|
|
17
20
|
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
@@ -26,12 +29,13 @@ export class LambderS3UploadBucket {
|
|
|
26
29
|
bucket;
|
|
27
30
|
ticketLifetimeSeconds;
|
|
28
31
|
downloadLifetimeSeconds;
|
|
32
|
+
uploadMethod;
|
|
29
33
|
clientConfig;
|
|
30
34
|
client;
|
|
31
35
|
clientSdk;
|
|
32
36
|
presignedPostSdk;
|
|
33
37
|
requestPresignerSdk;
|
|
34
|
-
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300 }) {
|
|
38
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, uploadMethod = "POST" }) {
|
|
35
39
|
if (!bucket.trim())
|
|
36
40
|
throw new Error("bucket is required");
|
|
37
41
|
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
@@ -41,12 +45,15 @@ export class LambderS3UploadBucket {
|
|
|
41
45
|
this.clientConfig = clientConfig;
|
|
42
46
|
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
43
47
|
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
48
|
+
this.uploadMethod = uploadMethod;
|
|
44
49
|
}
|
|
45
50
|
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
|
|
46
51
|
assertPinnedObjectKey(objectKey);
|
|
47
52
|
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
48
53
|
assertObjectOptions(object);
|
|
49
54
|
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
55
|
+
if (this.uploadMethod === "PUT")
|
|
56
|
+
return this.issuePutTicket(objectKey, fileFacts, lifetimeSeconds, object);
|
|
50
57
|
const [{ client }, { createPresignedPost }] = await Promise.all([this.s3(), this.loadPresignedPostSdk()]);
|
|
51
58
|
const post = await createPresignedPost(client, {
|
|
52
59
|
Bucket: this.bucket,
|
|
@@ -61,7 +68,47 @@ export class LambderS3UploadBucket {
|
|
|
61
68
|
},
|
|
62
69
|
Conditions: [["content-length-range", fileFacts.byteSize, fileFacts.byteSize]],
|
|
63
70
|
});
|
|
64
|
-
return { uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
71
|
+
return { method: "POST", uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A presigned PUT. Every header the ticket hands the browser is signed
|
|
75
|
+
* into the URL, and so is the length, which the browser sets from the
|
|
76
|
+
* body itself; the `x-amz-` ones are kept as headers rather than moved
|
|
77
|
+
* into the query, so the store checks the body against the checksum.
|
|
78
|
+
*/
|
|
79
|
+
async issuePutTicket(objectKey, fileFacts, lifetimeSeconds, object) {
|
|
80
|
+
const headers = {
|
|
81
|
+
"content-type": fileFacts.mimeType,
|
|
82
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
83
|
+
...uploadObjectHeaders(object),
|
|
84
|
+
};
|
|
85
|
+
const [{ sdk, client }, { getSignedUrl }] = await Promise.all([this.s3(), this.loadRequestPresignerSdk()]);
|
|
86
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
87
|
+
const metadata = Object.entries(object?.metadata ?? {});
|
|
88
|
+
const uploadUrl = await getSignedUrl(client, new sdk.PutObjectCommand({
|
|
89
|
+
Bucket: this.bucket,
|
|
90
|
+
Key: objectKey,
|
|
91
|
+
ContentType: fileFacts.mimeType,
|
|
92
|
+
ContentLength: fileFacts.byteSize,
|
|
93
|
+
ChecksumSHA256: fileFacts.sha256Base64,
|
|
94
|
+
...(tags.length ? { Tagging: new URLSearchParams(tags).toString() } : {}),
|
|
95
|
+
...(metadata.length ? { Metadata: Object.fromEntries(metadata.map(([name, value]) => [name.toLowerCase(), value])) } : {}),
|
|
96
|
+
...(object?.cacheControl !== undefined ? { CacheControl: object.cacheControl } : {}),
|
|
97
|
+
...(object?.contentDisposition ? { ContentDisposition: contentDispositionHeader(object.contentDisposition) } : {}),
|
|
98
|
+
}), {
|
|
99
|
+
expiresIn: lifetimeSeconds,
|
|
100
|
+
signableHeaders: new Set([...Object.keys(headers), "content-length"]),
|
|
101
|
+
unhoistableHeaders: new Set(Object.keys(headers).filter((name) => name.startsWith("x-amz-"))),
|
|
102
|
+
});
|
|
103
|
+
// What the URL is signed for must be what the ticket sends, or the
|
|
104
|
+
// store refuses every upload with it: said here, where it is written,
|
|
105
|
+
// rather than as a refused upload in a browser.
|
|
106
|
+
const signed = (new URL(uploadUrl).searchParams.get("X-Amz-SignedHeaders") ?? "").split(";").filter((name) => name !== "host" && name !== "content-length");
|
|
107
|
+
const sent = Object.keys(headers);
|
|
108
|
+
if (signed.length !== sent.length || !sent.every((name) => signed.includes(name))) {
|
|
109
|
+
throw new Error(`LambderS3UploadBucket: a PUT ticket's URL is signed for [${signed.join(", ")}] but the ticket sends [${sent.join(", ")}]`);
|
|
110
|
+
}
|
|
111
|
+
return { method: "PUT", uploadUrl, headers, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
65
112
|
}
|
|
66
113
|
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
67
114
|
const { sdk, client } = await this.s3();
|
|
@@ -51,7 +51,7 @@ export type LambderTestAppOptions = {
|
|
|
51
51
|
* (`lambderTestApp<SessionData, ApiContractType>(lambder)`), so the tests type
|
|
52
52
|
* their calls against plain members rather than the chained intersection.
|
|
53
53
|
*/
|
|
54
|
-
export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
|
|
54
|
+
export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any, any, any> & {
|
|
55
55
|
readonly ApiContract: TContract;
|
|
56
56
|
};
|
|
57
57
|
/**
|
package/dist/testing.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
export { lambderTestApp } from "./testing/LambderTestApp.js";
|
|
18
18
|
export type { LambderTestApp, LambderTestAppOptions, LambderTestedInstance } from "./testing/LambderTestApp.js";
|
|
19
19
|
export type { LambderTestVisitor, LambderTestVisitorOptions, LambderTestRequestInit } from "./testing/LambderTestVisitor.js";
|
|
20
|
-
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
20
|
+
export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
21
21
|
export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
22
22
|
export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreConformance.js";
|
|
23
23
|
export type { LambderSessionStoreConformanceOptions } from "./testing/lambderSessionStoreConformance.js";
|
package/dist/testing.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* writes itself.
|
|
16
16
|
*/
|
|
17
17
|
export { lambderTestApp } from "./testing/LambderTestApp.js";
|
|
18
|
-
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
18
|
+
export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
19
19
|
// The rules each store interface promises, as cases an app registers with its
|
|
20
20
|
// own runner's `it` and `expect` to hold a store it writes over its own
|
|
21
21
|
// database to the same rules Lambder's memory and DynamoDB stores meet.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
6
6
|
"keywords": [
|