@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,80 @@
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
+ /** A KV namespace's identity, decoded from the create response. */
9
+ export const KVNamespaceInfo = z
10
+ .object({
11
+ id: z.string().describe("The CF-assigned namespace id used to address the namespace."),
12
+ title: z.string().describe("The namespace title."),
13
+ })
14
+ .describe("A Cloudflare KV namespace's identity, as returned by the create endpoint.");
15
+ export type KVNamespaceInfo = z.output<typeof KVNamespaceInfo>;
16
+
17
+ /**
18
+ * Account-level KV control plane: **create and delete namespaces**. Customers use this to stand up
19
+ * and tear down per-environment KV namespaces (e.g. ephemeral staging), and `pithy add`-driven
20
+ * provisioning uses it to create each environment's namespaces. Addressed by account — unlike
21
+ * {@link CloudflareKVManager}, which targets one namespace id for key reads/writes.
22
+ */
23
+ export class CloudflareKVProvisioner extends CloudflareManager {
24
+ getServiceType(): string {
25
+ return "Cloudflare KV (control plane)";
26
+ }
27
+
28
+ /** Prove access by listing namespaces — a read, never a destructive create/delete. Never throws. */
29
+ async validateServiceAccess(): Promise<boolean> {
30
+ try {
31
+ await this.getClient().kv.namespaces.list({ account_id: this.accountId });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Find a namespace by exact title in the account, or `null` — for idempotent provisioning. */
39
+ async findNamespaceByTitle(title: string): Promise<KVNamespaceInfo | null> {
40
+ return cloudflareRequest(`find KV namespace ${title}`, async () => {
41
+ for await (const ns of this.getClient().kv.namespaces.list({ account_id: this.accountId })) {
42
+ const parsed = KVNamespaceInfo.safeParse(ns);
43
+ if (parsed.success && parsed.data.title === title) return parsed.data;
44
+ }
45
+ return null;
46
+ });
47
+ }
48
+
49
+ /** List every namespace in the account — for prefix-scan reconcile teardown. */
50
+ async listNamespaces(): Promise<KVNamespaceInfo[]> {
51
+ return cloudflareRequest("list KV namespaces", async () => {
52
+ const namespaces: KVNamespaceInfo[] = [];
53
+ for await (const ns of this.getClient().kv.namespaces.list({ account_id: this.accountId })) {
54
+ const parsed = KVNamespaceInfo.safeParse(ns);
55
+ if (parsed.success) namespaces.push(parsed.data);
56
+ }
57
+ return namespaces;
58
+ });
59
+ }
60
+
61
+ /** Create a KV namespace by title; returns its id and title. */
62
+ async createNamespace(title: string): Promise<KVNamespaceInfo> {
63
+ const response = await cloudflareRequest(`create KV namespace ${title}`, () =>
64
+ this.getClient().kv.namespaces.create({ account_id: this.accountId, title }),
65
+ );
66
+ return decodeResponse(KVNamespaceInfo, response, "KV namespace create");
67
+ }
68
+
69
+ /** Delete a KV namespace by id. Idempotent — a missing namespace is not an error, so teardown can re-run safely. */
70
+ async deleteNamespace(namespaceId: string): Promise<void> {
71
+ await cloudflareRequest(`delete KV namespace ${namespaceId}`, async () => {
72
+ try {
73
+ await this.getClient().kv.namespaces.delete(namespaceId, { account_id: this.accountId });
74
+ } catch (error) {
75
+ if (isNotFoundError(error)) return;
76
+ throw error;
77
+ }
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { CloudflareRequestError, cloudflareRequest, decodeResponse } from "../client/errors";
6
+ import type { CloudflareImageManager } from "./imageManager";
7
+ import { type AssetOwner, withAssetOwnership } from "./ownership";
8
+ import type { CloudflareStreamManager } from "./streamManager";
9
+
10
+ /**
11
+ * Seed-time byte uploads to the account-flat Cloudflare Images and Stream stores. Seeding hands a
12
+ * manager raw bytes and a metadata bag and gets back the minted asset id/uid to record — the one
13
+ * place these two stores accept fixture bytes from a CLI/CI context. Every wire response is
14
+ * validated with a local Zod object so a shape drift fails loudly (`cloudflare/invalid_response`)
15
+ * instead of surfacing as `undefined`. These calls are always remote: neither store has a local
16
+ * emulation, so even a `pithy seed` against dev writes into the same store production shares.
17
+ *
18
+ * Which is why {@link AssetOwner} is **required**, not optional. An asset here is keyed by a
19
+ * Cloudflare-minted id, so its metadata is the only thing that says which project seeded it —
20
+ * see `ownership.ts`. Seed fixtures are the likeliest source of debris, and an unattributable
21
+ * fixture asset is one nobody can sweep.
22
+ */
23
+
24
+ /** The default max video duration (seconds) CF Stream requires for a direct upload — 6 hours. */
25
+ const STREAM_MAX_DURATION_SECONDS = 21600;
26
+
27
+ /** The only field of an Images upload response the seeder needs: the minted image id. */
28
+ const ImageUploadResult = z.object({ id: z.string() });
29
+
30
+ /** The fields of a Stream direct-upload mint the seeder needs: the uid and the one-time upload URL. */
31
+ const StreamDirectUploadResult = z.object({ uid: z.string(), uploadURL: z.string() });
32
+
33
+ /**
34
+ * Upload image bytes to Cloudflare Images, stamped with the owner, returning the minted id. Images
35
+ * supports a direct byte upload, so this is a single `uploadImage({ file, metadata })` call — the
36
+ * manager JSON-encodes the metadata bag for the wire. The fixture's own metadata rides along; the
37
+ * ownership keys are merged last, so a fixture cannot claim another project's assets.
38
+ */
39
+ export async function uploadImageBytes(
40
+ manager: CloudflareImageManager,
41
+ bytes: Uint8Array | Blob,
42
+ opts: { owner: AssetOwner; metadata?: Record<string, string> },
43
+ ): Promise<{ id: string }> {
44
+ // The Images SDK's `Uploadable` type is a `File`, not a bare `Blob`, so wrap a Blob/bytes input.
45
+ const file = bytes instanceof File ? bytes : new File([bytes], "seed");
46
+ const response = await manager.uploadImage({ file, metadata: withAssetOwnership(opts.owner, opts.metadata) });
47
+ const result = decodeResponse(ImageUploadResult, response, "Images seed upload");
48
+ return { id: result.id };
49
+ }
50
+
51
+ /**
52
+ * Upload video bytes to Cloudflare Stream, returning the minted uid. Stream has no single direct-bytes
53
+ * method, so this mints a one-time upload URL with `createDirectUpload` — carrying the stamped metadata
54
+ * as `meta` so ownership lands with the video — then POSTs the bytes to that URL as `multipart/form-data` (the
55
+ * basic direct-upload contract). Because the mint carries the metadata, no follow-up `updateVideo`
56
+ * stamp is needed; if a future Stream contract dropped mint-time `meta`, stamp with
57
+ * `updateVideo(uid, { meta })` after the upload.
58
+ */
59
+ export async function uploadStreamBytes(
60
+ manager: CloudflareStreamManager,
61
+ bytes: Uint8Array | Blob,
62
+ opts: { owner: AssetOwner; metadata?: Record<string, string>; maxDurationSeconds?: number },
63
+ ): Promise<{ uid: string }> {
64
+ const mint = decodeResponse(
65
+ StreamDirectUploadResult,
66
+ await manager.createDirectUpload({
67
+ maxDurationSeconds: opts.maxDurationSeconds ?? STREAM_MAX_DURATION_SECONDS,
68
+ meta: withAssetOwnership(opts.owner, opts.metadata),
69
+ }),
70
+ "Stream seed direct upload",
71
+ );
72
+
73
+ await cloudflareRequest("Stream seed byte upload", async () => {
74
+ const file = bytes instanceof Blob ? bytes : new Blob([bytes]);
75
+ const form = new FormData();
76
+ form.append("file", file, "seed");
77
+ const response = await fetch(mint.uploadURL, { method: "POST", body: form });
78
+ if (!response.ok) {
79
+ throw new CloudflareRequestError({
80
+ message: "The Stream direct upload failed.",
81
+ detail: `Stream direct upload returned ${response.status}: ${await response.text()}`,
82
+ });
83
+ }
84
+ });
85
+
86
+ return { uid: mint.uid };
87
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Cloudflare } from "cloudflare";
5
+ import type { Image, V1CreateParams, V1EditParams } from "cloudflare/resources/images/v1/v1";
6
+ import type {
7
+ DirectUploadCreateParams,
8
+ DirectUploadCreateResponse,
9
+ } from "cloudflare/resources/images/v2/direct-uploads";
10
+ import type { V2ListParams } from "cloudflare/resources/images/v2/v2";
11
+ import { cloudflareRequest } from "../client/errors";
12
+ import { CloudflareManager } from "../client/manager";
13
+
14
+ /**
15
+ * Per-request CF SDK options for every Images call: a 10s timeout and up to 3 retries. Uploads and
16
+ * list calls can be slow, so the timeout is generous and retries cover transient API blips.
17
+ */
18
+ const requestOptions: Cloudflare.RequestOptions = {
19
+ timeout: 10000,
20
+ maxRetries: 3,
21
+ };
22
+
23
+ /**
24
+ * Encode a user-metadata bag to the string the Images API expects. CF stores image metadata as a
25
+ * JSON string; we serialize the generic `Record<string, string>` passthrough before sending, and
26
+ * leave an absent bag untouched.
27
+ */
28
+ function encodeMetadata(metadata: unknown): unknown {
29
+ return metadata === undefined || metadata === null ? metadata : JSON.stringify(metadata);
30
+ }
31
+
32
+ /**
33
+ * Out-of-Worker Cloudflare Images access over the REST API: upload, fetch, edit, delete, list, and
34
+ * direct-upload URLs from a CLI/CI/provisioning context. Image metadata is an arbitrary
35
+ * `Record<string, string>` passthrough at this layer — the manager imposes no shape.
36
+ *
37
+ * The ownership stamp is imposed one layer up, by `withAssetOwnership` in `ownership.ts`, which every
38
+ * Pithy path that *creates* an image goes through (the seeder here, and media's `imageMinter`). The
39
+ * split is deliberate: this class stays a faithful REST client usable for reading and sweeping
40
+ * anyone's images, while nothing Pithy writes can escape carrying `pithyProject`/`pithyEnv`.
41
+ */
42
+ export class CloudflareImageManager extends CloudflareManager {
43
+ /** Upload an image via the V1 API. `account_id` is set from the manager's config. */
44
+ async uploadImage(params: Omit<V1CreateParams, "account_id">): Promise<Image> {
45
+ return cloudflareRequest("Images upload", () =>
46
+ this.getClient().images.v1.create(
47
+ { account_id: this.accountId, ...params, metadata: encodeMetadata(params.metadata) },
48
+ requestOptions,
49
+ ),
50
+ );
51
+ }
52
+
53
+ /** Fetch details for one image by id. */
54
+ async imageDetails(imageId: string): Promise<Image> {
55
+ return cloudflareRequest(`Images get details for '${imageId}'`, () =>
56
+ this.getClient().images.v1.get(imageId, { account_id: this.accountId }, requestOptions),
57
+ );
58
+ }
59
+
60
+ /** Update an image's metadata and settings. */
61
+ async updateImage(imageId: string, params: Omit<V1EditParams, "account_id">): Promise<Image> {
62
+ return cloudflareRequest(`Images update '${imageId}'`, () =>
63
+ this.getClient().images.v1.edit(imageId, { account_id: this.accountId, ...params }, requestOptions),
64
+ );
65
+ }
66
+
67
+ /** Delete an image by id. */
68
+ async deleteImage(imageId: string): Promise<void> {
69
+ await cloudflareRequest(`Images delete '${imageId}'`, () =>
70
+ this.getClient().images.v1.delete(imageId, { account_id: this.accountId }, requestOptions),
71
+ );
72
+ }
73
+
74
+ /** Create a direct-upload URL for a client-side upload (V2 API). */
75
+ async createDirectUploadUrl(
76
+ params: Omit<DirectUploadCreateParams, "account_id">,
77
+ ): Promise<DirectUploadCreateResponse> {
78
+ return cloudflareRequest("Images create direct upload URL", () =>
79
+ this.getClient().images.v2.directUploads.create(
80
+ { account_id: this.accountId, ...params, metadata: encodeMetadata(params.metadata) },
81
+ requestOptions,
82
+ ),
83
+ );
84
+ }
85
+
86
+ /** List images (V2 API), first page only. Returns the page's images, never null. */
87
+ async listImages(params?: Omit<V2ListParams, "account_id">): Promise<Image[]> {
88
+ return cloudflareRequest("Images list", async () => {
89
+ const response = await this.getClient().images.v2.list({ account_id: this.accountId, ...params }, requestOptions);
90
+ return response.images ?? [];
91
+ });
92
+ }
93
+
94
+ /** List all images across every page, following the V2 continuation token. */
95
+ async listAllImages(params?: Omit<V2ListParams, "account_id">): Promise<Image[]> {
96
+ return cloudflareRequest("Images list all", async () => {
97
+ const allImages: Image[] = [];
98
+ const client = this.getClient();
99
+ const fetchPage = async (token?: string | null): Promise<void> => {
100
+ const response = await client.images.v2.list(
101
+ { account_id: this.accountId, ...params, continuation_token: token },
102
+ requestOptions,
103
+ );
104
+ if (response.images) allImages.push(...response.images);
105
+ if (response.continuation_token) await fetchPage(response.continuation_token);
106
+ };
107
+ await fetchPage();
108
+ return allImages;
109
+ });
110
+ }
111
+
112
+ getServiceType(): string {
113
+ return "Cloudflare Images";
114
+ }
115
+
116
+ /** Prove access by listing images. Never throws. */
117
+ async validateServiceAccess(): Promise<boolean> {
118
+ try {
119
+ await this.listImages();
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareNotConfiguredError } from "../client/errors";
5
+
6
+ /**
7
+ * The ownership stamp on every Cloudflare Images and Stream asset Pithy creates.
8
+ *
9
+ * Every other resource this toolset provisions is isolated by its **name** —
10
+ * `<project>-<env>-<thing>`, because those namespaces are flat and account-wide (CLAUDE.md
11
+ * §Resource naming). Images and Stream cannot use that lever: their stores are just as flat and
12
+ * account-wide, but an asset is keyed by a **Cloudflare-minted id**, not by a name we choose. So
13
+ * metadata is the only place ownership can live, and without it two projects' assets in one account
14
+ * are indistinguishable — you cannot tell which app owns an asset, cannot sweep one app's assets at
15
+ * teardown, and `pithy doctor` is blind to them.
16
+ *
17
+ * The **same two keys** are used for both stores (Images `metadata`, Stream `meta`), so one query
18
+ * answers "what does this project own" across them rather than two dialects answering half each.
19
+ * Nothing else is imposed: a caller's own metadata rides along untouched — but it can never displace
20
+ * the reserved keys, which are merged last on purpose.
21
+ */
22
+
23
+ /** The metadata key naming the owning project — the root `pithy.config.ts` `name`. */
24
+ export const ASSET_PROJECT_KEY = "pithyProject";
25
+
26
+ /** The metadata key naming the owning environment (`dev` | `staging` | `production`). */
27
+ export const ASSET_ENV_KEY = "pithyEnv";
28
+
29
+ /** Who owns an asset: the project that created it, in the environment that created it. */
30
+ export interface AssetOwner {
31
+ /**
32
+ * The project name — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and never
33
+ * guessed. It is the segment every sweep, listing, and teardown filters on.
34
+ */
35
+ project: string;
36
+ /** The environment the asset was created in (`dev` | `staging` | `production`). */
37
+ env: string;
38
+ }
39
+
40
+ /**
41
+ * Merge the ownership stamp over a caller's metadata bag.
42
+ *
43
+ * Ownership is applied **last**, so a caller-supplied `pithyProject`/`pithyEnv` cannot displace the
44
+ * real one — a fixture or a client-supplied bag claiming another project's assets would defeat the
45
+ * whole point of the stamp. A blank project or environment is refused rather than written: an asset
46
+ * stamped with an empty string reads as owned and still sweeps to nothing.
47
+ */
48
+ export function withAssetOwnership(owner: AssetOwner, metadata?: Record<string, string>): Record<string, string> {
49
+ const project = owner.project.trim();
50
+ const env = owner.env.trim();
51
+ if (!project || !env) {
52
+ throw new CloudflareNotConfiguredError({
53
+ message: "A Cloudflare Images or Stream asset cannot be created without an owner.",
54
+ action: "Pass the project (requireProjectName) and the environment to the uploader.",
55
+ detail: `asset owner is incomplete: project="${owner.project}", env="${owner.env}"`,
56
+ });
57
+ }
58
+ return { ...metadata, [ASSET_PROJECT_KEY]: project, [ASSET_ENV_KEY]: env };
59
+ }
@@ -0,0 +1,198 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Cloudflare } from "cloudflare";
5
+ import type { DirectUploadCreateParams, DirectUploadCreateResponse } from "cloudflare/resources/stream/direct-upload";
6
+ import type { StreamEditParams, Video } from "cloudflare/resources/stream/stream";
7
+ import { z } from "zod";
8
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
9
+ import { CloudflareManager } from "../client/manager";
10
+
11
+ /**
12
+ * Per-request CF SDK options for every Stream call: a 10s timeout and up to 3 retries — the same
13
+ * generous envelope the Images manager uses, since video operations can be slow.
14
+ */
15
+ const requestOptions: Cloudflare.RequestOptions = {
16
+ timeout: 10000,
17
+ maxRetries: 3,
18
+ };
19
+
20
+ /**
21
+ * One download artifact's state, as returned by the Stream downloads API. The SDK types these
22
+ * responses as `unknown`, so this Zod object is the validated shape — it is the JS↔wire boundary
23
+ * for the raw downloads payloads.
24
+ */
25
+ export const DownloadEntry = z
26
+ .object({
27
+ status: z.enum(["error", "inprogress", "ready"]).optional().describe("Where this download is in its lifecycle."),
28
+ url: z.string().optional().describe("The download URL, present once the artifact is ready."),
29
+ percentComplete: z.number().optional().describe("Generation progress, 0–100, while in progress."),
30
+ })
31
+ .describe("The state of one Stream download artifact (MP4 or audio).");
32
+ export type DownloadEntry = z.infer<typeof DownloadEntry>;
33
+
34
+ /**
35
+ * The Stream downloads payload: the default (MP4) artifact, plus an optional audio-only (M4A) one.
36
+ * `GET /downloads` returns every type; `POST /downloads/{type}` returns just that type.
37
+ */
38
+ export const StreamDownloadStatus = z
39
+ .object({
40
+ default: DownloadEntry.describe("The default MP4 download artifact."),
41
+ audio: DownloadEntry.optional().describe("The audio-only (M4A) download artifact, when requested."),
42
+ })
43
+ .describe("The set of download artifacts Cloudflare Stream has generated for a video.");
44
+ export type StreamDownloadStatus = z.infer<typeof StreamDownloadStatus>;
45
+
46
+ /** The envelope the raw audio-download endpoint returns: `{ result: { audio } }`. */
47
+ const AudioDownloadEnvelope = z
48
+ .object({
49
+ result: z
50
+ .object({ audio: DownloadEntry.describe("The audio-only download artifact.") })
51
+ .describe("The CF API result wrapper for the audio download."),
52
+ })
53
+ .describe("The raw `POST /downloads/audio` response envelope.");
54
+
55
+ /**
56
+ * Validate a raw downloads payload against `StreamDownloadStatus`, mapping a Zod failure to a
57
+ * `CloudflareInvalidResponseError` (the response did not match its expected shape) rather than
58
+ * letting it surface as a generic request failure.
59
+ */
60
+ function parseDownloadStatus(response: unknown): StreamDownloadStatus {
61
+ const result = StreamDownloadStatus.safeParse(response);
62
+ if (!result.success) {
63
+ throw new CloudflareInvalidResponseError({
64
+ message: "A Stream downloads response had an unexpected shape.",
65
+ detail: result.error.message,
66
+ });
67
+ }
68
+ return result.data;
69
+ }
70
+
71
+ /**
72
+ * Out-of-Worker Cloudflare Stream access over the REST API: delete, fetch, edit, direct uploads,
73
+ * and download (MP4 + audio-only) management from a CLI/CI/provisioning context. Video metadata is
74
+ * the SDK's own `Video` shape — this class imposes none of its own.
75
+ *
76
+ * The ownership stamp is imposed one layer up, by `withAssetOwnership` in `ownership.ts`, which every
77
+ * Pithy path that *creates* a video goes through (the seeder here, and media's `videoMinter`). Stream
78
+ * is account-flat and keys a video by a Cloudflare-minted uid, so `meta.pithyProject` is the only
79
+ * thing that says which project owns it.
80
+ */
81
+ export class CloudflareStreamManager extends CloudflareManager {
82
+ /** Delete a video by its Stream UID. */
83
+ async deleteVideo(videoId: string): Promise<void> {
84
+ await cloudflareRequest(`Stream delete '${videoId}'`, () =>
85
+ this.getClient().stream.delete(videoId, { account_id: this.accountId }, requestOptions),
86
+ );
87
+ }
88
+
89
+ /** Fetch a video's details by UID. */
90
+ async getVideoDetails(videoId: string): Promise<Video> {
91
+ return cloudflareRequest(`Stream get details for '${videoId}'`, () =>
92
+ this.getClient().stream.get(videoId, { account_id: this.accountId }, requestOptions),
93
+ );
94
+ }
95
+
96
+ /** Update a video's metadata and settings. */
97
+ async updateVideo(videoId: string, params: Omit<StreamEditParams, "account_id">): Promise<Video> {
98
+ return cloudflareRequest(`Stream update '${videoId}'`, () =>
99
+ this.getClient().stream.edit(videoId, { account_id: this.accountId, ...params }, requestOptions),
100
+ );
101
+ }
102
+
103
+ /** Read the download status for a video (all artifact types). */
104
+ async videoDownloadStatus(videoId: string): Promise<StreamDownloadStatus> {
105
+ return cloudflareRequest(`Stream download status for '${videoId}'`, async () => {
106
+ const response = await this.getClient().stream.downloads.get(
107
+ videoId,
108
+ { account_id: this.accountId },
109
+ requestOptions,
110
+ );
111
+ return parseDownloadStatus(response);
112
+ });
113
+ }
114
+
115
+ /** Trigger Cloudflare to generate a downloadable MP4 for a video. */
116
+ async createVideoDownload(videoId: string): Promise<StreamDownloadStatus> {
117
+ return cloudflareRequest(`Stream create download for '${videoId}'`, async () => {
118
+ const response = await this.getClient().stream.downloads.create(
119
+ videoId,
120
+ { account_id: this.accountId },
121
+ requestOptions,
122
+ );
123
+ return parseDownloadStatus(response);
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Request an audio-only (M4A) download for a video. The CF Node SDK does not expose the
129
+ * type-specific `POST /downloads/audio` endpoint, so this falls back to a direct `fetch` — the
130
+ * documented escape hatch — authenticated with the manager's API token.
131
+ */
132
+ async createAudioDownload(videoId: string): Promise<DownloadEntry> {
133
+ return cloudflareRequest(`Stream create audio download for '${videoId}'`, async () => {
134
+ const response = await fetch(
135
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/stream/${videoId}/downloads/audio`,
136
+ {
137
+ method: "POST",
138
+ headers: {
139
+ Authorization: `Bearer ${this.getApiToken()}`,
140
+ "Content-Type": "application/json",
141
+ },
142
+ },
143
+ );
144
+ if (!response.ok) {
145
+ throw new CloudflareInvalidResponseError({
146
+ message: "The Stream audio-download request failed.",
147
+ detail: `Stream audio download returned ${response.status}: ${await response.text()}`,
148
+ });
149
+ }
150
+ const json: unknown = await response.json();
151
+ const envelope = AudioDownloadEnvelope.safeParse(json);
152
+ if (!envelope.success) {
153
+ throw new CloudflareInvalidResponseError({
154
+ message: "The Stream audio-download response had an unexpected shape.",
155
+ detail: envelope.error.message,
156
+ });
157
+ }
158
+ return envelope.data.result.audio;
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Read the audio-download status for a video. The SDK has no standalone `GET /downloads/audio`,
164
+ * so this reads the generic downloads endpoint and extracts the `audio` entry.
165
+ */
166
+ async audioDownloadStatus(videoId: string): Promise<DownloadEntry> {
167
+ return cloudflareRequest(`Stream audio download status for '${videoId}'`, async () => {
168
+ const response = await this.getClient().stream.downloads.get(
169
+ videoId,
170
+ { account_id: this.accountId },
171
+ requestOptions,
172
+ );
173
+ const status = parseDownloadStatus(response);
174
+ return status.audio ?? {};
175
+ });
176
+ }
177
+
178
+ /** Create a direct-upload URL for a client-side video upload. */
179
+ async createDirectUpload(params: Omit<DirectUploadCreateParams, "account_id">): Promise<DirectUploadCreateResponse> {
180
+ return cloudflareRequest("Stream create direct upload", () =>
181
+ this.getClient().stream.directUpload.create({ account_id: this.accountId, ...params }, requestOptions),
182
+ );
183
+ }
184
+
185
+ getServiceType(): string {
186
+ return "Cloudflare Stream";
187
+ }
188
+
189
+ /** Prove access by listing videos. Never throws. */
190
+ async validateServiceAccess(): Promise<boolean> {
191
+ try {
192
+ await this.getClient().stream.list({ account_id: this.accountId });
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+ }