@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,95 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import { MEDIA_ASSETS_TABLE, type MediaDatabase } from "../data/tables";
6
+ import { MediaNotFoundError } from "../error/errors";
7
+ import type { ListRecordsOptions, MediaRecord, RecordStore } from "./store";
8
+
9
+ /** Default page size for {@link RecordStore.list}. */
10
+ const DEFAULT_LIMIT = 50;
11
+
12
+ /** The row shape after `schema.encode` — a bag of SQLite-primitive columns. */
13
+ type EncodedRow = Record<string, unknown>;
14
+
15
+ /**
16
+ * The D1-backed record store — the default. Records live in `pithy_media_assets`, one row each, read
17
+ * and written through the effective schema's codecs (dates ↔ ms-epoch, booleans ↔ 0|1). Extension
18
+ * columns ride along automatically: `schema.encode` includes them and Kysely writes every key present,
19
+ * so an adopter's fields persist with no store changes. Derived text (`transcription`, `extractedText`)
20
+ * is a real column, so it is queryable — the reason D1 is the default store.
21
+ */
22
+ export function d1RecordStore(db: MediaDatabase, schema: z.ZodObject): RecordStore {
23
+ /** Decode a raw D1 row into a validated record. */
24
+ const decode = (row: unknown): MediaRecord => schema.parse(row) as MediaRecord;
25
+
26
+ /** Encode an app-shape record into its SQLite row. */
27
+ const encode = (record: MediaRecord): EncodedRow => schema.encode(record) as EncodedRow;
28
+
29
+ const table = db.selectFrom(MEDIA_ASSETS_TABLE);
30
+
31
+ return {
32
+ async create(record) {
33
+ const row = encode(record);
34
+ // Kysely writes every runtime key on the object, so extension columns are inserted too; the cast
35
+ // narrows the compile-time type to the base table without dropping those keys at runtime.
36
+ await db
37
+ .insertInto(MEDIA_ASSETS_TABLE)
38
+ .values(row as never)
39
+ .execute();
40
+ return decode(row);
41
+ },
42
+
43
+ async get(id) {
44
+ const row = await table.selectAll().where("id", "=", id).executeTakeFirst();
45
+ return row ? decode(row) : null;
46
+ },
47
+
48
+ async patch(id, changes) {
49
+ const existing = await table.selectAll().where("id", "=", id).executeTakeFirst();
50
+ if (!existing) {
51
+ throw new MediaNotFoundError({ detail: `no media record to patch for id ${id}` });
52
+ }
53
+ const merged = { ...(decode(existing) as object), ...(changes as object) } as MediaRecord;
54
+ const row = encode(merged);
55
+ await db
56
+ .updateTable(MEDIA_ASSETS_TABLE)
57
+ .set(row as never)
58
+ .where("id", "=", id)
59
+ .execute();
60
+ return decode(row);
61
+ },
62
+
63
+ async delete(id) {
64
+ await db.deleteFrom(MEDIA_ASSETS_TABLE).where("id", "=", id).execute();
65
+ },
66
+
67
+ async list(options: ListRecordsOptions = {}) {
68
+ const limit = options.limit ?? DEFAULT_LIMIT;
69
+ const offset = decodeCursor(options.cursor);
70
+ let query = table.selectAll();
71
+ if (options.type) query = query.where("type", "=", options.type);
72
+ const rows = await query
73
+ .orderBy("createdAt", "desc")
74
+ .orderBy("id", "desc")
75
+ .limit(limit + 1)
76
+ .offset(offset)
77
+ .execute();
78
+ const items = rows.slice(0, limit).map(decode);
79
+ const cursor = rows.length > limit ? encodeCursor(offset + limit) : undefined;
80
+ return { items, cursor };
81
+ },
82
+ };
83
+ }
84
+
85
+ /** Encode an integer offset as an opaque cursor string. */
86
+ function encodeCursor(offset: number): string {
87
+ return String(offset);
88
+ }
89
+
90
+ /** Decode an opaque cursor back to an integer offset; a missing or malformed cursor starts at 0. */
91
+ function decodeCursor(cursor: string | undefined): number {
92
+ if (!cursor) return 0;
93
+ const parsed = Number.parseInt(cursor, 10);
94
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
95
+ }
@@ -0,0 +1,104 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { MediaType } from "../data/enums";
6
+ import { MediaHash } from "../data/mediaHash";
7
+ import { MEDIA_HASHES_TABLE, mediaHashDatabase } from "../data/tables";
8
+
9
+ /**
10
+ * The dedup hash store — always D1, independent of where records live. Written on finalize, read by
11
+ * duplicate search, deleted with the record. Keeping it in its own table (not on the record, not in KV)
12
+ * is what makes `sha256` exact-match and `phash` near-duplicate detection work in every configuration.
13
+ */
14
+
15
+ /** The hash to persist for one media record. */
16
+ export interface HashEntry {
17
+ /** The media record id. */
18
+ mediaId: string;
19
+ /** The media type — scopes the near-duplicate scan to images. */
20
+ mediaType: MediaType;
21
+ /** Lowercase-hex SHA-256 of the file. */
22
+ sha256: string;
23
+ /** Perceptual hash for near-duplicate images; null/undefined for other types. */
24
+ phash?: string | null;
25
+ }
26
+
27
+ /** A `{ mediaId, phash }` pair for near-duplicate scanning. */
28
+ export interface HashPhashEntry {
29
+ /** The media record id. */
30
+ mediaId: string;
31
+ /** The record's perceptual hash. */
32
+ phash: string;
33
+ }
34
+
35
+ /** A `{ mediaId, mediaType }` pair returned by an exact-match lookup. */
36
+ export interface HashMatch {
37
+ /** The media record id. */
38
+ mediaId: string;
39
+ /** The media type. */
40
+ mediaType: MediaType;
41
+ }
42
+
43
+ /** The dedup hash store seam. */
44
+ export interface HashStore {
45
+ /** Insert or replace the hash for a media record (idempotent per `mediaId`). */
46
+ upsert(entry: HashEntry): Promise<void>;
47
+ /** Delete the hash row for a media record. A miss is a no-op. */
48
+ deleteByMedia(mediaId: string): Promise<void>;
49
+ /** Every record whose `sha256` exactly matches — the exact-duplicate lookup. */
50
+ findBySha256(sha256: string): Promise<HashMatch[]>;
51
+ /** Every image record carrying a perceptual hash — the bounded set the near-duplicate scan compares. */
52
+ listImagePhashes(): Promise<HashPhashEntry[]>;
53
+ }
54
+
55
+ /** Build the D1-backed hash store from the `DB` binding. */
56
+ export function d1HashStore(d1: D1Database, now: () => Date = () => new Date()): HashStore {
57
+ const db = mediaHashDatabase(d1);
58
+ return {
59
+ async upsert(entry) {
60
+ const row = MediaHash.encode({
61
+ id: 0, // ignored — auto-increment PK
62
+ mediaId: entry.mediaId,
63
+ mediaType: entry.mediaType,
64
+ sha256: entry.sha256,
65
+ phash: entry.phash ?? null,
66
+ createdAt: now(),
67
+ });
68
+ // `mediaId` is unique — replace any existing row so a re-finalize doesn't duplicate.
69
+ await db
70
+ .insertInto(MEDIA_HASHES_TABLE)
71
+ .values(row as never)
72
+ .onConflict((oc) => oc.column("mediaId").doUpdateSet({ sha256: row.sha256, phash: row.phash }))
73
+ .execute();
74
+ },
75
+
76
+ async deleteByMedia(mediaId) {
77
+ await db.deleteFrom(MEDIA_HASHES_TABLE).where("mediaId", "=", mediaId).execute();
78
+ },
79
+
80
+ async findBySha256(sha256) {
81
+ const rows = await db
82
+ .selectFrom(MEDIA_HASHES_TABLE)
83
+ .select(["mediaId", "mediaType"])
84
+ .where("sha256", "=", sha256)
85
+ .execute();
86
+ return rows.map((row) => ({ mediaId: row.mediaId, mediaType: row.mediaType as MediaType }));
87
+ },
88
+
89
+ async listImagePhashes() {
90
+ const rows = await db
91
+ .selectFrom(MEDIA_HASHES_TABLE)
92
+ .select(["mediaId", "phash"])
93
+ .where("mediaType", "=", "image")
94
+ .where("phash", "is not", null)
95
+ .execute();
96
+ const entries: HashPhashEntry[] = [];
97
+ for (const row of rows) {
98
+ if (typeof row.phash === "string" && row.phash.length > 0)
99
+ entries.push({ mediaId: row.mediaId, phash: row.phash });
100
+ }
101
+ return entries;
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { KVNamespace } from "@cloudflare/workers-types";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { kvMetadata, TypedKv } from "@pithy-sh/core/src/kv/kv";
7
+ import { z } from "zod";
8
+ import { MediaNotFoundError } from "../error/errors";
9
+ import type { ListRecordsOptions, MediaRecord, RecordStore } from "./store";
10
+
11
+ /** Default page size for {@link RecordStore.list}. */
12
+ const DEFAULT_LIMIT = 50;
13
+
14
+ /** The KV key: the fixed `media` prefix plus the record id — `media:<id>`. */
15
+ const MediaKey = z
16
+ .object({ id: z.string().describe("The media record id — the KV key segment.") })
17
+ .describe("The KV key for a media record: `media:<id>`.");
18
+
19
+ /** Fields the store always projects into metadata so `list` can filter by type and sort by recency. */
20
+ const REQUIRED_METADATA_FIELDS = ["type", "createdAt"];
21
+
22
+ /**
23
+ * The KV-backed record store — the `recordStore: 'kv'` opt-in. Records are typed KV values (`media:<id>`),
24
+ * validated whole against the effective schema on every read and write, so extension fields round-trip
25
+ * exactly as in D1 with no columns and no migration.
26
+ *
27
+ * KV is a key-value store, not a query engine. `get`/`patch`/`delete` are direct key lookups. `list` is
28
+ * made scalable by **KV metadata**: `metadataFields` (from `kvMetadata` config) are stored as each entry's
29
+ * metadata, which rides free on a KV `list` — so `list` filters by type, sorts by recency, and paginates
30
+ * from metadata alone, then reads only the returned page's values (bounded by the page size, not the
31
+ * corpus). Put the fields your list views need (an owning `userId`, tags) in `kvMetadata`; KV caps
32
+ * metadata at 1024 bytes, so keep them small. Duplicate detection is not here — it always uses D1.
33
+ */
34
+ export function kvRecordStore(namespace: KVNamespace, schema: z.ZodObject, metadataFields: string[] = []): RecordStore {
35
+ // Always include the fields `list` needs (type, createdAt). Field names are validated against the
36
+ // effective schema at capability construction (see `assertValidKvMetadata`), so no shape filter here —
37
+ // the metadata is derived from the value by name, which lets an adopter's extension field ride along
38
+ // even in the enrichment worker, whose passthrough schema does not carry the extension in its shape.
39
+ const fields = [...new Set([...REQUIRED_METADATA_FIELDS, ...metadataFields])];
40
+ // A size-bounded, permissive metadata object: the projected fields (including extension fields the
41
+ // schema's `shape` may not list) validate as a small denormalized bag for list controls.
42
+ const metadataSchema = kvMetadata(z.record(z.string(), z.unknown()));
43
+
44
+ const kv = new TypedKv(namespace, {
45
+ prefix: "media",
46
+ key: MediaKey,
47
+ value: schema,
48
+ metadata: metadataSchema,
49
+ deriveMetadata: (value) => {
50
+ const record = value as Record<string, unknown>;
51
+ const meta: Record<string, unknown> = {};
52
+ for (const field of fields) {
53
+ if (record[field] !== undefined) meta[field] = record[field];
54
+ }
55
+ return meta;
56
+ },
57
+ });
58
+
59
+ return {
60
+ async create(record) {
61
+ await kv.put({ id: String(record.id) }, record);
62
+ return (await kv.get({ id: String(record.id) })) as MediaRecord;
63
+ },
64
+
65
+ async get(id) {
66
+ return (await kv.get({ id })) as MediaRecord | null;
67
+ },
68
+
69
+ async patch(id, changes) {
70
+ const existing = await kv.get({ id });
71
+ if (!existing) {
72
+ throw new MediaNotFoundError({ detail: `no media record to patch for id ${id}` });
73
+ }
74
+ const merged = { ...(existing as object), ...(changes as object) } as MediaRecord;
75
+ // A re-derive keeps metadata in step with the merged value.
76
+ await kv.put({ id }, merged);
77
+ return merged;
78
+ },
79
+
80
+ async delete(id) {
81
+ await kv.delete({ id });
82
+ },
83
+
84
+ async list(options: ListRecordsOptions = {}) {
85
+ const limit = options.limit ?? DEFAULT_LIMIT;
86
+
87
+ // List every key with its metadata — cheap, no per-value reads. Filter and sort from metadata.
88
+ // `createdAt` round-trips through KV as a JSON (ISO) value, so normalize it to an epoch for sorting.
89
+ const entries: Array<{ id: string; type: unknown; createdAt: number }> = [];
90
+ let listCursor: string | undefined;
91
+ do {
92
+ const page = await kv.list({ cursor: listCursor, limit: 1000 });
93
+ for (const entry of page.keys) {
94
+ const meta = entry.metadata as { type?: unknown; createdAt?: unknown } | null;
95
+ entries.push({ id: entry.key.id, type: meta?.type, createdAt: toEpoch(meta?.createdAt) });
96
+ }
97
+ listCursor = page.cursor;
98
+ } while (listCursor);
99
+
100
+ const filtered = options.type ? entries.filter((entry) => entry.type === options.type) : entries;
101
+ // Newest first — the same order D1 returns.
102
+ filtered.sort((a, b) => b.createdAt - a.createdAt);
103
+
104
+ const offset = decodeCursor(options.cursor);
105
+ const pageEntries = filtered.slice(offset, offset + limit);
106
+ // Read only the page's values (bounded by `limit`, not the corpus size).
107
+ const items: MediaRecord[] = [];
108
+ for (const entry of pageEntries) {
109
+ const value = await kv.get({ id: entry.id });
110
+ if (value) items.push(value as MediaRecord);
111
+ }
112
+ const cursor = offset + limit < filtered.length ? String(offset + limit) : undefined;
113
+ return { items, cursor };
114
+ },
115
+ };
116
+ }
117
+
118
+ /** Decode an opaque cursor back to an integer offset; a missing or malformed cursor starts at 0. */
119
+ function decodeCursor(cursor: string | undefined): number {
120
+ if (!cursor) return 0;
121
+ const parsed = Number.parseInt(cursor, 10);
122
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
123
+ }
124
+
125
+ /** Normalize a metadata `createdAt` (ISO string, epoch number, or Date) to an epoch; 0 when absent/invalid. */
126
+ function toEpoch(value: unknown): number {
127
+ if (value == null) return 0;
128
+ const time = new Date(value as string | number | Date).getTime();
129
+ return Number.isFinite(time) ? time : 0;
130
+ }
131
+
132
+ /**
133
+ * Validate the adopter's `kvMetadata` field names against the effective record schema, so a typo or an
134
+ * unknown field fails fast at capability construction with a clear message — never silently ignored.
135
+ * Called by `media()`; `type`/`createdAt` are always included by the store and need not be listed.
136
+ */
137
+ export function assertValidKvMetadata(fields: readonly string[], schema: z.ZodObject): void {
138
+ const valid = new Set(Object.keys(schema.shape));
139
+ const unknownFields = fields.filter((field) => !valid.has(field));
140
+ if (unknownFields.length > 0) {
141
+ throw new InternalError({
142
+ message: `Unknown kvMetadata field(s): ${unknownFields.join(", ")}.`,
143
+ action: "Every kvMetadata entry must be a record field (a base field or one added via media({ extend })).",
144
+ detail: `Valid record fields: ${[...valid].join(", ")}`,
145
+ });
146
+ }
147
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, KVNamespace } from "@cloudflare/workers-types";
5
+ import type { z } from "zod";
6
+ import type { MediaConfig } from "../config/config";
7
+ import { mediaDatabase } from "../data/tables";
8
+ import { d1RecordStore } from "./d1Store";
9
+ import { d1HashStore, type HashStore } from "./hashStore";
10
+ import { kvRecordStore } from "./kvStore";
11
+ import type { RecordStore } from "./store";
12
+
13
+ /** The bindings the record store reads from the request env: the app `DB` and the `MEDIA` KV namespace. */
14
+ export interface RecordStoreEnv {
15
+ /** The app D1 database the `pithy_media_assets` table lives in (used when `recordStore: 'd1'`). */
16
+ DB: D1Database;
17
+ /** The KV namespace media records live in (used when `recordStore: 'kv'`). */
18
+ MEDIA: KVNamespace;
19
+ }
20
+
21
+ /**
22
+ * Resolve the configured record store from the request env. `recordStore: 'd1'` (default) builds the
23
+ * Kysely-backed D1 store; `recordStore: 'kv'` builds the KV-backed store. Both are bound to the effective
24
+ * schema so extension fields validate and round-trip.
25
+ */
26
+ export function resolveRecordStore(env: RecordStoreEnv, config: MediaConfig, schema: z.ZodObject): RecordStore {
27
+ if (config.recordStore === "kv") return kvRecordStore(env.MEDIA, schema, config.kvMetadata);
28
+ return d1RecordStore(mediaDatabase(env.DB, schema), schema);
29
+ }
30
+
31
+ /** Resolve the dedup hash store — always the D1 `DB` binding, whatever the record store. */
32
+ export function resolveHashStore(env: RecordStoreEnv, now: () => Date = () => new Date()): HashStore {
33
+ return d1HashStore(env.DB, now);
34
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MediaType } from "../data/enums";
5
+ import type { MediaAsset } from "../data/mediaAsset";
6
+
7
+ /**
8
+ * A stored media record: the base {@link MediaAsset} plus whatever fields the adopter added through
9
+ * `media({ extend })`. The extra fields are unknown at the package's type level but validated at runtime
10
+ * by the effective schema, so they round-trip through create/get/list unchanged.
11
+ */
12
+ export type MediaRecord = MediaAsset & Record<string, unknown>;
13
+
14
+ /** Options for listing records. Filtering by `type` is precise in D1 and a scan-filter in KV. */
15
+ export interface ListRecordsOptions {
16
+ /** Restrict to one media type. */
17
+ type?: MediaType;
18
+ /** Maximum records per page. Defaults to 50. */
19
+ limit?: number;
20
+ /** Opaque cursor from a previous page. */
21
+ cursor?: string;
22
+ }
23
+
24
+ /** A page of listed records with an optional continuation cursor. */
25
+ export interface RecordPage {
26
+ /** The records on this page. */
27
+ items: MediaRecord[];
28
+ /** Cursor for the next page, or undefined when the listing is complete. */
29
+ cursor?: string;
30
+ }
31
+
32
+ /**
33
+ * The record-store seam: one interface, two backends. `recordStore: 'd1'` (default) persists to the
34
+ * `pithy_media_assets` table with queryable derived text; `recordStore: 'kv'` persists to a KV namespace
35
+ * (key lookup; `list` scans, documented as such). Both validate every read and write against the effective
36
+ * schema, so an adopter's extension fields survive with no backend-specific work. Duplicate detection is
37
+ * NOT here — it always runs against the D1 {@link HashStore}, independent of the record store.
38
+ */
39
+ export interface RecordStore {
40
+ /** Persist a new record. Returns the stored, re-validated record. */
41
+ create(record: MediaRecord): Promise<MediaRecord>;
42
+ /** Read a record by id, or null on a miss. */
43
+ get(id: string): Promise<MediaRecord | null>;
44
+ /** Merge `changes` over the current record, validate, and write it back. Throws if the id is unknown. */
45
+ patch(id: string, changes: Partial<MediaRecord>): Promise<MediaRecord>;
46
+ /** Delete a record by id. A miss is a no-op. */
47
+ delete(id: string): Promise<void>;
48
+ /** List records, newest first, optionally filtered by type. */
49
+ list(options?: ListRecordsOptions): Promise<RecordPage>;
50
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
5
+ import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * The secrets the media capability reads (CLAUDE.md §secrets). There are two, because they answer to
10
+ * two different owners.
11
+ *
12
+ * `media-storage-credentials` is media's own: the scoped Cloudflare API token that mints Images and
13
+ * Stream direct-upload URLs, and the account id it is scoped to. Nothing else.
14
+ *
15
+ * `media-r2-credentials` is **declared here and read nowhere in this package**. It is
16
+ * `@pithy-sh/storage`'s R2 bundle, declared through that package's `r2CredentialsRegistry` factory and
17
+ * resolved by the `ObjectStore` seam media presigns through. One factory means every declaration of
18
+ * the name agrees on `backend`, `scope`, `rotatable`, and `valueType` — exactly what
19
+ * `aggregateSecretRegistries` requires before it will let two capabilities share a name. So media
20
+ * *names* the R2 secret and never handles an R2 key again.
21
+ *
22
+ * The values are **supplied, not minted**: Cloudflare exposes no API for creating an R2 S3 access-key
23
+ * pair, and the permission catalog carries no Images or Stream keys, so the operator creates both and
24
+ * hands them to `pithy media provision`. Pithy stores, scopes, and rotates them from there.
25
+ */
26
+
27
+ /** The name the Images + Stream credentials are stored and resolved under. */
28
+ export const MEDIA_STORAGE_SECRET = "media-storage-credentials";
29
+
30
+ /**
31
+ * Where a human makes the scoped Images + Stream token, and where Cloudflare rolls it.
32
+ *
33
+ * **Two pages, because the two axes genuinely differ here.** The kit cannot compose the creation command:
34
+ * `pithy token mint` works from a permission catalog, and that catalog carries no Images or Stream group,
35
+ * so an operator picks them in the dashboard. Replacement is not the same act — a Cloudflare API token
36
+ * rolls through Cloudflare's own API and comes back with its successor, which is what `provider` means
37
+ * and what the manager's own token already declares.
38
+ *
39
+ * This is the pair `media-r2-credentials` does *not* have, and the reason rotation is declared per secret
40
+ * rather than per issuer: both come from Cloudflare, and only one of them can replace itself.
41
+ */
42
+ const MEDIA_TOKEN_PAGE = "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/";
43
+ const MEDIA_TOKEN_ROLL = "https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/update/";
44
+
45
+ /** The name media's R2 bucket credentials are stored under — the name `objectStore` is pointed at. */
46
+ export const MEDIA_R2_SECRET = "media-r2-credentials";
47
+
48
+ /** The credential bundle media reads to mint Cloudflare Images and Stream direct-upload URLs. */
49
+ export const MediaStorageCredentials = z
50
+ .object({
51
+ apiToken: z
52
+ .string()
53
+ .describe("A scoped Cloudflare API token with Images and Stream permissions, for minting direct-upload URLs."),
54
+ accountId: z.string().describe("The Cloudflare account id the media resources live in."),
55
+ })
56
+ .describe("The credentials the media capability reads to mint Cloudflare Images and Stream direct-upload URLs.");
57
+ export type MediaStorageCredentials = z.output<typeof MediaStorageCredentials>;
58
+
59
+ /**
60
+ * The R2 half of media's registry — one entry, built by `@pithy-sh/storage`'s factory. Exported on its
61
+ * own so the provisioner can write the secret against the *declared* schema rather than a second copy
62
+ * of the shape, which is the only way the two cannot drift.
63
+ */
64
+ export const mediaR2Registry = r2CredentialsRegistry(MEDIA_R2_SECRET);
65
+
66
+ /** The media capability's secret-registry slice — aggregated into the shared accessor at startup. */
67
+ export const mediaSecretsRegistry = defineSecretRegistry({
68
+ [MEDIA_STORAGE_SECRET]: {
69
+ // An encrypted row in the per-environment secrets D1 — where this value actually lives. No
70
+ // wrangler template binds it from the Cloudflare Secrets Store; `pithy media provision` writes it
71
+ // through `dispatchSecretWrite` → the manager Workflow → `SystemSecretsStore`, the D1 path. The
72
+ // read seam routes strictly on this field, so it has to say where the value really is.
73
+ backend: "d1",
74
+ scope: "environment",
75
+ rotatable: false,
76
+ valueType: "json",
77
+ schema: MediaStorageCredentials,
78
+ origin: { kind: "obtained", issuer: "cloudflare", documentation: MEDIA_TOKEN_PAGE },
79
+ rotation: { kind: "provider", issuer: "cloudflare", documentation: MEDIA_TOKEN_ROLL },
80
+ },
81
+ // Both axes arrive with the entry, from `@pithy-sh/storage`'s factory. Restating them here is how the
82
+ // two declarations of one name would drift, and `aggregateSecretRegistries` is what would then refuse
83
+ // to boot a Worker composing both capabilities.
84
+ ...mediaR2Registry,
85
+ });
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MediaConfig } from "../config/config";
5
+ import type { MediaType, StorageBackend } from "../data/enums";
6
+
7
+ /**
8
+ * Resolve which storage backend a media type uses from config. Images and video are configurable
9
+ * (`r2` | `cf-images`, `r2` | `cf-stream`); audio and documents are always R2.
10
+ */
11
+ export function backendForType(type: MediaType, config: MediaConfig): StorageBackend {
12
+ switch (type) {
13
+ case "image":
14
+ return config.images.store;
15
+ case "video":
16
+ return config.video.store;
17
+ case "audio":
18
+ case "document":
19
+ return "r2";
20
+ }
21
+ }
22
+
23
+ /**
24
+ * The R2 object key for a media record: `media/<type>/<id>`. Namespaced under `media/` so a shared
25
+ * bucket never collides with an adopter's own objects, and partitioned by type for clarity.
26
+ */
27
+ export function mediaR2Key(type: MediaType, id: string): string {
28
+ return `media/${type}/${id}`;
29
+ }
@@ -0,0 +1,77 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CloudflareImageManager } from "@pithy-sh/cloudflare/src/media/imageManager";
5
+ import { type AssetOwner, withAssetOwnership } from "@pithy-sh/cloudflare/src/media/ownership";
6
+ import type { CloudflareStreamManager } from "@pithy-sh/cloudflare/src/media/streamManager";
7
+ import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
8
+ import { z } from "zod";
9
+ import type { ImageMinter, R2Minter, VideoMinter } from "./minter";
10
+
11
+ /**
12
+ * The adapters that fill the {@link ImageMinter}/{@link VideoMinter}/{@link R2Minter} seams.
13
+ *
14
+ * Images and Stream are the SDK-friction boundary: this is the only media file that touches the
15
+ * `@pithy-sh/cloudflare` managers' SDK-typed responses, and it validates the handful of fields it needs
16
+ * with a local Zod object so an unexpected shape fails loudly rather than surfacing as `undefined`.
17
+ *
18
+ * They are also the one boundary where a **name** cannot isolate a project. An R2 key lives inside a
19
+ * bucket already called `<project>-<env>-media`; an Images id and a Stream uid are minted by
20
+ * Cloudflare into one account-flat store. So both minters take an {@link AssetOwner} at construction
21
+ * and stamp it into every asset they create — `withAssetOwnership` merges it **over** the caller's bag,
22
+ * so a caller can neither omit nor overwrite it.
23
+ *
24
+ * R2 is not adapted from an SDK at all. It comes from `@pithy-sh/storage`'s {@link ObjectStore} — the
25
+ * object-plane seam built to be pointed at a second bucket under a second credential name. Media holds
26
+ * no `CloudflareR2Manager`, no key pair, and no bucket name; it holds a store it asks for two URLs.
27
+ */
28
+
29
+ /** The default max video duration (seconds) CF Stream requires for a direct upload — 6 hours. */
30
+ const STREAM_MAX_DURATION_SECONDS = 21600;
31
+
32
+ const ImageUploadResponse = z.object({ id: z.string(), uploadURL: z.string() });
33
+ const StreamUploadResponse = z.object({ uid: z.string(), uploadURL: z.string() });
34
+
35
+ /** Adapt a {@link CloudflareImageManager} to the {@link ImageMinter} seam, stamped for one owner. */
36
+ export function imageMinter(manager: CloudflareImageManager, owner: AssetOwner): ImageMinter {
37
+ return {
38
+ async mintDirectUpload(metadata) {
39
+ const response = ImageUploadResponse.parse(
40
+ await manager.createDirectUploadUrl({ metadata: withAssetOwnership(owner, metadata) }),
41
+ );
42
+ return { id: response.id, uploadUrl: response.uploadURL };
43
+ },
44
+ delete: (id) => manager.deleteImage(id),
45
+ };
46
+ }
47
+
48
+ /** Adapt a {@link CloudflareStreamManager} to the {@link VideoMinter} seam, stamped for one owner. */
49
+ export function videoMinter(manager: CloudflareStreamManager, owner: AssetOwner): VideoMinter {
50
+ return {
51
+ async mintDirectUpload(metadata) {
52
+ const response = StreamUploadResponse.parse(
53
+ await manager.createDirectUpload({
54
+ maxDurationSeconds: STREAM_MAX_DURATION_SECONDS,
55
+ meta: withAssetOwnership(owner, metadata),
56
+ }),
57
+ );
58
+ return { uid: response.uid, uploadUrl: response.uploadURL };
59
+ },
60
+ delete: (uid) => manager.deleteVideo(uid),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Adapt an {@link ObjectStore} to the {@link R2Minter} seam — the whole of media's R2 wiring.
66
+ *
67
+ * Only the presign paths cross here. `deleteObject` and `readR2Object` stay on the `R2Bucket` binding
68
+ * in `storage.ts`: a binding read needs no credential, makes no round trip to resolve one, and streams
69
+ * its body, so routing it through the store would cost something and buy nothing. The store's default
70
+ * expiry (one hour) is the one media wants for both URLs, so neither call names one.
71
+ */
72
+ export function objectStoreMinter(store: ObjectStore): R2Minter {
73
+ return {
74
+ mintUpload: (key, contentType, contentLength) => store.presignPut(key, contentType, contentLength),
75
+ mintDownload: (key) => store.presignGet(key),
76
+ };
77
+ }