@pithy-sh/media 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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +52 -0
  4. package/pithy.manifest.json +85 -0
  5. package/src/ai/enrich.ts +187 -0
  6. package/src/ai/videoBatching.ts +130 -0
  7. package/src/capability.ts +129 -0
  8. package/src/cloudflare-test.d.ts +15 -0
  9. package/src/config/config.ts +155 -0
  10. package/src/data/enums.ts +53 -0
  11. package/src/data/extend.ts +135 -0
  12. package/src/data/mediaAsset.ts +78 -0
  13. package/src/data/mediaHash.ts +24 -0
  14. package/src/data/tables.ts +45 -0
  15. package/src/deliver/url.ts +78 -0
  16. package/src/error/errors.ts +95 -0
  17. package/src/hash/duplicates.ts +129 -0
  18. package/src/hash/sha256.ts +34 -0
  19. package/src/http/dispatch.ts +86 -0
  20. package/src/http/guard.ts +25 -0
  21. package/src/http/handlers.ts +205 -0
  22. package/src/http/routes.ts +119 -0
  23. package/src/http/schemas.ts +90 -0
  24. package/src/index.ts +39 -0
  25. package/src/migrations/0001_init.ts +104 -0
  26. package/src/migrations/extend.ts +40 -0
  27. package/src/provision/provisionMedia.ts +226 -0
  28. package/src/provision/resolveMediaConfig.ts +78 -0
  29. package/src/record/d1Store.ts +95 -0
  30. package/src/record/hashStore.ts +104 -0
  31. package/src/record/kvStore.ts +147 -0
  32. package/src/record/resolve.ts +34 -0
  33. package/src/record/store.ts +50 -0
  34. package/src/secret/registry.ts +85 -0
  35. package/src/storage/backend.ts +29 -0
  36. package/src/storage/cloudflare.ts +77 -0
  37. package/src/storage/minter.ts +57 -0
  38. package/src/storage/resolve.ts +78 -0
  39. package/src/storage/storage.ts +130 -0
  40. package/src/version.generated.ts +16 -0
  41. package/src/workflows/enrich.ts +116 -0
  42. package/src/workflows/hls.ts +99 -0
  43. package/src/workflows/retryPolicy.ts +56 -0
  44. package/src/workflows/specs.ts +75 -0
  45. package/src/workflows/worker.ts +192 -0
  46. package/src/workflows/wrangler.jsonc +73 -0
@@ -0,0 +1,90 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MediaType } from "../data/enums";
6
+
7
+ /**
8
+ * The request schemas for the media routes. Validation happens at the HTTP boundary (CLAUDE.md §Zod),
9
+ * declared on the route line with `zValidator(target, Schema, validationHook)` — so reading `routes.ts`
10
+ * tells you what each route accepts without opening a handler.
11
+ * The create body is `loose` so an adopter's extension fields pass through to be validated against the
12
+ * effective record schema; the known client fields are typed and bounded here.
13
+ */
14
+
15
+ const HEX = /^[0-9a-f]+$/;
16
+
17
+ /**
18
+ * The `:id` path parameter every single-record media route carries. Deliberately a bounded generic
19
+ * string and NOT `z.uuid()`: ids are minted with `crypto.randomUUID()`, but the shape check exists to
20
+ * stop an unbounded string reaching the record store — not to pre-empt the store's own lookup. An
21
+ * unknown-but-well-formed id must still reach the handler and answer `media/not_found` (404), which a
22
+ * UUID check would turn into a 400.
23
+ */
24
+ export const MediaIdParam = z
25
+ .object({
26
+ id: z.string().min(1).max(128).describe("The media record id from the path — bounded, not shape-checked."),
27
+ })
28
+ .describe("The path parameter identifying one media record.");
29
+ export type MediaIdParam = z.output<typeof MediaIdParam>;
30
+
31
+ /** The client-supplied part of a media record on upload-init. Server fields (id, status, storage) are set by the handler. */
32
+ export const CreateMediaInput = z
33
+ .object({
34
+ type: MediaType.describe("The kind of media being uploaded."),
35
+ name: z.string().min(1).describe("A human-readable display name for the media."),
36
+ filename: z.string().min(1).describe("The original client filename, including extension."),
37
+ contentType: z.string().min(1).describe("The MIME type of the bytes being uploaded."),
38
+ size: z.number().int().positive().nullish().describe("The declared size in bytes, if known."),
39
+ sha256: z
40
+ .string()
41
+ .regex(HEX)
42
+ .length(64)
43
+ .nullish()
44
+ .describe("Client-computed lowercase-hex SHA-256 of the file, for exact-match dedup."),
45
+ phash: z.string().regex(HEX).nullish().describe("Client-computed perceptual hash (hex) for near-duplicate images."),
46
+ width: z.number().int().positive().nullish().describe("Original pixel width for images and video."),
47
+ height: z.number().int().positive().nullish().describe("Original pixel height for images and video."),
48
+ })
49
+ .loose()
50
+ .describe("The client-supplied fields for creating a media record; extra keys are adopter extension fields.");
51
+ export type CreateMediaInput = z.output<typeof CreateMediaInput>;
52
+
53
+ /** Optional fields a client may supply on finalize (computed after the upload landed). */
54
+ export const FinalizeMediaInput = z
55
+ .object({
56
+ size: z.number().int().positive().nullish().describe("The final object size in bytes, now that it is known."),
57
+ sha256: z.string().regex(HEX).length(64).nullish().describe("The SHA-256 computed over the uploaded bytes."),
58
+ phash: z.string().regex(HEX).nullish().describe("The perceptual hash computed over the uploaded image."),
59
+ })
60
+ .describe("Optional fields supplied when finalizing an upload.");
61
+ export type FinalizeMediaInput = z.output<typeof FinalizeMediaInput>;
62
+
63
+ /** The duplicate-search request. */
64
+ export const DuplicatesInput = z
65
+ .object({
66
+ type: MediaType.describe("The media type to search within."),
67
+ sha256: z.string().regex(HEX).length(64).describe("The SHA-256 to match exactly."),
68
+ phash: z.string().regex(HEX).optional().describe("The perceptual hash to match near, for images."),
69
+ threshold: z
70
+ .number()
71
+ .int()
72
+ .min(0)
73
+ .max(64)
74
+ .optional()
75
+ .describe("The Hamming distance under which two perceptual hashes count as near-duplicates."),
76
+ limit: z.number().int().min(1).max(50).optional().describe("The maximum number of candidates to return."),
77
+ })
78
+ .describe("A request to find exact and near duplicates of a file.");
79
+ export type DuplicatesInput = z.output<typeof DuplicatesInput>;
80
+
81
+ /** Query parameters for listing media. */
82
+ export const ListMediaQuery = z
83
+ .object({
84
+ type: MediaType.optional().describe("Restrict the listing to one media type."),
85
+ limit: z.coerce.number().int().min(1).max(100).optional().describe("Maximum records per page."),
86
+ // Bounded, but no `.min(1)`: `?cursor=` (empty) already decodes to offset 0 today and must keep working.
87
+ cursor: z.string().max(64).optional().describe("Continuation cursor from a previous page."),
88
+ })
89
+ .describe("Query parameters for the media list route.");
90
+ export type ListMediaQuery = z.output<typeof ListMediaQuery>;
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add media` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, its config and options types, the record and dedup API, and the
7
+ * schemas an app extends. Every other module is imported by deep path (`@pithy-sh/media/src/...`); this
8
+ * is the documented contract, not a barrel over the package.
9
+ */
10
+
11
+ export { isMediaCapability, MEDIA_MIGRATION_ORDER, type MediaCapability, type MediaOptions, media } from "./capability";
12
+ export { MediaConfig, type MediaConfigInput, type MediaDelivery } from "./config/config";
13
+ export {
14
+ EXTRACTABLE_EXTENSIONS,
15
+ isExtractableDocument,
16
+ MediaStatus,
17
+ MediaType,
18
+ StorageBackend,
19
+ } from "./data/enums";
20
+ export { extendMediaAsset } from "./data/extend";
21
+ export { MediaAsset } from "./data/mediaAsset";
22
+ export {
23
+ buildImageUrl,
24
+ buildStreamDashUrl,
25
+ buildStreamHlsUrl,
26
+ buildStreamIframeUrl,
27
+ buildStreamThumbnailUrl,
28
+ mediaUrl,
29
+ } from "./deliver/url";
30
+ export {
31
+ classifyDistance,
32
+ type DuplicateCandidate,
33
+ findDuplicates,
34
+ hammingDistance,
35
+ SIMILAR_THRESHOLD,
36
+ } from "./hash/duplicates";
37
+ export { computeSha256 } from "./hash/sha256";
38
+ export type { HashStore } from "./record/hashStore";
39
+ export type { MediaRecord, RecordStore } from "./record/store";
@@ -0,0 +1,104 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /** What the media schema looks like in the app database, which depends on where records live. */
8
+ export interface MediaInitOptions {
9
+ /**
10
+ * Whether to create the record table `pithy_media_assets`. True for `recordStore: 'd1'`; false for
11
+ * `recordStore: 'kv'`, where records live in KV and only the dedup hash table belongs in D1.
12
+ */
13
+ readonly withAssets: boolean;
14
+ }
15
+
16
+ /**
17
+ * The whole media schema in the app database, in one migration — parameterized rather than chained,
18
+ * because what media creates there is a function of its record store and not of its history.
19
+ *
20
+ * `pithy_media_hashes` is created in **both** modes: dedup is a query workload (an exact `sha256`
21
+ * lookup and a bounded near-`phash` scan) that KV cannot serve, so the hashes always live in D1. The
22
+ * record table `pithy_media_assets` is created for the D1 record store only, which is what
23
+ * `withAssets` selects. One table with a `type` discriminator and nullable per-type derived columns;
24
+ * `sha256`/`phash` are kept on the record for reference, but dedup queries the dedicated hash table,
25
+ * so those columns are not indexed here. Adopter extension columns arrive in the generated
26
+ * `0002_extend` migration (`extend.ts`), which is derived per adopter from their extension schema and
27
+ * is not part of this authored schema.
28
+ *
29
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse,
30
+ * dropping in reverse of what `up` created for the same options.
31
+ *
32
+ * **`recordStore` is chosen once, not migrated between.** A project that flips `kv` → `d1` after its
33
+ * first `pithy migrate` gets no record table, because this migration has already run. That was never a
34
+ * working path: the previous two-migration form would have created an empty table and left every
35
+ * existing record stranded in KV, unreadable and unlisted. Changing record store means a new database
36
+ * and a deliberate copy, not a schema step.
37
+ *
38
+ * See `CONTRIBUTING.md` §Migrations for why a capability's schema is one migration while nothing is
39
+ * published, and what changes the day something is.
40
+ */
41
+ export function media_0001_init({ withAssets }: MediaInitOptions): Migration {
42
+ return {
43
+ up: async (db: Kysely<unknown>): Promise<void> => {
44
+ await db.schema
45
+ .createTable("pithyMediaHashes")
46
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
47
+ // One row per media record, so a re-finalize upserts rather than duplicating.
48
+ .addColumn("mediaId", "text", (c) => c.notNull().unique())
49
+ .addColumn("mediaType", "text", (c) => c.notNull())
50
+ .addColumn("sha256", "text", (c) => c.notNull())
51
+ .addColumn("phash", "text")
52
+ .addColumn("createdAt", "integer", (c) => c.notNull())
53
+ .execute();
54
+
55
+ // Exact-match dedup looks a row up by sha256; the near-duplicate scan reads the bounded
56
+ // (mediaType, phash) image set.
57
+ await db.schema.createIndex("pithyMediaHashesSha256Idx").on("pithyMediaHashes").column("sha256").execute();
58
+ await db.schema
59
+ .createIndex("pithyMediaHashesPhashIdx")
60
+ .on("pithyMediaHashes")
61
+ .columns(["mediaType", "phash"])
62
+ .execute();
63
+
64
+ if (!withAssets) return;
65
+
66
+ await db.schema
67
+ .createTable("pithyMediaAssets")
68
+ .addColumn("id", "text", (c) => c.primaryKey())
69
+ .addColumn("type", "text", (c) => c.notNull())
70
+ .addColumn("status", "text", (c) => c.notNull())
71
+ .addColumn("name", "text", (c) => c.notNull())
72
+ .addColumn("filename", "text", (c) => c.notNull())
73
+ .addColumn("contentType", "text", (c) => c.notNull())
74
+ .addColumn("size", "integer")
75
+ .addColumn("storageBackend", "text", (c) => c.notNull())
76
+ .addColumn("storageKey", "text", (c) => c.notNull())
77
+ .addColumn("sha256", "text")
78
+ .addColumn("phash", "text")
79
+ .addColumn("width", "integer")
80
+ .addColumn("height", "integer")
81
+ .addColumn("altText", "text")
82
+ .addColumn("caption", "text")
83
+ .addColumn("transcription", "text")
84
+ .addColumn("hasTranscription", "integer", (c) => c.notNull().defaultTo(0))
85
+ .addColumn("extractedText", "text")
86
+ .addColumn("hasExtractedText", "integer", (c) => c.notNull().defaultTo(0))
87
+ .addColumn("createdAt", "integer", (c) => c.notNull())
88
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
89
+ .execute();
90
+
91
+ // List/filter by type is the common query; dedup uses pithy_media_hashes, not this table.
92
+ await db.schema.createIndex("pithyMediaAssetsTypeIdx").on("pithyMediaAssets").column("type").execute();
93
+ },
94
+ down: async (db: Kysely<unknown>): Promise<void> => {
95
+ if (withAssets) {
96
+ await db.schema.dropIndex("pithyMediaAssetsTypeIdx").execute();
97
+ await db.schema.dropTable("pithyMediaAssets").execute();
98
+ }
99
+ await db.schema.dropIndex("pithyMediaHashesPhashIdx").execute();
100
+ await db.schema.dropIndex("pithyMediaHashesSha256Idx").execute();
101
+ await db.schema.dropTable("pithyMediaHashes").execute();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+ import type { ExtensionColumn } from "../data/extend";
7
+ import { MEDIA_ASSETS_TABLE } from "../data/tables";
8
+
9
+ /**
10
+ * The generated `0002_extend` migration: it adds one real column to `pithy_media_assets` per adopter
11
+ * extension field, derived from that field's Zod type (see `data/extend.ts`). This is what makes an
12
+ * adopter's extra fields (an owning `userId`, a tenant id, tags) real, migrated D1 columns from the same
13
+ * one schema that validates them on read and write — with no backend-specific work.
14
+ *
15
+ * A `NOT NULL` column added by `ALTER TABLE` needs a default in SQLite (existing rows must get a value),
16
+ * so a required field is added with a type-appropriate default (`0` / `''`); the effective Zod schema
17
+ * still enforces a real value on every write. Optional fields are added nullable. `down` drops the
18
+ * columns in reverse order. Returns `null` when there is nothing to add, so no empty migration is
19
+ * registered.
20
+ */
21
+ export function mediaExtendMigration(columns: ExtensionColumn[]): Migration | null {
22
+ if (columns.length === 0) return null;
23
+ return {
24
+ up: async (db: Kysely<unknown>): Promise<void> => {
25
+ for (const column of columns) {
26
+ await db.schema
27
+ .alterTable(MEDIA_ASSETS_TABLE)
28
+ .addColumn(column.name, column.type, (c) =>
29
+ column.notNull ? c.notNull().defaultTo(column.type === "integer" ? 0 : "") : c,
30
+ )
31
+ .execute();
32
+ }
33
+ },
34
+ down: async (db: Kysely<unknown>): Promise<void> => {
35
+ for (const column of [...columns].reverse()) {
36
+ await db.schema.alterTable(MEDIA_ASSETS_TABLE).dropColumn(column.name).execute();
37
+ }
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,226 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import { resourceNames, type ScopedNames } from "@pithy-sh/core/src/naming/resourceNames";
7
+ import { type ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
8
+ import { MEDIA_CAPABILITY } from "../workflows/specs";
9
+
10
+ /**
11
+ * The provisioning orchestration for the media capability — the live counterpart to `pithy add media`'s
12
+ * config wiring. `pithy add` writes bindings; this stands up what those bindings point at: the R2 bucket
13
+ * media objects live in, the `MEDIA` KV namespace when records live in KV, two per-environment secrets,
14
+ * and the prebuilt media worker that hosts the four enrichment Workflows.
15
+ *
16
+ * **Two secrets, because media reads only one of them.** `media-storage-credentials` carries the Images
17
+ * and Stream token media mints direct-upload URLs with. `media-r2-credentials` carries the R2 key pair
18
+ * and bucket, and is read by `@pithy-sh/storage`'s `ObjectStore` — media declares the name and never
19
+ * touches the values. Both are per environment, because the bucket is (see {@link mediaBucketName}).
20
+ *
21
+ * The live Cloudflare/wrangler steps are behind the {@link MediaProvisioner} seam, so the orchestration
22
+ * (order, idempotency contract, per-env fan-out) is unit-tested without touching Cloudflare. **Every step
23
+ * is idempotent** — find-then-create for resources, create-then-update for the secret, and a deploy that
24
+ * overwrites — so re-running provisioning is a no-op on an already-provisioned account.
25
+ *
26
+ * **What it does not do.** Cloudflare exposes no API for minting an R2 S3 access-key pair, and the
27
+ * permission catalog carries no Images or Stream keys, so nothing here mints a credential. The scoped API
28
+ * token and the R2 key pair are supplied by the operator (flags, or `R2_CREDENTIALS` in `.dev.vars`) and
29
+ * written into the secrets as given. Minting them is a follow-up, not a promise this command keeps today.
30
+ */
31
+
32
+ /**
33
+ * Every name media provisions under, for one project in one environment.
34
+ *
35
+ * One call to the facade, so the project is validated once and the environment once — the R2 bucket,
36
+ * the KV namespace, and the host Worker then each ask for their own kind of resource and get that
37
+ * namespace's own limit. Media names the same capability three times over, in three namespaces whose
38
+ * caps are 63, 512, and 63; going through the facade is what stops one number standing in for all
39
+ * three, and what refuses `production` here rather than quietly provisioning a fourth environment.
40
+ */
41
+ function mediaNames(project: string, env: ManagedEnvironment): ScopedNames {
42
+ return resourceNames(project).env(env);
43
+ }
44
+
45
+ /**
46
+ * The R2 bucket media objects are stored in, **per project and per environment** —
47
+ * `acme-staging-media`, `acme-prod-media`.
48
+ *
49
+ * One bucket per account would mean staging writes objects into the bucket prod reads, and a
50
+ * staging teardown deletes prod's media. Buckets are free — the cost is bytes and operations —
51
+ * so a shared one buys nothing and risks everything. This is the same per-environment posture the
52
+ * media worker itself takes, and the opposite of `@pithy-sh/email`'s deliberately project-shared
53
+ * suppression database, which is shared precisely *because* an unsubscribe must apply everywhere.
54
+ *
55
+ * The project segment carries ownership. R2's namespace is flat and account-wide, and provisioning
56
+ * reuses a bucket it finds by name — so without it, a second Pithy project in the same account adopts
57
+ * this one's bucket instead of creating its own, and either teardown takes both projects' media.
58
+ *
59
+ * Named through the facade's `r2` getter, which carries R2's own rule — 3 to 63 characters, starting
60
+ * and ending alphanumeric — rather than a number this file picked.
61
+ */
62
+ export function mediaBucketName(project: string, env: ManagedEnvironment): string {
63
+ return mediaNames(project, env).r2(MEDIA_CAPABILITY);
64
+ }
65
+
66
+ /**
67
+ * The title of the KV namespace media records live in when `recordStore: 'kv'`. Per project and per
68
+ * environment, for the same reasons — KV titles are account-wide too, and reuse keys on the title.
69
+ *
70
+ * Same string as the bucket today, and deliberately asked for separately: a KV title may run to 512
71
+ * characters and a bucket stops at 63, so the two are the same only for as long as the composed name
72
+ * is short. Asking `kv` for a KV title is what keeps them right when it stops being.
73
+ */
74
+ export function mediaKvTitle(project: string, env: ManagedEnvironment): string {
75
+ return mediaNames(project, env).kv(MEDIA_CAPABILITY);
76
+ }
77
+
78
+ /**
79
+ * The deployed Worker name for a project's environment — also its resolved config basename. Held to
80
+ * the Worker rule (63, refused rather than truncated: a script cannot be renamed after a deploy
81
+ * without orphaning it and every `service` binding pointing at it).
82
+ */
83
+ export function mediaWorkerName(project: string, env: ManagedEnvironment): string {
84
+ return mediaNames(project, env).worker(MEDIA_CAPABILITY);
85
+ }
86
+
87
+ /** The provisioned resources every environment's worker and secret are wired to. */
88
+ export interface MediaResources {
89
+ /** The R2 bucket media objects live in. */
90
+ bucketName: string;
91
+ /** The `MEDIA` KV namespace id, or `null` when records live in D1 and the binding is dropped. */
92
+ kvNamespaceId: string | null;
93
+ }
94
+
95
+ /** The live Cloudflare/wrangler seam. Each step must be idempotent. */
96
+ export interface MediaProvisioner {
97
+ /**
98
+ * Verify account prerequisites before any resource is created — most importantly a registered
99
+ * `workers.dev` subdomain, which Cloudflare requires to deploy the Workflow-hosting media worker.
100
+ * Throws a clear, actionable error so provisioning fails fast and clean rather than mid-deploy.
101
+ */
102
+ preflight(): Promise<void>;
103
+ /** Create (or reuse) this environment's R2 bucket; returns its name. Idempotent. */
104
+ ensureBucket(env: ManagedEnvironment): Promise<{ bucketName: string }>;
105
+ /**
106
+ * Create (or reuse) this environment's `MEDIA` KV namespace, but only when records live in KV;
107
+ * returns `null` otherwise so the resolver drops the binding rather than pointing it at a namespace
108
+ * that was never created. Idempotent.
109
+ */
110
+ ensureKvNamespace(env: ManagedEnvironment): Promise<{ namespaceId: string } | null>;
111
+ /**
112
+ * Write this environment's two secrets: `media-storage-credentials` (the Images + Stream token and the
113
+ * account id) and `media-r2-credentials` (the R2 key pair and this environment's bucket). Idempotent
114
+ * (create, else update). Runs before any worker is deployed: a worker that boots without its
115
+ * credentials fails on its first enrichment.
116
+ */
117
+ writeCredentials(env: ManagedEnvironment, resources: MediaResources): Promise<void>;
118
+ /** Deploy the prebuilt media worker for this environment, wired to the provisioned resources. */
119
+ deployWorker(env: ManagedEnvironment, resources: MediaResources): Promise<void>;
120
+ }
121
+
122
+ /** What provisioning produced, per environment. */
123
+ export interface MediaProvisionResult {
124
+ /** Each environment provisioned, with the resources its worker and secret were wired to. */
125
+ environments: Array<{ env: ManagedEnvironment } & MediaResources>;
126
+ }
127
+
128
+ /**
129
+ * Provision the media infrastructure: check the account, create the bucket and (in KV mode) the
130
+ * namespace once, write every environment's credentials, then deploy every environment's worker. The
131
+ * order is the contract — the resources exist before a secret names them, and the secrets exist before a
132
+ * worker that reads them boots. Idempotent end to end (each step is).
133
+ *
134
+ * `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
135
+ * environment is provisioned; an environment this skipped would be one the project deploys to with no
136
+ * resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
137
+ */
138
+ export async function provisionMedia(
139
+ provisioner: MediaProvisioner,
140
+ environments: DeclaredEnvironments | readonly string[],
141
+ ): Promise<MediaProvisionResult> {
142
+ await provisioner.preflight();
143
+
144
+ // Resources first for every environment, then credentials, then workers — the ordering is the
145
+ // contract. A secret must not name a bucket that does not exist, and a worker must not boot before
146
+ // the secret it reads. Fanning each phase across all environments (rather than completing one
147
+ // environment at a time) means a failure in prod's bucket stops the run before staging's
148
+ // worker is deployed against a half-provisioned account.
149
+ const resources = new Map<ManagedEnvironment, MediaResources>();
150
+ for (const env of managedEnvironments(environments)) {
151
+ const { bucketName } = await provisioner.ensureBucket(env);
152
+ const namespace = await provisioner.ensureKvNamespace(env);
153
+ resources.set(env, { bucketName, kvNamespaceId: namespace?.namespaceId ?? null });
154
+ }
155
+
156
+ for (const env of managedEnvironments(environments)) {
157
+ await provisioner.writeCredentials(env, resourcesFor(resources, env));
158
+ }
159
+ for (const env of managedEnvironments(environments)) {
160
+ await provisioner.deployWorker(env, resourcesFor(resources, env));
161
+ }
162
+
163
+ return {
164
+ environments: managedEnvironments(environments).map((env) => ({ env, ...resourcesFor(resources, env) })),
165
+ };
166
+ }
167
+
168
+ /** Read back an environment's resources. Absent is impossible by construction, so the throw is a bug check. */
169
+ function resourcesFor(resources: Map<ManagedEnvironment, MediaResources>, env: ManagedEnvironment): MediaResources {
170
+ const found = resources.get(env);
171
+ if (!found) throw new InternalError({ message: `No provisioned resources for the ${env} environment.` });
172
+ return found;
173
+ }
174
+
175
+ /** The teardown seam — the inverse of {@link MediaProvisioner}. Every step idempotent (a missing resource is a no-op). */
176
+ export interface MediaDeprovisioner {
177
+ /** Delete the env's media worker. Idempotent (a missing worker is a no-op). */
178
+ deleteWorker(env: ManagedEnvironment): Promise<void>;
179
+ /**
180
+ * Delete this environment's R2 bucket **and every object in it**. **Destructive** — nothing here is
181
+ * recoverable. Idempotent (a missing bucket is a no-op).
182
+ *
183
+ * Emptying the bucket is part of the contract, not a nicety: R2 refuses to delete a bucket that still
184
+ * holds an object, or the parts of a multipart upload that was never completed. An implementation that
185
+ * only called the control-plane delete would work on an untouched bucket and fail on every bucket
186
+ * anyone had used.
187
+ */
188
+ deleteBucket(env: ManagedEnvironment): Promise<void>;
189
+ /** Delete this environment's `MEDIA` KV namespace. **Destructive** — every KV-mode record is lost. Idempotent. */
190
+ deleteKvNamespace(env: ManagedEnvironment): Promise<void>;
191
+ }
192
+
193
+ /** Teardown options. By default the stored media is **kept** — only the workers come down. */
194
+ export interface MediaDeprovisionOptions {
195
+ /**
196
+ * Also delete the R2 bucket — with every object in it — and the `MEDIA` KV namespace. Off by default,
197
+ * because the objects and records they hold are the adopter's data and no deploy can restore them; the
198
+ * flag is the confirmation.
199
+ */
200
+ deleteStorage?: boolean;
201
+ }
202
+
203
+ /**
204
+ * Tear down the media infrastructure, reversing {@link provisionMedia}: delete every environment's worker
205
+ * first (they bind the bucket and namespace), then — only when `deleteStorage` is set — the storage
206
+ * itself, objects and all. Stored media is preserved unless explicitly requested. Idempotent end to end.
207
+ *
208
+ * `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
209
+ * environment is provisioned; an environment this skipped would be one the project deploys to with no
210
+ * resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
211
+ */
212
+ export async function deprovisionMedia(
213
+ deprovisioner: MediaDeprovisioner,
214
+ environments: DeclaredEnvironments | readonly string[],
215
+ options: MediaDeprovisionOptions = {},
216
+ ): Promise<void> {
217
+ for (const env of managedEnvironments(environments)) {
218
+ await deprovisioner.deleteWorker(env);
219
+ }
220
+ if (options.deleteStorage) {
221
+ for (const env of managedEnvironments(environments)) {
222
+ await deprovisioner.deleteBucket(env);
223
+ await deprovisioner.deleteKvNamespace(env);
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,78 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
5
+ import { masterKeySecretName } from "@pithy-sh/secrets/src/provision/provisionSecrets";
6
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
7
+ import type { MediaConfig } from "../config/config";
8
+ import { MEDIA_CAPABILITY, mediaWorkflowRegistry } from "../workflows/specs";
9
+ import type { MediaResources } from "./provisionMedia";
10
+
11
+ /**
12
+ * Resolve the media worker's committed `wrangler.jsonc` template into one environment's standalone
13
+ * config. Every per-environment decision lives here; everything static — the compatibility date, the AI
14
+ * binding, the four Workflow class names — stays as the template committed it.
15
+ *
16
+ * Thin over core's {@link resolveWorkflowHost}: the generic resolver owns the mechanics (clone, fill by
17
+ * binding name, stamp the worker's identity as `ENVIRONMENT` + `PROJECT`), and this file owns only what
18
+ * is media's — which bindings map to which provisioned resource, the Workflow names derived from
19
+ * media's own specs, and the serialized config the worker parses. Pure: the caller parses the template
20
+ * and writes the result.
21
+ *
22
+ * `PROJECT` matters more here than anywhere else the resolver is used: media is the one capability that
23
+ * creates Cloudflare Images and Stream assets, and those two stores are account-flat with
24
+ * Cloudflare-minted ids, so the var is what the ownership metadata on each asset is written from.
25
+ */
26
+
27
+ /** The resolved resource ids + per-env values for one environment's media-worker deploy. */
28
+ export interface MediaConfigParams {
29
+ /**
30
+ * The project name — the `<project>` segment the deployed worker and its four Workflow names lead
31
+ * with. The root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed:
32
+ * Worker script and Workflow names are account-scoped, so a wrong value overwrites another
33
+ * project's running host rather than colliding with it.
34
+ */
35
+ project: string;
36
+ /** The target environment. */
37
+ env: ManagedEnvironment;
38
+ /** The app database id for this environment — where media records and hashes live. */
39
+ appDatabaseId: string;
40
+ /** This environment's secrets database id (`<project>-<env>-secrets`) — holds the storage credentials. */
41
+ secretsDatabaseId: string;
42
+ /** The CF Secrets Store id holding the per-env master key. */
43
+ storeId: string;
44
+ /** The provisioned bucket and KV namespace the worker binds. */
45
+ resources: MediaResources;
46
+ /** The app's resolved media config — serialized into the worker's `MEDIA_CONFIG` var. */
47
+ mediaConfig: MediaConfig;
48
+ }
49
+
50
+ /**
51
+ * Fill the template for one environment.
52
+ *
53
+ * The one media-specific subtlety is the `MEDIA` KV binding: the template declares it unconditionally,
54
+ * because a template describes every binding the capability *might* need. In `recordStore: 'd1'` mode no
55
+ * namespace is ever created, so the binding is dropped rather than left pointing at nothing — a deploy
56
+ * that binds a namespace which does not exist fails opaquely at the worker's first request.
57
+ */
58
+ export function resolveMediaConfig(template: WorkflowHostTemplate, params: MediaConfigParams): WorkflowHostTemplate {
59
+ const { project, env, appDatabaseId, secretsDatabaseId, storeId, resources, mediaConfig } = params;
60
+ const kvNamespaceId = resources.kvNamespaceId;
61
+
62
+ return resolveWorkflowHost(template, {
63
+ project,
64
+ capability: MEDIA_CAPABILITY,
65
+ env,
66
+ databaseIds: { DB: appDatabaseId, SECRETS: secretsDatabaseId },
67
+ ...(kvNamespaceId ? { kvNamespaceIds: { MEDIA: kvNamespaceId } } : { omitKvBindings: ["MEDIA"] }),
68
+ r2BucketNames: { MEDIA_BUCKET: resources.bucketName },
69
+ secretsStoreId: storeId,
70
+ // The master key entry is project- and env-scoped, matching what the secrets manager wrote.
71
+ masterKeySecretName: masterKeySecretName(project, env),
72
+ vars: { MEDIA_CONFIG: JSON.stringify(mediaConfig) },
73
+ // The four enrichment Workflows, derived from the specs rather than from the template's own block.
74
+ // A Workflow name is account-scoped, so the template's `pithy-media-image-to-text` cannot be made
75
+ // project-scoped by suffixing — only the registry knows both the project and the job.
76
+ workflows: hostWorkflowsFor(mediaWorkflowRegistry, { project, capability: MEDIA_CAPABILITY, env }).workflows,
77
+ });
78
+ }