@pithy-sh/cloudflare 0.1.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +48 -0
  4. package/src/ai/aiManager.ts +227 -0
  5. package/src/ai/vectorizeManager.ts +161 -0
  6. package/src/ai/vectorizeProvisioner.ts +266 -0
  7. package/src/client/accounts.ts +80 -0
  8. package/src/client/clients.ts +244 -0
  9. package/src/client/errors.ts +143 -0
  10. package/src/client/manager.ts +85 -0
  11. package/src/d1/d1Manager.ts +171 -0
  12. package/src/d1/d1PreparedStatement.ts +114 -0
  13. package/src/d1/d1Provisioner.ts +75 -0
  14. package/src/email/emailRoutingManager.ts +143 -0
  15. package/src/email/emailSendManager.ts +81 -0
  16. package/src/env/devVars.ts +90 -0
  17. package/src/hostnames/customHostnamesManager.ts +134 -0
  18. package/src/kv/kvManager.ts +202 -0
  19. package/src/kv/kvProvisioner.ts +80 -0
  20. package/src/media/assetSeeder.ts +87 -0
  21. package/src/media/imageManager.ts +125 -0
  22. package/src/media/ownership.ts +59 -0
  23. package/src/media/streamManager.ts +198 -0
  24. package/src/queue/queueManager.ts +185 -0
  25. package/src/r2/r2Credentials.ts +17 -0
  26. package/src/r2/r2Manager.ts +548 -0
  27. package/src/r2/r2Provisioner.ts +99 -0
  28. package/src/secrets/secretsStoreManager.ts +177 -0
  29. package/src/secrets/secretsStores.ts +75 -0
  30. package/src/test-utils/emailRoutingRules.ts +122 -0
  31. package/src/test-utils/fixtureReportSetup.ts +31 -0
  32. package/src/test-utils/fixtures.ts +372 -0
  33. package/src/test-utils/harness.ts +413 -0
  34. package/src/test-utils/inboundRecorder.ts +189 -0
  35. package/src/test-utils/integrationSetup.ts +46 -0
  36. package/src/test-utils/reap.ts +297 -0
  37. package/src/tokens/accountTokensManager.ts +334 -0
  38. package/src/tokens/permissions.ts +67 -0
  39. package/src/tokens/profiles.ts +238 -0
  40. package/src/turnstile/turnstileManager.ts +177 -0
  41. package/src/user/userManager.ts +73 -0
  42. package/src/workers/buildsManager.ts +348 -0
  43. package/src/workers/buildsTypes.ts +122 -0
  44. package/src/workers/workersBuildEvent.ts +48 -0
  45. package/src/workers/workersManager.ts +423 -0
  46. package/src/workers/workersProvisioner.ts +167 -0
  47. package/src/workflows/stepFailure.ts +280 -0
  48. package/src/workflows/workflowsClient.ts +213 -0
  49. package/src/zones/zonesManager.ts +92 -0
@@ -0,0 +1,548 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import {
5
+ AbortMultipartUploadCommand,
6
+ CompleteMultipartUploadCommand,
7
+ CopyObjectCommand,
8
+ CreateMultipartUploadCommand,
9
+ DeleteObjectCommand,
10
+ GetObjectCommand,
11
+ HeadObjectCommand,
12
+ ListMultipartUploadsCommand,
13
+ ListObjectsV2Command,
14
+ ListPartsCommand,
15
+ PutObjectCommand,
16
+ S3Client,
17
+ UploadPartCommand,
18
+ } from "@aws-sdk/client-s3";
19
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
20
+ import { z } from "zod";
21
+ import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors";
22
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
23
+ import { R2Credentials } from "./r2Credentials";
24
+
25
+ /** How long a presigned R2 URL stays valid, in seconds (1 hour), when the caller names no lifetime. */
26
+ const PRESIGN_EXPIRY_SECONDS = 3600;
27
+
28
+ /** How many parts one `ListParts` page returns. S3's own cap; the drain loop below relies on it. */
29
+ const LIST_PARTS_PAGE_SIZE = 1000;
30
+
31
+ /**
32
+ * Config for the R2 manager: the shared client config plus the S3-compatible credential pair and
33
+ * the bucket it targets. R2 signs presigned URLs with the S3 keys, not the CF API token — both
34
+ * come from config (no environment coupling).
35
+ */
36
+ export interface R2ManagerConfig extends CloudflareManagerConfig, R2Credentials {
37
+ /** The R2 bucket all object operations target. */
38
+ bucketName: string;
39
+ }
40
+
41
+ /** How long a presigned URL stays valid. Shared by every presign method so the option reads the same. */
42
+ export interface PresignOptions {
43
+ /** Lifetime in seconds. Defaults to one hour — long enough for a large upload, short enough to leak little. */
44
+ expiresIn?: number;
45
+ }
46
+
47
+ /** Presign options for one multipart part, which may additionally pin the part's exact byte count. */
48
+ export interface PresignPartOptions extends PresignOptions {
49
+ /**
50
+ * Exact byte count the client must send. Omitted by default — see {@link CloudflareR2Manager.presignUploadPart}
51
+ * for why signing a part length is opt-in where signing a whole-object length is not.
52
+ */
53
+ contentLength?: number;
54
+ }
55
+
56
+ /** What emptying a bucket reclaimed — the counts a teardown reports and an audit event records. */
57
+ export interface R2BucketDrain {
58
+ /** How many stored objects were deleted. */
59
+ objectsDeleted: number;
60
+ /** How many abandoned multipart uploads were aborted before the objects were drained. */
61
+ uploadsAborted: number;
62
+ }
63
+
64
+ /** Which slice of a bucket's keys to list, and how many to return. */
65
+ export interface ListObjectsOptions {
66
+ /** Only return keys starting with this prefix. Omitted lists the whole bucket. */
67
+ prefix?: string;
68
+ /** Continuation cursor from a previous page's `cursor`. Omitted starts at the beginning. */
69
+ cursor?: string;
70
+ /** Page size. R2 caps this at 1000 and returns a `cursor` when more remain. */
71
+ maxKeys?: number;
72
+ }
73
+
74
+ /** One completed part, as the client reports it back after a presigned `PUT` to a part URL. */
75
+ export interface CompletedPart {
76
+ /** The part's 1-based index, matching the `partNumber` the part URL was presigned for. */
77
+ partNumber: number;
78
+ /** The `ETag` response header R2 returned for that part, verbatim — quotes included. */
79
+ etag: string;
80
+ }
81
+
82
+ /** An object's metadata as R2 reports it, without fetching the body. */
83
+ export const R2ObjectHead = z
84
+ .object({
85
+ size: z.number().int().nonnegative().describe("The object's size in bytes."),
86
+ etag: z.string().min(1).describe("R2's entity tag for this object version, verbatim — quotes included."),
87
+ contentType: z
88
+ .string()
89
+ .min(1)
90
+ .optional()
91
+ .describe("The stored `Content-Type`. Absent when the object was written without one."),
92
+ uploaded: z.date().optional().describe("When R2 last wrote the object. Absent when R2 reports no timestamp."),
93
+ })
94
+ .describe("Metadata for one R2 object, read with a HEAD — no body transfer.");
95
+ export type R2ObjectHead = z.output<typeof R2ObjectHead>;
96
+
97
+ /** One part already uploaded against an in-flight multipart upload. */
98
+ export const R2UploadedPart = z
99
+ .object({
100
+ partNumber: z.number().int().min(1).describe("The part's 1-based index within the upload."),
101
+ etag: z.string().min(1).describe("The part's entity tag, verbatim. Pass it back unchanged to complete the upload."),
102
+ size: z.number().int().nonnegative().describe("The part's size in bytes, as R2 stored it."),
103
+ })
104
+ .describe("One uploaded part of a multipart upload — the unit a resumed upload skips re-sending.");
105
+ export type R2UploadedPart = z.output<typeof R2UploadedPart>;
106
+
107
+ /** One page of object keys from a bucket listing. */
108
+ export const R2ObjectListing = z
109
+ .object({
110
+ keys: z.array(z.string().min(1)).describe("The object keys in this page, in R2's lexicographic order."),
111
+ cursor: z
112
+ .string()
113
+ .min(1)
114
+ .optional()
115
+ .describe("Continuation cursor for the next page. Absent means this page was the last."),
116
+ })
117
+ .describe("One page of an R2 bucket listing. Pagination is caller-driven — pass `cursor` back to advance.");
118
+ export type R2ObjectListing = z.output<typeof R2ObjectListing>;
119
+
120
+ /**
121
+ * A multipart upload that was started and never completed or aborted.
122
+ *
123
+ * Abandoned uploads are invisible and not free — R2 stores and bills their parts, and a bucket holding
124
+ * one cannot be deleted at all.
125
+ */
126
+ export const R2PendingUpload = z
127
+ .object({
128
+ key: z.string().min(1).describe("The object key this upload would have assembled into."),
129
+ uploadId: z.string().min(1).describe("The id `abortMultipartUpload` needs in order to reclaim it."),
130
+ initiated: z.date().optional().describe("When the upload was started, for age-based reclamation."),
131
+ })
132
+ .describe("A multipart upload started and never completed or aborted — billed, invisible, and blocking.");
133
+ export type R2PendingUpload = z.output<typeof R2PendingUpload>;
134
+
135
+ /**
136
+ * Whether a thrown S3 error is a 404.
137
+ *
138
+ * `isNotFoundError` from `client/errors` reads `error.status`, which the `cloudflare` SDK sets and the
139
+ * AWS SDK does not — the AWS SDK reports the code on `$metadata.httpStatusCode` and names the shape
140
+ * `NotFound` (HEAD) or `NoSuchKey` (GET). So the S3 protocol needs its own check; sharing one would
141
+ * silently classify every missing object as a request failure.
142
+ */
143
+ function isS3NotFound(error: unknown): boolean {
144
+ if (typeof error !== "object" || error === null) return false;
145
+ const name = (error as { name?: unknown }).name;
146
+ if (name === "NotFound" || name === "NoSuchKey") return true;
147
+ const metadata = (error as { $metadata?: { httpStatusCode?: unknown } }).$metadata;
148
+ return metadata?.httpStatusCode === 404;
149
+ }
150
+
151
+ /**
152
+ * Out-of-Worker Cloudflare R2 access over the S3 protocol: presigned URLs the client uses to move
153
+ * bytes directly, plus the server-side object and multipart operations that surround them. Inside a
154
+ * Worker you use the R2 binding for object reads and writes; this manager is the REST/S3 counterpart,
155
+ * addressed by bucket name, and the only path to *presigned* multipart — the binding's multipart API
156
+ * streams bytes through the Worker, which defeats the point.
157
+ *
158
+ * Only `uploadPart` is presigned. Create, complete, abort, list, head, copy and delete are server-side
159
+ * calls the holder of the credentials makes directly — presigning them would hand a client authority
160
+ * it has no reason to hold.
161
+ */
162
+ export class CloudflareR2Manager extends CloudflareManager {
163
+ private readonly bucketName: string;
164
+
165
+ private readonly accessKeyId: string;
166
+
167
+ private readonly secretAccessKey: string;
168
+
169
+ private s3Client: S3Client | null = null;
170
+
171
+ constructor(config: R2ManagerConfig) {
172
+ super(config);
173
+ if (!config.bucketName) {
174
+ throw new CloudflareNotConfiguredError({ detail: "Missing bucketName for R2 access." });
175
+ }
176
+ // Validate the S3 credential pair the same way every other manager guards its resource id —
177
+ // an empty/unresolved key fails here with a clear config error, not later as an opaque SigV4 fault.
178
+ const credentials = R2Credentials.safeParse(config);
179
+ if (!credentials.success) {
180
+ throw new CloudflareNotConfiguredError({
181
+ detail: `Invalid R2 credentials: ${credentials.error.message}`,
182
+ });
183
+ }
184
+ this.bucketName = config.bucketName;
185
+ this.accessKeyId = config.accessKeyId;
186
+ this.secretAccessKey = config.secretAccessKey;
187
+ }
188
+
189
+ /** The S3-compatible client for R2, lazily created and cached for the manager's lifetime. */
190
+ private getS3Client(): S3Client {
191
+ if (!this.s3Client) {
192
+ this.s3Client = new S3Client({
193
+ region: "auto",
194
+ endpoint: `https://${this.accountId}.r2.cloudflarestorage.com`,
195
+ credentials: { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey },
196
+ });
197
+ }
198
+ return this.s3Client;
199
+ }
200
+
201
+ /**
202
+ * Presign a PUT URL for uploading one whole object. Valid for one hour unless `expiresIn` says otherwise.
203
+ *
204
+ * **`ContentLength` is signed; `ContentType` is not.** The length lands in `X-Amz-SignedHeaders`, so
205
+ * the client must send exactly that byte count — the signature is the enforcement, and that is
206
+ * deliberate for a whole-object upload: the caller already knows the size (it is what a quota check
207
+ * was run against), so signing it stops a client from spending more storage than it was granted.
208
+ *
209
+ * The type cannot be enforced this way at all. `@aws-sdk/s3-request-presigner` marks `content-type`
210
+ * unsignable before signing (`prepareRequest` → `unsignableHeaders.add("content-type")`), because a
211
+ * browser rewrites the header on a form or an XHR upload and a signature over it would break every
212
+ * such client. It therefore never reaches `X-Amz-SignedHeaders`, and R2 stores whatever type the
213
+ * client's PUT actually carries. `contentType` here is documentation of intent, not a constraint —
214
+ * unlike {@link createMultipartUpload}, which is a server-side call and does pin the type it sends.
215
+ * Anything keyed on an object's type must read it back with {@link headObject} after the upload,
216
+ * never from what the caller declared.
217
+ */
218
+ async createUploadUrl(
219
+ key: string,
220
+ contentType: string,
221
+ contentLength: number,
222
+ options: PresignOptions = {},
223
+ ): Promise<string> {
224
+ return cloudflareRequest(`R2 presign upload for '${key}'`, () => {
225
+ const command = new PutObjectCommand({
226
+ Bucket: this.bucketName,
227
+ Key: key,
228
+ ContentType: contentType,
229
+ ContentLength: contentLength,
230
+ });
231
+ return getSignedUrl(this.getS3Client(), command, {
232
+ expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS,
233
+ });
234
+ });
235
+ }
236
+
237
+ /** Presign a GET URL for downloading one object. Valid for one hour unless `expiresIn` says otherwise. */
238
+ async createDownloadUrl(key: string, options: PresignOptions = {}): Promise<string> {
239
+ return cloudflareRequest(`R2 presign download for '${key}'`, () => {
240
+ const command = new GetObjectCommand({ Bucket: this.bucketName, Key: key });
241
+ return getSignedUrl(this.getS3Client(), command, {
242
+ expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS,
243
+ });
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Open a multipart upload and return its `uploadId`. Server-side — no presigning. The id is the
249
+ * handle every later part, completion, abort and resume is addressed by, so a caller that means to
250
+ * support resuming must persist it.
251
+ *
252
+ * R2's multipart shape: at most 10,000 parts, every part but the last at least 5 MiB, each at most
253
+ * 5 GiB. Nothing here enforces that — R2 rejects a violating `complete`, and the part-size policy
254
+ * belongs to the layer that chose the part size.
255
+ */
256
+ async createMultipartUpload(key: string, contentType: string): Promise<string> {
257
+ return cloudflareRequest(`R2 create multipart upload for '${key}'`, async () => {
258
+ const response = await this.getS3Client().send(
259
+ new CreateMultipartUploadCommand({ Bucket: this.bucketName, Key: key, ContentType: contentType }),
260
+ );
261
+ return decodeResponse(
262
+ z.string().min(1).describe("The multipart upload id."),
263
+ response.UploadId,
264
+ `R2 create multipart upload for '${key}'`,
265
+ );
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Presign a PUT URL for one part of a multipart upload. This is the only multipart step a client
271
+ * ever touches, and therefore the only one presigned.
272
+ *
273
+ * **`ContentLength` is omitted by default**, unlike `createUploadUrl`. A signed length forces the
274
+ * client to send exactly that many bytes, and for a multipart upload the last part's length is
275
+ * `total - partSize * (n - 1)` — a different number from every other part. Signing it would mean
276
+ * either knowing the total size at mint time (a resumable or streaming upload does not) or minting
277
+ * the final part's URL through a separate code path. Neither earns its complexity: R2 already
278
+ * enforces the 5 MiB floor and 5 GiB ceiling per part, and the whole-object size is enforced where
279
+ * it matters — at `completeMultipartUpload`, against the parts R2 actually stored. A caller that
280
+ * does know the exact part size may still pin it by passing `contentLength`.
281
+ */
282
+ async presignUploadPart(
283
+ key: string,
284
+ uploadId: string,
285
+ partNumber: number,
286
+ options: PresignPartOptions = {},
287
+ ): Promise<string> {
288
+ return cloudflareRequest(`R2 presign part ${partNumber} for '${key}'`, () => {
289
+ const command = new UploadPartCommand({
290
+ Bucket: this.bucketName,
291
+ Key: key,
292
+ UploadId: uploadId,
293
+ PartNumber: partNumber,
294
+ ...(options.contentLength === undefined ? {} : { ContentLength: options.contentLength }),
295
+ });
296
+ return getSignedUrl(this.getS3Client(), command, {
297
+ expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS,
298
+ });
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Assemble the uploaded parts into one object. Server-side — the client only ever reports the
304
+ * `{ partNumber, etag }` pairs its part PUTs returned.
305
+ *
306
+ * Parts are sorted ascending before the call: S3 rejects an out-of-order part list, and the order a
307
+ * client finishes concurrent part uploads in is not the order they belong in. ETags pass through
308
+ * verbatim — they are opaque to us, and R2 compares them byte for byte.
309
+ */
310
+ async completeMultipartUpload(key: string, uploadId: string, parts: readonly CompletedPart[]): Promise<void> {
311
+ await cloudflareRequest(`R2 complete multipart upload for '${key}'`, async () => {
312
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
313
+ await this.getS3Client().send(
314
+ new CompleteMultipartUploadCommand({
315
+ Bucket: this.bucketName,
316
+ Key: key,
317
+ UploadId: uploadId,
318
+ MultipartUpload: { Parts: ordered.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag })) },
319
+ }),
320
+ );
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Discard an in-flight multipart upload and the parts already stored under it. Idempotent — an
326
+ * upload id R2 has already forgotten is not an error, so a sweep or a retried teardown can re-run.
327
+ */
328
+ async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
329
+ await cloudflareRequest(`R2 abort multipart upload for '${key}'`, async () => {
330
+ try {
331
+ await this.getS3Client().send(
332
+ new AbortMultipartUploadCommand({ Bucket: this.bucketName, Key: key, UploadId: uploadId }),
333
+ );
334
+ } catch (error) {
335
+ if (isS3NotFound(error)) return;
336
+ throw error;
337
+ }
338
+ });
339
+ }
340
+
341
+ /**
342
+ * List the parts already stored against an in-flight upload, ascending. This is what makes an upload
343
+ * resumable: a client that lost its progress asks which parts landed and re-sends only the rest.
344
+ *
345
+ * Pagination is drained here rather than surfaced. An upload holds at most 10,000 parts and a page
346
+ * holds 1000, so the loop is bounded at ten calls — a cursor would be API surface for no gain.
347
+ */
348
+ async listParts(key: string, uploadId: string): Promise<R2UploadedPart[]> {
349
+ return cloudflareRequest(`R2 list parts for '${key}'`, async () => {
350
+ const parts: R2UploadedPart[] = [];
351
+ let marker: string | undefined;
352
+ do {
353
+ const response = await this.getS3Client().send(
354
+ new ListPartsCommand({
355
+ Bucket: this.bucketName,
356
+ Key: key,
357
+ UploadId: uploadId,
358
+ MaxParts: LIST_PARTS_PAGE_SIZE,
359
+ PartNumberMarker: marker,
360
+ }),
361
+ );
362
+ for (const part of response.Parts ?? []) {
363
+ parts.push(
364
+ decodeResponse(
365
+ R2UploadedPart,
366
+ { partNumber: part.PartNumber, etag: part.ETag, size: part.Size },
367
+ `R2 list parts for '${key}'`,
368
+ ),
369
+ );
370
+ }
371
+ marker = response.IsTruncated ? response.NextPartNumberMarker : undefined;
372
+ } while (marker);
373
+ return parts.sort((a, b) => a.partNumber - b.partNumber);
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Read one object's metadata without transferring its body. Returns `null` when the object is
379
+ * absent — a missing object is an answer, not a failure, and the caller decides what it means.
380
+ */
381
+ async headObject(key: string): Promise<R2ObjectHead | null> {
382
+ return cloudflareRequest(`R2 head object '${key}'`, async () => {
383
+ try {
384
+ const response = await this.getS3Client().send(new HeadObjectCommand({ Bucket: this.bucketName, Key: key }));
385
+ return decodeResponse(
386
+ R2ObjectHead,
387
+ {
388
+ size: response.ContentLength,
389
+ etag: response.ETag,
390
+ contentType: response.ContentType,
391
+ uploaded: response.LastModified,
392
+ },
393
+ `R2 head object '${key}'`,
394
+ );
395
+ } catch (error) {
396
+ if (isS3NotFound(error)) return null;
397
+ throw error;
398
+ }
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Every multipart upload started in this bucket and never completed or aborted.
404
+ *
405
+ * Abandoned uploads are invisible and not free: R2 stores and bills the parts, and a bucket holding one
406
+ * cannot be deleted at all. Nothing else can find them — `listObjects` does not show an upload that never
407
+ * assembled, and `listParts` needs the `uploadId` you are trying to discover. So this is the only route to
408
+ * reclaiming a bucket, and the only way an orphan sweep can see the cost it is meant to reclaim.
409
+ *
410
+ * Fully drained rather than cursor-paged: a caller wants all of them, and the count is bounded by how many
411
+ * uploads were abandoned rather than by how much data the bucket holds.
412
+ */
413
+ async listMultipartUploads(prefix?: string): Promise<R2PendingUpload[]> {
414
+ return cloudflareRequest("R2 list multipart uploads", async () => {
415
+ const found: R2PendingUpload[] = [];
416
+ let keyMarker: string | undefined;
417
+ let uploadIdMarker: string | undefined;
418
+ do {
419
+ const response = await this.getS3Client().send(
420
+ new ListMultipartUploadsCommand({
421
+ Bucket: this.bucketName,
422
+ Prefix: prefix,
423
+ KeyMarker: keyMarker,
424
+ UploadIdMarker: uploadIdMarker,
425
+ }),
426
+ );
427
+ for (const upload of response.Uploads ?? []) {
428
+ if (!upload.Key || !upload.UploadId) continue;
429
+ found.push(
430
+ decodeResponse(
431
+ R2PendingUpload,
432
+ { key: upload.Key, uploadId: upload.UploadId, initiated: upload.Initiated },
433
+ "R2 pending multipart upload",
434
+ ),
435
+ );
436
+ }
437
+ keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
438
+ uploadIdMarker = response.IsTruncated ? response.NextUploadIdMarker : undefined;
439
+ } while (keyMarker || uploadIdMarker);
440
+ return found;
441
+ });
442
+ }
443
+
444
+ /**
445
+ * List one page of object keys. Pagination is caller-driven: pass the returned `cursor` back to get
446
+ * the next page, and stop when it is absent. Unlike `listParts` a bucket has no bounded size, so
447
+ * draining it here would be an unbounded loop with no way for the caller to stop early.
448
+ */
449
+ async listObjects(options: ListObjectsOptions = {}): Promise<R2ObjectListing> {
450
+ return cloudflareRequest("R2 list objects", async () => {
451
+ const response = await this.getS3Client().send(
452
+ new ListObjectsV2Command({
453
+ Bucket: this.bucketName,
454
+ Prefix: options.prefix,
455
+ ContinuationToken: options.cursor,
456
+ MaxKeys: options.maxKeys,
457
+ }),
458
+ );
459
+ const keys = (response.Contents ?? []).map((entry) => entry.Key).filter((key): key is string => Boolean(key));
460
+ return decodeResponse(
461
+ R2ObjectListing,
462
+ // A truncated page always carries a continuation token; guard anyway so a token without the
463
+ // truncation flag can never strand the caller mid-listing.
464
+ { keys, cursor: response.IsTruncated ? response.NextContinuationToken : undefined },
465
+ "R2 list objects",
466
+ );
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Copy an object within this bucket, server-side — the bytes never leave R2.
472
+ *
473
+ * `CopySource` is `/<bucket>/<key>` with the key percent-encoded per path segment. The SDK does not
474
+ * encode it, and a key containing a space or `?` produces a malformed source header otherwise;
475
+ * encoding whole would destroy the `/` separators a nested key depends on.
476
+ */
477
+ async copyObject(sourceKey: string, destKey: string): Promise<void> {
478
+ await cloudflareRequest(`R2 copy '${sourceKey}' to '${destKey}'`, async () => {
479
+ const source = `/${this.bucketName}/${sourceKey.split("/").map(encodeURIComponent).join("/")}`;
480
+ await this.getS3Client().send(
481
+ new CopyObjectCommand({ Bucket: this.bucketName, Key: destKey, CopySource: source }),
482
+ );
483
+ });
484
+ }
485
+
486
+ /**
487
+ * Delete one object. Idempotent by protocol — S3 answers a delete of an absent key with a success,
488
+ * so teardown and orphan sweeps can re-run safely.
489
+ */
490
+ async deleteObject(key: string): Promise<void> {
491
+ await cloudflareRequest(`R2 delete object '${key}'`, async () => {
492
+ await this.getS3Client().send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }));
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Delete every object in this bucket and abort every multipart upload still in flight.
498
+ *
499
+ * **R2 refuses to delete a bucket that is not empty**, so this is the step that has to run before
500
+ * `CloudflareR2Provisioner.deleteBucket` — and it is the reason a teardown needs the S3 key pair at
501
+ * all: the CF API has no object plane, so nothing but this protocol can empty a bucket.
502
+ *
503
+ * Two different things hold a bucket, and only one of them is visible. Stored objects show up in a
504
+ * listing; the parts of an upload that was started and never completed or aborted do not, and they
505
+ * block the delete just the same. So the aborts go first: draining the keys of a bucket that still
506
+ * carries a dangling upload leaves it empty-looking and undeletable, which is the confusing failure.
507
+ *
508
+ * **Destructive and unrecoverable**, and it does not ask — the caller owns the confirmation. Idempotent:
509
+ * an already-empty bucket is a no-op reporting zeroes, so a retried teardown is safe.
510
+ */
511
+ async emptyBucket(): Promise<R2BucketDrain> {
512
+ let uploadsAborted = 0;
513
+ for (const pending of await this.listMultipartUploads()) {
514
+ await this.abortMultipartUpload(pending.key, pending.uploadId);
515
+ uploadsAborted += 1;
516
+ }
517
+
518
+ let objectsDeleted = 0;
519
+ let cursor: string | undefined;
520
+ do {
521
+ const page = await this.listObjects({ cursor });
522
+ for (const key of page.keys) {
523
+ await this.deleteObject(key);
524
+ objectsDeleted += 1;
525
+ }
526
+ cursor = page.cursor;
527
+ } while (cursor);
528
+
529
+ return { objectsDeleted, uploadsAborted };
530
+ }
531
+
532
+ getServiceType(): string {
533
+ return "Cloudflare R2";
534
+ }
535
+
536
+ /**
537
+ * Prove access by reading the bucket record over the CF API (token + account + bucket existence),
538
+ * not merely that an S3 client could be built. Never throws.
539
+ */
540
+ async validateServiceAccess(): Promise<boolean> {
541
+ try {
542
+ await this.getClient().r2.buckets.get(this.bucketName, { account_id: this.accountId });
543
+ return true;
544
+ } catch {
545
+ return false;
546
+ }
547
+ }
548
+ }
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /** An R2 bucket's identity, decoded from the create/list response. Buckets are addressed by name, not id. */
9
+ export const R2BucketInfo = z
10
+ .object({
11
+ name: z.string().describe("The bucket name — R2's sole address for a bucket (no separate uuid id)."),
12
+ })
13
+ .describe("A Cloudflare R2 bucket's identity, as returned by the create/list endpoints.");
14
+ export type R2BucketInfo = z.output<typeof R2BucketInfo>;
15
+
16
+ /**
17
+ * Account-level R2 control plane: **create and delete buckets**. Customers use this to stand up and
18
+ * tear down per-environment buckets (e.g. ephemeral staging), and worktree teardown uses it to
19
+ * reconcile/prune feature-scoped buckets. Unlike {@link CloudflareR2Manager}, which operates within
20
+ * one already-provisioned bucket (presigned object URLs), this manager is account-scoped and
21
+ * addresses buckets **by name** — R2 buckets carry no separate uuid id the way D1 databases do.
22
+ */
23
+ export class CloudflareR2Provisioner extends CloudflareManager {
24
+ getServiceType(): string {
25
+ return "Cloudflare R2 (control plane)";
26
+ }
27
+
28
+ /** Prove access by listing buckets — a read, never a destructive create/delete. Never throws. */
29
+ async validateServiceAccess(): Promise<boolean> {
30
+ try {
31
+ await this.getClient().r2.buckets.list({ account_id: this.accountId });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Find a bucket by exact name in the account, or `null` — for idempotent provisioning. */
39
+ async findBucketByName(name: string): Promise<R2BucketInfo | null> {
40
+ const buckets = await this.listBuckets();
41
+ return buckets.find((bucket) => bucket.name === name) ?? null;
42
+ }
43
+
44
+ /**
45
+ * List every bucket in the account. R2's bucket list is cursor-paginated (a single call returns only
46
+ * one page, unlike D1/KV whose SDK list auto-paginates), so this drains every page via `start_after` —
47
+ * the name of the last bucket seen — until a page comes back empty, then returns them all. Skips entries
48
+ * with no name.
49
+ */
50
+ async listBuckets(): Promise<R2BucketInfo[]> {
51
+ return cloudflareRequest("list R2 buckets", async () => {
52
+ const buckets: R2BucketInfo[] = [];
53
+ let startAfter: string | undefined;
54
+ for (;;) {
55
+ const response = await this.getClient().r2.buckets.list({
56
+ account_id: this.accountId,
57
+ per_page: 1000,
58
+ ...(startAfter ? { start_after: startAfter } : {}),
59
+ });
60
+ const page = response.buckets ?? [];
61
+ if (page.length === 0) break;
62
+ for (const bucket of page) {
63
+ const parsed = R2BucketInfo.safeParse(bucket);
64
+ if (parsed.success) buckets.push(parsed.data);
65
+ }
66
+ const last = page[page.length - 1]?.name;
67
+ if (!last || last === startAfter) break; // no name to page past, or the cursor didn't advance.
68
+ startAfter = last;
69
+ }
70
+ return buckets;
71
+ });
72
+ }
73
+
74
+ /** Create an R2 bucket by name; returns its name. */
75
+ async createBucket(name: string): Promise<R2BucketInfo> {
76
+ const response = await cloudflareRequest(`create R2 bucket ${name}`, () =>
77
+ this.getClient().r2.buckets.create({ account_id: this.accountId, name }),
78
+ );
79
+ return decodeResponse(R2BucketInfo, response, "R2 bucket create");
80
+ }
81
+
82
+ /**
83
+ * Delete an R2 bucket by name. Idempotent — a missing bucket is not an error, so teardown can re-run safely.
84
+ *
85
+ * **The bucket must already be empty.** R2 rejects the delete otherwise, and "empty" includes multipart
86
+ * uploads that were never completed — parts nothing in this control plane can even see. Emptying is an
87
+ * S3-protocol operation: call `CloudflareR2Manager.emptyBucket` first, which needs the S3 key pair.
88
+ */
89
+ async deleteBucket(name: string): Promise<void> {
90
+ await cloudflareRequest(`delete R2 bucket ${name}`, async () => {
91
+ try {
92
+ await this.getClient().r2.buckets.delete(name, { account_id: this.accountId });
93
+ } catch (error) {
94
+ if (isNotFoundError(error)) return;
95
+ throw error;
96
+ }
97
+ });
98
+ }
99
+ }