lambder 8.0.2 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +7 -2
  3. package/dist/build/ContractTypePrinter.d.ts +85 -0
  4. package/dist/build/ContractTypePrinter.js +402 -0
  5. package/dist/build/moduleLocation.d.ts +11 -0
  6. package/dist/build/moduleLocation.js +6 -0
  7. package/dist/build/writeApiContract.d.ts +78 -0
  8. package/dist/build/writeApiContract.js +302 -0
  9. package/dist/build/writeApiSignatures.d.ts +32 -27
  10. package/dist/build/writeApiSignatures.js +37 -42
  11. package/dist/build/writeFileAtomically.d.ts +8 -0
  12. package/dist/build/writeFileAtomically.js +22 -0
  13. package/dist/build.d.ts +8 -3
  14. package/dist/build.js +6 -3
  15. package/dist/client/LambderUploadRunner.d.ts +96 -0
  16. package/dist/client/LambderUploadRunner.js +234 -0
  17. package/dist/client.d.ts +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +9 -10
  20. package/dist/core/Lambder.js +9 -10
  21. package/dist/index.d.ts +11 -1
  22. package/dist/index.js +8 -0
  23. package/dist/mock/lambderMockMswHandler.d.ts +10 -4
  24. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  25. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  26. package/dist/mock.d.ts +3 -0
  27. package/dist/mock.js +4 -0
  28. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  29. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  30. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  31. package/dist/shared/util/LambderContentDisposition.js +13 -0
  32. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  33. package/dist/shared/util/LambderTextDigest.js +11 -5
  34. package/dist/shared/wire/LambderApiContract.d.ts +10 -40
  35. package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
  36. package/dist/shared/wire/LambderApiRefusal.js +6 -0
  37. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  38. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  39. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  40. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  41. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  42. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  43. package/dist/stores/LambderDdbSdk.js +1 -5
  44. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  45. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  46. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  47. package/dist/stores/LambderS3UploadBucket.js +144 -0
  48. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  49. package/dist/stores/LambderSdkInstallHint.js +14 -0
  50. package/dist/testing/LambderTestApp.d.ts +4 -4
  51. package/dist/testing.d.ts +2 -0
  52. package/dist/testing.js +1 -0
  53. package/package.json +15 -1
@@ -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" } });
@@ -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
+ });
@@ -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 lets a large app name its
50
- * flattened contract interface explicitly
51
- * (`lambderTestApp<SessionData, ApiContractType>(lambder)`) and keep the
52
- * cheap type check that interface exists for.
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;
package/dist/testing.d.ts CHANGED
@@ -19,6 +19,8 @@ export { LambderMemorySessionStore } from "./stores/LambderMemorySessionStore.js
19
19
  export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
20
20
  export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
21
21
  export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
22
+ export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
23
+ export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
22
24
  export { LambderLocalFileSource } from "./stores/LambderLocalFileSource.js";
23
25
  export { LambderCookieJar } from "./shared/transport/LambderCookieJar.js";
24
26
  export { LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
package/dist/testing.js CHANGED
@@ -19,6 +19,7 @@ export { LambderMemorySessionStore } from "./stores/LambderMemorySessionStore.js
19
19
  export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
20
20
  export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
21
21
  export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
22
+ export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
22
23
  export { LambderLocalFileSource } from "./stores/LambderLocalFileSource.js";
23
24
  export { LambderCookieJar } from "./shared/transport/LambderCookieJar.js";
24
25
  export { LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "8.0.2",
3
+ "version": "8.1.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": [
@@ -114,7 +114,10 @@
114
114
  "@aws-sdk/client-lambda": "^3.574.0",
115
115
  "@aws-sdk/client-s3": "^3.574.0",
116
116
  "@aws-sdk/lib-dynamodb": "^3.574.0",
117
+ "@aws-sdk/s3-presigned-post": "^3.574.0",
118
+ "@aws-sdk/s3-request-presigner": "^3.574.0",
117
119
  "msw": "^2.0.0",
120
+ "typescript": ">=5.4",
118
121
  "zod": "^4.6.2"
119
122
  },
120
123
  "peerDependenciesMeta": {
@@ -130,8 +133,17 @@
130
133
  "@aws-sdk/lib-dynamodb": {
131
134
  "optional": true
132
135
  },
136
+ "@aws-sdk/s3-presigned-post": {
137
+ "optional": true
138
+ },
139
+ "@aws-sdk/s3-request-presigner": {
140
+ "optional": true
141
+ },
133
142
  "msw": {
134
143
  "optional": true
144
+ },
145
+ "typescript": {
146
+ "optional": true
135
147
  }
136
148
  },
137
149
  "devDependencies": {
@@ -139,6 +151,8 @@
139
151
  "@aws-sdk/client-lambda": "^3.1131.0",
140
152
  "@aws-sdk/client-s3": "^3.1127.0",
141
153
  "@aws-sdk/lib-dynamodb": "^3.913.0",
154
+ "@aws-sdk/s3-presigned-post": "^3.1141.0",
155
+ "@aws-sdk/s3-request-presigner": "^3.1141.0",
142
156
  "@types/js-cookie": "^3.0.6",
143
157
  "@types/mime-types": "^2.1.4",
144
158
  "@types/node": "^24.9.0",