lambder 9.0.2 → 9.0.3

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/README.md CHANGED
@@ -69,8 +69,9 @@ const company = await caller.api("getCompany", { slug: "acme" });
69
69
  - **Frontend hosting.** Serve a build from a folder, S3, R2 or any HTTP
70
70
  origin, with an app shell rendered through a build-pipeline-safe template
71
71
  engine.
72
- - **Direct uploads.** Files go from the browser straight to S3 on tickets
73
- that pin their size, type and SHA-256, with a browser runner that hashes,
72
+ - **Direct uploads.** Files go from the browser straight to S3, or to R2 on
73
+ presigned PUTs, on tickets that pin their size, type and SHA-256, with a
74
+ browser runner that hashes,
74
75
  retries and reports progress, and a memory bucket that holds tests and the
75
76
  mock to the same rules.
76
77
  - **Runs anywhere Lambda does.** API Gateway REST APIs (payload v1), HTTP APIs
@@ -163,7 +164,7 @@ guide that matches what you are building. The full index lives in
163
164
  | [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
164
165
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
165
166
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
166
- | [Direct uploads](./docs/uploads.md) | Files the browser posts straight to S3 with tickets the server signs, verified before they count |
167
+ | [Direct uploads](./docs/uploads.md) | Files the browser sends straight to S3 or R2 with tickets the server signs, verified before they count |
167
168
  | [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
168
169
  | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
169
170
  | [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
@@ -60,9 +60,9 @@ export type LambderUploadRunnerOptions<Reference, Receipt> = {
60
60
  maxDelayMs?: number;
61
61
  };
62
62
  /**
63
- * How long a post may be open and move nothing before it counts as
64
- * dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
65
- * which reports a body's progress; a runtime with only fetch posts
63
+ * How long an upload to storage may be open and move nothing before it
64
+ * counts as dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
65
+ * which reports a body's progress; a runtime with only fetch uploads
66
66
  * unwatched.
67
67
  */
68
68
  stallTimeoutMs?: number;
@@ -91,6 +91,6 @@ export declare class LambderUploadRunner<Reference, Receipt> {
91
91
  }): Promise<Receipt>;
92
92
  /** Forgets a confirmed upload through the app's endpoint, when it declared one. */
93
93
  discard(receipt: Receipt): Promise<void>;
94
- /** One post of the file to storage. Never throws: every ending is an outcome. */
95
- private post;
94
+ /** One upload of the file to storage, in the ticket's form. Never throws: every ending is an outcome. */
95
+ private send;
96
96
  }
@@ -90,7 +90,7 @@ export class LambderUploadRunner {
90
90
  for (;;) {
91
91
  stopIfCancelled();
92
92
  report("uploading");
93
- const outcome = await this.post(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
93
+ const outcome = await this.send(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
94
94
  if (outcome.kind === "stored")
95
95
  break;
96
96
  if (outcome.kind === "cancelled")
@@ -124,22 +124,29 @@ export class LambderUploadRunner {
124
124
  async discard(receipt) {
125
125
  await this.options.discardUpload?.(receipt);
126
126
  }
127
- /** One post of the file to storage. Never throws: every ending is an outcome. */
128
- post(ticket, file, signal, onSent) {
127
+ /** One upload of the file to storage, in the ticket's form. Never throws: every ending is an outcome. */
128
+ send(ticket, file, signal, onSent) {
129
129
  if (signal?.aborted)
130
130
  return Promise.resolve({ kind: "cancelled" });
131
- const form = new FormData();
132
- for (const [name, value] of Object.entries(ticket.formFields))
133
- form.append(name, value);
134
- // Storage ignores every field that comes after the file.
135
- form.append("file", file);
131
+ let request;
132
+ if (ticket.method === "PUT") {
133
+ request = { method: "PUT", url: ticket.uploadUrl, body: file, headers: ticket.headers };
134
+ }
135
+ else {
136
+ const form = new FormData();
137
+ for (const [name, value] of Object.entries(ticket.formFields))
138
+ form.append(name, value);
139
+ // Storage ignores every field that comes after the file.
140
+ form.append("file", file);
141
+ request = { method: "POST", url: ticket.uploadUrl, body: form, headers: {} };
142
+ }
136
143
  return typeof XMLHttpRequest === "function"
137
- ? postWithXhr(ticket.uploadUrl, form, file.size, this.stallTimeoutMs, signal, onSent)
138
- : postWithFetch(ticket.uploadUrl, form, file.size, signal, onSent);
144
+ ? sendWithXhr(request, file.size, this.stallTimeoutMs, signal, onSent)
145
+ : sendWithFetch(request, file.size, signal, onSent);
139
146
  }
140
147
  }
141
148
  /** XMLHttpRequest rather than fetch where it exists: fetch cannot report how much of a request body has been sent. */
142
- const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
149
+ const sendWithXhr = ({ method, url, body, headers }, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
143
150
  const request = new XMLHttpRequest();
144
151
  let stalled = false;
145
152
  let stallTimer;
@@ -158,7 +165,7 @@ const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => ne
158
165
  };
159
166
  request.upload.onprogress = (event) => {
160
167
  watchForStall();
161
- // `loaded` counts the form's own framing too, a little over the file.
168
+ // A form's `loaded` counts its own framing too, a little over the file.
162
169
  onSent(Math.min(event.loaded, fileBytes));
163
170
  };
164
171
  request.onload = () => {
@@ -173,18 +180,20 @@ const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => ne
173
180
  signal?.addEventListener("abort", cancel, { once: true });
174
181
  watchForStall();
175
182
  try {
176
- request.open("POST", url);
177
- request.send(form);
183
+ request.open(method, url);
184
+ for (const [name, value] of Object.entries(headers))
185
+ request.setRequestHeader(name, value);
186
+ request.send(body);
178
187
  }
179
188
  catch {
180
189
  // A URL the browser will not open, or a request it will not send, answers nothing.
181
190
  settle({ kind: "unreachable" });
182
191
  }
183
192
  });
184
- const postWithFetch = async (url, form, fileBytes, signal, onSent) => {
193
+ const sendWithFetch = async ({ method, url, body, headers }, fileBytes, signal, onSent) => {
185
194
  let response;
186
195
  try {
187
- response = await fetch(url, { method: "POST", body: form, signal });
196
+ response = await fetch(url, { method, body, headers, signal });
188
197
  }
189
198
  catch {
190
199
  return signal?.aborted ? { kind: "cancelled" } : { kind: "unreachable" };
package/dist/client.d.ts CHANGED
@@ -46,4 +46,4 @@ export { constantTimeEquals } from "./shared/util/LambderTextDigest.js";
46
46
  export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
47
47
  export type { LambderUploadRunnerOptions, LambderUploadProgress, LambderUploadPhase, LambderUploadFailureReason } from "./client/LambderUploadRunner.js";
48
48
  export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
49
- export type { LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadRuleVerdict } from "./shared/contracts/LambderUploadBucket.js";
49
+ export type { LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadMethod, LambderUploadRuleVerdict } from "./shared/contracts/LambderUploadBucket.js";
package/dist/index.d.ts CHANGED
@@ -88,7 +88,7 @@ export type { LambderS3UploadBucketOptions } from "./stores/LambderS3UploadBucke
88
88
  export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
89
89
  export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
90
90
  export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
91
- export type { LambderUploadBucket, LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadVerdict, LambderUploadRuleVerdict, LambderUploadObjectOptions, LambderUploadContentDisposition, } from "./shared/contracts/LambderUploadBucket.js";
91
+ export type { LambderUploadBucket, LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadMethod, LambderUploadVerdict, LambderUploadRuleVerdict, LambderUploadObjectOptions, LambderUploadContentDisposition, } from "./shared/contracts/LambderUploadBucket.js";
92
92
  export { LambderUploadFileFactsSchema, LambderUploadTicketSchema } from "./shared/wire/LambderUploadSchemas.js";
93
93
  export { refuseUnacceptedUpload } from "./shared/wire/LambderUploadRefusal.js";
94
94
  export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
@@ -13,14 +13,29 @@ export type LambderUploadFileFacts = {
13
13
  /** SHA-256 of the file's bytes as base64, the form storage checks an upload against: 43 characters and one pad. */
14
14
  sha256Base64: string;
15
15
  };
16
- /** Everything the browser needs to post one file to storage, and until when. */
16
+ /**
17
+ * Everything the browser needs to send one file to storage, and until when,
18
+ * in one of the two forms a store signs. `POST` is a form (S3's presigned
19
+ * POST), `PUT` the file as the body of a signed URL (a presigned PUT, which
20
+ * Cloudflare R2 and other stores without POST policies take).
21
+ */
17
22
  export type LambderUploadTicket = {
23
+ method: "POST";
18
24
  uploadUrl: string;
19
25
  /** Sent as form fields ahead of the file, which storage wants last. */
20
26
  formFields: Record<string, string>;
21
27
  /** Epoch milliseconds after which storage refuses the ticket. */
22
28
  expiresAt: number;
29
+ } | {
30
+ method: "PUT";
31
+ uploadUrl: string;
32
+ /** Sent exactly as given, each one signed into the URL; the browser adds the length itself. */
33
+ headers: Record<string, string>;
34
+ /** Epoch milliseconds after which storage refuses the ticket. */
35
+ expiresAt: number;
23
36
  };
37
+ /** How a bucket's tickets send a file: the `method` of the tickets it signs. */
38
+ export type LambderUploadMethod = LambderUploadTicket["method"];
24
39
  /** What a bucket holds under a key, compared with what the browser said it would upload. */
25
40
  export type LambderUploadVerdict = {
26
41
  verified: true;
@@ -38,7 +53,7 @@ export type LambderUploadContentDisposition = {
38
53
  };
39
54
  /**
40
55
  * What storage keeps beside an object's bytes. A ticket pins every one of
41
- * these in its signed policy, so the browser posts them unchanged.
56
+ * these in its signature, so the browser sends them unchanged.
42
57
  */
43
58
  export type LambderUploadObjectOptions = {
44
59
  /**
@@ -91,7 +106,7 @@ export interface LambderUploadBucket {
91
106
  * Signs a ticket for exactly the file the browser described, or refuses
92
107
  * (a LambderApiRefusal, code `lambder/upload-empty`,
93
108
  * `lambder/upload-type-rejected` or `lambder/upload-too-large`) when the
94
- * rule does not accept it. Storage then enforces every fact: the post
109
+ * rule does not accept it. Storage then enforces every fact: the upload
95
110
  * fails unless the body has that byte size, that content type and that
96
111
  * SHA-256, so what verifies later is what was described here. `object`
97
112
  * is what the stored object carries besides, pinned the same way, and
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * An API payload tops out near a few megabytes once a file is base64, and a
6
6
  * Lambda's request body at six, so anything larger (a scanned lease, a
7
- * signed PDF, a video) is posted by the browser to the bucket itself, with a
7
+ * signed PDF, a video) is sent by the browser to the bucket itself, with a
8
8
  * ticket the server signed beforehand. The ticket pins everything about the
9
9
  * upload: the key, the exact byte size, the content type and the SHA-256 of
10
10
  * the bytes, all enforced by the storage, so the browser can only ever store
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * The conversation is the same three steps whatever the app stores: the
14
14
  * browser describes the file (LambderUploadFileFacts), the app's endpoint
15
- * answers with a ticket (LambderUploadTicket), and after the post the app's
15
+ * answers with a ticket (LambderUploadTicket), and after the upload the app's
16
16
  * confirm endpoint asks the bucket what arrived before its record counts as
17
17
  * uploaded. The server half is a LambderUploadBucket, the browser half is
18
18
  * LambderUploadRunner.
@@ -1,6 +1,6 @@
1
1
  import type { LambderUploadObjectOptions } from "../contracts/LambderUploadBucket.js";
2
2
  /**
3
- * What a ticket's form carries for the stored object, in the fields S3's
3
+ * What a POST ticket's form carries for the stored object, in the fields S3's
4
4
  * presigned POST reads them from: the tag set as the XML `tagging` field,
5
5
  * each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
6
6
  * `Cache-Control` and `Content-Disposition`. Both buckets build their
@@ -8,3 +8,12 @@ import type { LambderUploadObjectOptions } from "../contracts/LambderUploadBucke
8
8
  * every field is pinned by the ticket like the key and the checksum.
9
9
  */
10
10
  export declare const uploadObjectFormFields: (object: LambderUploadObjectOptions | undefined) => Record<string, string>;
11
+ /**
12
+ * What a PUT ticket's headers carry for the stored object, as a presigned
13
+ * PUT sends them: the tag set as the URL-encoded `x-amz-tagging`, each
14
+ * metadata entry as `x-amz-meta-<name>` (lowercased), `cache-control` and
15
+ * `content-disposition`. Names are lowercase, as a signature lists them. Both
16
+ * buckets build their PUT tickets' headers with this, and every one is signed
17
+ * into the URL like the checksum.
18
+ */
19
+ export declare const uploadObjectHeaders: (object: LambderUploadObjectOptions | undefined) => Record<string, string>;
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "9.0.2",
3
+ "version": "9.0.3",
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": [