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.
Files changed (84) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +29 -14
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client/LambderUploadRunner.d.ts +5 -5
  23. package/dist/client/LambderUploadRunner.js +25 -16
  24. package/dist/client.d.ts +6 -5
  25. package/dist/client.js +2 -1
  26. package/dist/core/Lambder.d.ts +121 -23
  27. package/dist/core/Lambder.js +105 -51
  28. package/dist/core/LambderContext.d.ts +1 -1
  29. package/dist/core/LambderContext.js +5 -0
  30. package/dist/core/LambderCreateOptions.d.ts +58 -15
  31. package/dist/core/LambderCreateOptions.js +10 -0
  32. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  33. package/dist/core/LambderPolicyBuilders.js +10 -8
  34. package/dist/core/LambderResolver.d.ts +1 -1
  35. package/dist/core/LambderResolver.js +1 -1
  36. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  37. package/dist/core/LambderResponseBuilder.js +12 -11
  38. package/dist/index.d.ts +13 -9
  39. package/dist/index.js +6 -2
  40. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  41. package/dist/invoke/LambderInvokeCaller.js +13 -12
  42. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  43. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  44. package/dist/mock/LambderMockApp.d.ts +86 -24
  45. package/dist/mock/LambderMockApp.js +108 -16
  46. package/dist/mock/LambderMockCallRecorder.js +6 -3
  47. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  48. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  49. package/dist/mock/LambderMockFailureInjector.js +18 -4
  50. package/dist/mock/LambderMockTypes.d.ts +34 -11
  51. package/dist/mock.d.ts +4 -3
  52. package/dist/mock.js +4 -3
  53. package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
  54. package/dist/shared/contracts/LambderUploadBucket.js +2 -2
  55. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  56. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  57. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  58. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  59. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  60. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  61. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  62. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  63. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  64. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  65. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  66. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  67. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  68. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  69. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  70. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  71. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  72. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  73. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
  74. package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
  75. package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
  76. package/dist/shared/wire/LambderUploadSchemas.js +18 -7
  77. package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
  78. package/dist/stores/LambderMemoryUploadBucket.js +60 -12
  79. package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
  80. package/dist/stores/LambderS3UploadBucket.js +51 -4
  81. package/dist/testing/LambderTestApp.d.ts +1 -1
  82. package/dist/testing.d.ts +1 -1
  83. package/dist/testing.js +1 -1
  84. 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.object({
25
- uploadUrl: z.url(),
26
- /** Sent as form fields ahead of the file, which storage wants last. */
27
- formFields: z.record(z.string(), z.string()),
28
- /** Epoch milliseconds after which storage refuses the ticket. */
29
- expiresAt: z.number().int(),
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
- * A download link reads the object until it expires.
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 under a
92
- * ticket stores its file, a GET or HEAD through a download link reads an
93
- * object. A request outside baseUrl answers null, for the caller to hand
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 a post was signed with: the memory bucket's stand-in for S3's signed policy. */
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
- * A download link reads the object until it expires.
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, { objectKey, mimeType: fileFacts.mimeType, byteSize: fileFacts.byteSize, sha256Base64: fileFacts.sha256Base64, expiresAt, formFields, object });
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 under a
129
- * ticket stores its file, a GET or HEAD through a download link reads an
130
- * object. A request outside baseUrl answers null, for the caller to hand
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.formFields));
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.formFields).every(([name, value]) => fields.get(name) === value);
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; Cloudflare R2 does not take presigned POSTs.
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; Cloudflare R2 does not take presigned POSTs.
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": "9.0.2",
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": [