@glw907/cairn-cms 0.57.1 → 0.59.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 (34) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/components/CairnMediaLibrary.svelte +2070 -26
  3. package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
  4. package/dist/components/admin-icons.d.ts +5 -0
  5. package/dist/components/admin-icons.js +5 -0
  6. package/dist/components/cairn-admin.css +402 -3
  7. package/dist/content/media-rewrite.d.ts +65 -0
  8. package/dist/content/media-rewrite.js +442 -0
  9. package/dist/log/events.d.ts +1 -1
  10. package/dist/media/bulk-delete-plan.d.ts +24 -0
  11. package/dist/media/bulk-delete-plan.js +25 -0
  12. package/dist/media/orphan-scan.d.ts +37 -0
  13. package/dist/media/orphan-scan.js +42 -0
  14. package/dist/media/reconcile.d.ts +3 -0
  15. package/dist/media/reconcile.js +3 -2
  16. package/dist/media/rewrite-plan.d.ts +65 -0
  17. package/dist/media/rewrite-plan.js +61 -0
  18. package/dist/sveltekit/cairn-admin.d.ts +8 -0
  19. package/dist/sveltekit/cairn-admin.js +15 -0
  20. package/dist/sveltekit/content-routes.d.ts +118 -4
  21. package/dist/sveltekit/content-routes.js +572 -1
  22. package/dist/sveltekit/index.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/lib/components/CairnMediaLibrary.svelte +2070 -26
  25. package/src/lib/components/admin-icons.ts +5 -0
  26. package/src/lib/content/media-rewrite.ts +555 -0
  27. package/src/lib/log/events.ts +6 -1
  28. package/src/lib/media/bulk-delete-plan.ts +54 -0
  29. package/src/lib/media/orphan-scan.ts +74 -0
  30. package/src/lib/media/reconcile.ts +3 -2
  31. package/src/lib/media/rewrite-plan.ts +122 -0
  32. package/src/lib/sveltekit/cairn-admin.ts +15 -0
  33. package/src/lib/sveltekit/content-routes.ts +722 -5
  34. package/src/lib/sveltekit/index.ts +3 -0
@@ -29,6 +29,14 @@ import { mediaLibraryEntry } from '../media/library-entry.js';
29
29
  import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
30
30
  import { buildUsageIndex } from '../media/usage.js';
31
31
  import type { UsageEntry } from '../media/usage.js';
32
+ import { runReconcile, MEDIA_KEY_RE, type ReconcileBucket } from '../media/reconcile.js';
33
+ import { buildOrphanScan, type OrphanScan } from '../media/orphan-scan.js';
34
+ import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
35
+ import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
36
+ import { planMediaRewrite } from '../media/rewrite-plan.js';
37
+ import type { BranchRef } from '../media/rewrite-plan.js';
38
+ import { planBulkDelete } from '../media/bulk-delete-plan.js';
39
+ import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
32
40
  import type { CookieJar, EventBase } from './types.js';
33
41
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
34
42
  import type { Editor, Role } from '../auth/types.js';
@@ -157,8 +165,10 @@ export interface MediaLibraryData {
157
165
  * redirected commit conflict never overwrite each other. */
158
166
  error: string | null;
159
167
  /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
160
- * `?updated=1`, null otherwise. The component renders a polite success strip for each. */
161
- flash: 'deleted' | 'updated' | null;
168
+ * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
169
+ * `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
170
+ * The component renders a polite success strip for each. */
171
+ flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
162
172
  /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
163
173
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
164
174
  flashError: string | null;
@@ -226,12 +236,112 @@ export interface MediaUpdateFailure {
226
236
  error: string;
227
237
  }
228
238
 
239
+ /** A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
240
+ * typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
241
+ * bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count. */
242
+ export interface MediaReplaceFailure {
243
+ error: string;
244
+ hash: string;
245
+ usage: UsageEntry[];
246
+ foundIn: number;
247
+ }
248
+
249
+ /** A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
250
+ * open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
251
+ * typed-slug gate. */
252
+ export interface MediaAltPropagateFailure {
253
+ error: string;
254
+ }
255
+
256
+ /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
257
+ * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
258
+ * returned summary, not a fail. */
259
+ export interface MediaBulkFailure {
260
+ error: string;
261
+ }
262
+
263
+ /** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
264
+ * partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
265
+ * not on the package subpath, so no reference page. */
266
+ export interface MediaBulkDeleteResult {
267
+ deleted: string[];
268
+ skipped: BulkDeleteSkip[];
269
+ failed: { hash: string; error: string }[];
270
+ }
271
+
272
+ /** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
273
+ * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
274
+ export interface MediaOrphanPurgeResult {
275
+ purged: string[];
276
+ skippedClaimed: string[];
277
+ failed: { key: string; error: string }[];
278
+ }
279
+
280
+ /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
281
+ * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
282
+ * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
283
+ * this. Admin-internal: exported from content-routes for the bundled Media Library component, not
284
+ * added to the package's sveltekit subpath, so it carries no reference page. */
285
+ export interface MediaReplacePreviewEntry {
286
+ /** The concept id, e.g. "posts". */
287
+ concept: string;
288
+ /** The entry id (its filename stem). */
289
+ id: string;
290
+ /** The entry's display title, from the content manifest. */
291
+ title: string;
292
+ /** The entry's public permalink, from the content manifest. */
293
+ permalink?: string;
294
+ /** The per-reference diff for this entry: one placement per repointed `media:` token. */
295
+ placements: RepointPlacement[];
296
+ }
297
+
298
+ /** The replace preview plan: the affected main entries (enriched), the distinct affected count, and
299
+ * the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
300
+ * rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this. */
301
+ export interface MediaReplacePreviewPlan {
302
+ affectedCount: number;
303
+ entries: MediaReplacePreviewEntry[];
304
+ branchDelta: BranchRef[];
305
+ }
306
+
307
+ /** One entry the alt-propagation preview reports, enriched with its display title and permalink from
308
+ * the content manifest. Its placements carry every reference of the asset on this entry, each tagged
309
+ * with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
310
+ * the screen can show what would change. Admin-internal: exported from content-routes for the bundled
311
+ * Media Library component, not added to the package's sveltekit subpath, so it carries no reference
312
+ * page. */
313
+ export interface MediaAltPreviewEntry {
314
+ /** The concept id, e.g. "posts". */
315
+ concept: string;
316
+ /** The entry id (its filename stem). */
317
+ id: string;
318
+ /** The entry's display title, from the content manifest. */
319
+ title: string;
320
+ /** The entry's public permalink, from the content manifest. */
321
+ permalink?: string;
322
+ /** The per-reference diff for this entry: one placement per reference of the asset. */
323
+ placements: AltPlacement[];
324
+ }
325
+
326
+ /** The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
327
+ * cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
328
+ * apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
329
+ * only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
330
+ * can show every bucket; the apply commits only the entries it actually changes. */
331
+ export interface MediaAltPreviewPlan {
332
+ entries: MediaAltPreviewEntry[];
333
+ branchDelta: BranchRef[];
334
+ /** The placement counts by bucket, summed across all entries. */
335
+ counts: { willFill: number; customized: number; decorativeSkipped: number };
336
+ }
337
+
229
338
  /** What a route's single `form` export presents to a view component: whichever content action
230
339
  * last failed, merged with every field optional. `error` is always set on a failure; the richer
231
340
  * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
232
- * `form` prop carries a `?/mediaDelete` or `?/mediaUpdate` refusal without a second type. */
341
+ * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
342
+ * refusal without a second type. */
233
343
  export type ContentFormFailure = Partial<
234
- SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure
344
+ SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure
235
345
  >;
236
346
 
237
347
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
@@ -463,6 +573,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
463
573
  let flash: MediaLibraryData['flash'] = null;
464
574
  if (event.url.searchParams.get('deleted') === '1') flash = 'deleted';
465
575
  else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
576
+ else if (event.url.searchParams.get('replaced') === '1') flash = 'replaced';
577
+ else if (event.url.searchParams.get('altPropagated') === '1') flash = 'altPropagated';
578
+ else if (event.url.searchParams.get('bulkDeleted') === '1') flash = 'bulkDeleted';
579
+ else if (event.url.searchParams.get('orphansPurged') === '1') flash = 'orphansPurged';
466
580
  const flashError = event.url.searchParams.get('error');
467
581
  let token: string;
468
582
  try {
@@ -1410,6 +1524,263 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1410
1524
  throw redirect(303, '/admin/media?deleted=1');
1411
1525
  }
1412
1526
 
1527
+ /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1528
+ * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1529
+ * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1530
+ * would blow the workerd connection budget at many open branches). The fail-closed posture is for
1531
+ * the WHOLE batch: if that single strict index cannot complete, the action refuses everything and
1532
+ * commits nothing, rather than risk deleting bytes a branch still references.
1533
+ *
1534
+ * Skip-and-report, never force: the pure planBulkDelete partitions the selection against the strict
1535
+ * index into deletable (no usage row, a committed manifest row exists), skipped-still-referenced (a
1536
+ * usage row, carried for the where-used), and skipped-uncommitted (no manifest row). An in-use item
1537
+ * is skipped and reported, never bulk-force-deleted; forced in-use deletion stays the single-item
1538
+ * typed-slug path.
1539
+ *
1540
+ * The order is load-bearing, mirroring single delete: ONE atomic commit removes every deletable row
1541
+ * FIRST, then the R2 objects are deleted (commit-row-then-delete-R2). A failure after the commit
1542
+ * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1543
+ * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1544
+ * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1545
+ * (deleted / skipped with reasons / failed); there is no success redirect. */
1546
+ async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
1547
+ const editor = requireSession(event);
1548
+ const token = await mintToken(event.platform?.env ?? {});
1549
+
1550
+ // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1551
+ // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
1552
+ // dropped silently rather than surfaced as a skip (it was never a real selection).
1553
+ const form = await event.request.formData();
1554
+ let raw = form.getAll('hash').map(String);
1555
+ if (raw.length === 0) {
1556
+ const json = form.get('hashes');
1557
+ if (typeof json === 'string') {
1558
+ try {
1559
+ const parsed: unknown = JSON.parse(json);
1560
+ if (Array.isArray(parsed)) raw = parsed.map(String);
1561
+ } catch {
1562
+ raw = [];
1563
+ }
1564
+ }
1565
+ }
1566
+ const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
1567
+
1568
+ // Read the fresh media manifest (the deletable rows come from here, by hash).
1569
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1570
+
1571
+ // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
1572
+ // the commit, exactly like single delete.
1573
+ const resolved = runtime.resolvedAssets;
1574
+ if (!resolved.enabled) {
1575
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1576
+ }
1577
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1578
+ const rawBucket = platformEnv[resolved.bucketBinding];
1579
+ if (!rawBucket) {
1580
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1581
+ }
1582
+ const store = r2Store(rawBucket as R2Bucket);
1583
+
1584
+ // THE fail-closed gate for the whole batch: one shared strict usage index. STRICT mode rethrows a
1585
+ // branch-read failure, so a transient branch read failing refuses the whole batch rather than
1586
+ // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
1587
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1588
+ try {
1589
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1590
+ } catch {
1591
+ return fail(503, { error: 'Could not verify where these assets are used. Try again.' } satisfies MediaBulkFailure);
1592
+ }
1593
+
1594
+ // The pure partition: membership in the fresh strict index is the gate, never the display count.
1595
+ const plan = planBulkDelete(selected, index, manifest);
1596
+ // An all-skipped or empty batch is a no-op success: nothing committed, nothing deleted.
1597
+ if (plan.deletable.length === 0) {
1598
+ return { deleted: [], skipped: plan.skipped, failed: [] } satisfies MediaBulkDeleteResult;
1599
+ }
1600
+
1601
+ // ONE atomic commit removing EVERY deletable row, folded over removeMediaEntry.
1602
+ let next = manifest;
1603
+ for (const hash of plan.deletable) next = removeMediaEntry(next, hash);
1604
+ const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
1605
+ try {
1606
+ await commitFiles(
1607
+ runtime.backend,
1608
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }],
1609
+ { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } },
1610
+ token,
1611
+ );
1612
+ log.info('commit.succeeded', commitFields);
1613
+ } catch (err) {
1614
+ commitFailure(commitFields, err, '/admin/media',
1615
+ 'The media manifest changed since you opened it. Reload and try again.');
1616
+ }
1617
+
1618
+ // THEN delete each deletable hash's R2 object (the load-bearing order, see the docstring). Best
1619
+ // effort and batch-resilient: a thrown key derivation or a delete error is reported in `failed`
1620
+ // and the loop continues. An absent object is a no-op (the R2 contract).
1621
+ const deleted: string[] = [];
1622
+ const failed: { hash: string; error: string }[] = [];
1623
+ for (const hash of plan.deletable) {
1624
+ try {
1625
+ const row = manifest[hash];
1626
+ await store.delete(r2Key(row.hash, row.ext));
1627
+ deleted.push(hash);
1628
+ } catch (err) {
1629
+ failed.push({ hash, error: err instanceof Error ? err.message : String(err) });
1630
+ }
1631
+ }
1632
+
1633
+ log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
1634
+ return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
1635
+ }
1636
+
1637
+ /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1638
+ * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1639
+ * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1640
+ * plus a reconcile pass on top of the strict usage build.
1641
+ *
1642
+ * Detection-time fail-closed: BOTH the reconcile and the strict usage build run inside one
1643
+ * try/catch, and any throw refuses the whole scan with fail(503) rather than returning a partial
1644
+ * result. The reconcile must not run on a half-listed bucket: a truncated R2 list would call
1645
+ * still-stored bytes orphaned. The strict usage build must not run on a half-read branch set: an
1646
+ * unread branch would make a branch-referenced asset look orphaned. A wrong orphan verdict here
1647
+ * feeds the irreversible purge, so the scan refuses rather than risk it.
1648
+ *
1649
+ * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1650
+ * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1651
+ * where-used so an operator can re-ingest rather than purge a still-referenced record). */
1652
+ async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
1653
+ requireSession(event);
1654
+ const token = await mintToken(event.platform?.env ?? {});
1655
+
1656
+ // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
1657
+ // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
1658
+ const resolved = runtime.resolvedAssets;
1659
+ if (!resolved.enabled) {
1660
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1661
+ }
1662
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1663
+ const rawBucket = platformEnv[resolved.bucketBinding];
1664
+ if (!rawBucket) {
1665
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1666
+ }
1667
+
1668
+ // Read the fresh media manifest for the reconcile's manifest side.
1669
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1670
+
1671
+ // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
1672
+ // and the strict usage build (a branch read that must complete in full) are both unsafe to use
1673
+ // partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
1674
+ // would feed the irreversible purge.
1675
+ let reconcile: Awaited<ReturnType<typeof runReconcile>>;
1676
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1677
+ try {
1678
+ reconcile = await runReconcile(rawBucket as unknown as ReconcileBucket, manifest);
1679
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1680
+ } catch {
1681
+ return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' } satisfies MediaBulkFailure);
1682
+ }
1683
+
1684
+ return buildOrphanScan(reconcile, manifest, index);
1685
+ }
1686
+
1687
+ /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1688
+ * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1689
+ * history. The whole action is built around that fact.
1690
+ *
1691
+ * The typed-count confirm is the never-bypassable gate, the analogue of single delete's typed-slug
1692
+ * check. The form's `confirm` must equal the count of selected keys (the approved rev.2 mockup's
1693
+ * "Type N to purge these files for good"); an empty selection or a mismatched count deletes nothing.
1694
+ *
1695
+ * Re-derive fresh is the safety crux. The selection came from an earlier scan, so the action does
1696
+ * NOT trust it: the purge keys are client-posted, so the server cannot assume they came from a fresh
1697
+ * scan. It reads the current media manifest AND rebuilds ONE strict cross-branch usage index, then
1698
+ * for each selected key parses the hash from the key grammar. A key that does not match the grammar
1699
+ * was never a real orphan key and is dropped silently. A key whose hash now has a manifest row OR is
1700
+ * referenced on any open cairn/* branch survived the scan window (it was claimed by a row, or a
1701
+ * draft started referencing those bytes), so it is skipped into skippedClaimed and its bytes survive.
1702
+ * Only a key whose hash is STILL absent from both is purged. This closes the TOCTOU between scan and
1703
+ * purge that could otherwise irreversibly delete a live draft's bytes.
1704
+ *
1705
+ * Like the scan and the bulk delete, the strict index build is the fail-closed gate: a branch read
1706
+ * that throws refuses the whole batch with fail(503) rather than mistaking an unverifiable reference
1707
+ * for an absent one. The index is built exactly once for the batch, never once per key.
1708
+ *
1709
+ * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1710
+ * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1711
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
1712
+ async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
1713
+ const editor = requireSession(event);
1714
+ const token = await mintToken(event.platform?.env ?? {});
1715
+
1716
+ // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
1717
+ // deletes through the MediaStore seam, so wrap the raw binding.
1718
+ const resolved = runtime.resolvedAssets;
1719
+ if (!resolved.enabled) {
1720
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1721
+ }
1722
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1723
+ const rawBucket = platformEnv[resolved.bucketBinding];
1724
+ if (!rawBucket) {
1725
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1726
+ }
1727
+ const store = r2Store(rawBucket as R2Bucket);
1728
+
1729
+ // Read the selected R2 keys and the typed confirm.
1730
+ const form = await event.request.formData();
1731
+ const keys = form.getAll('key').map(String);
1732
+ const confirm = String(form.get('confirm') ?? '');
1733
+
1734
+ // The irreversible gate: the confirm must equal the selected count, and the set must be non-empty.
1735
+ // A mismatch or an empty set refuses and deletes NOTHING.
1736
+ if (keys.length === 0 || confirm !== String(keys.length)) {
1737
+ return fail(400, { error: 'Type the number of files to confirm the purge.' } satisfies MediaBulkFailure);
1738
+ }
1739
+
1740
+ // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
1741
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1742
+
1743
+ // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
1744
+ // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
1745
+ // branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
1746
+ // treated as a true orphan. Build exactly one index, never one per key.
1747
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1748
+ try {
1749
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1750
+ } catch {
1751
+ return fail(503, { error: 'Could not verify where these files are used. Try again.' } satisfies MediaBulkFailure);
1752
+ }
1753
+
1754
+ const purged: string[] = [];
1755
+ const skippedClaimed: string[] = [];
1756
+ const failed: { key: string; error: string }[] = [];
1757
+ for (const key of keys) {
1758
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
1759
+ // A key that does not match the grammar was never a real orphan key: drop it silently.
1760
+ if (hash === undefined) continue;
1761
+ // A hash that now has a manifest row was claimed since the scan: its bytes are a live asset now.
1762
+ if (manifest[hash]) {
1763
+ skippedClaimed.push(key);
1764
+ continue;
1765
+ }
1766
+ // A hash referenced on any open cairn/* branch backs an in-progress draft: skip it claimed too.
1767
+ if (index.has(hash)) {
1768
+ skippedClaimed.push(key);
1769
+ continue;
1770
+ }
1771
+ // Still orphaned: delete the object directly. No commit, there is no manifest row.
1772
+ try {
1773
+ await store.delete(key);
1774
+ purged.push(key);
1775
+ } catch (err) {
1776
+ failed.push({ key, error: err instanceof Error ? err.message : String(err) });
1777
+ }
1778
+ }
1779
+
1780
+ log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
1781
+ return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
1782
+ }
1783
+
1413
1784
  /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1414
1785
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1415
1786
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
@@ -1452,7 +1823,353 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1452
1823
  throw redirect(303, '/admin/media?updated=1');
1453
1824
  }
1454
1825
 
1455
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1826
+ /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
1827
+ * an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
1828
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
1829
+ function replacementToken(slug: string, hash: string): string {
1830
+ return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
1831
+ }
1832
+
1833
+ /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
1834
+ * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
1835
+ * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
1836
+ * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
1837
+ * rather than a partial plan, so the confirm dialog never shows a count it cannot stand behind.
1838
+ *
1839
+ * Wire contract: a fetch POST with the JSON body `{ oldHash, newHash, slug }`, the CSRF token in
1840
+ * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
1841
+ * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
1842
+ * with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
1843
+ * `type`/`status` from the body, never the HTTP status. */
1844
+ async function mediaReplacePreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan> {
1845
+ // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
1846
+ // upload action. A failed check refuses before the session read or any GitHub call.
1847
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1848
+ return fail(403, { error: 'csrf', hash: '', usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
1849
+ }
1850
+ requireSession(event);
1851
+
1852
+ // Parse the JSON body. A malformed body or a hash that fails the 16-hex grammar refuses with a 400
1853
+ // before any GitHub read. The slug is the OLD asset's: a replace keeps the name and changes only the
1854
+ // content hash, so the repointed token carries the existing slug (an invalid slug falls back to a
1855
+ // bare-hash token below). It is cosmetic for the preview display; the apply re-derives it server-side.
1856
+ let payload: { oldHash?: unknown; newHash?: unknown; slug?: unknown };
1857
+ try {
1858
+ payload = JSON.parse(await event.request.text());
1859
+ } catch {
1860
+ return fail(400, { error: 'Could not read the replace request.', hash: '', usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
1861
+ }
1862
+ const oldHash = String(payload.oldHash ?? '');
1863
+ const newHash = String(payload.newHash ?? '');
1864
+ const slug = String(payload.slug ?? '');
1865
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
1866
+ return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
1867
+ }
1868
+
1869
+ const token = await mintToken(event.platform?.env ?? {});
1870
+ const contentManifest = await readManifest(token);
1871
+ const newToken = replacementToken(slug, newHash);
1872
+
1873
+ // Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
1874
+ // throws out of here rather than degrading to an absent reference; catch it and fail closed, the
1875
+ // same posture the delete gate takes.
1876
+ let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
1877
+ try {
1878
+ plan = await planMediaRewrite<RepointPlacement>({
1879
+ backend: runtime.backend,
1880
+ token,
1881
+ concepts: runtime.concepts,
1882
+ contentManifest,
1883
+ hash: oldHash,
1884
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1885
+ });
1886
+ } catch {
1887
+ return fail(503, {
1888
+ error: 'Could not verify where this asset is used. Try again.',
1889
+ hash: oldHash,
1890
+ usage: [],
1891
+ foundIn: 0,
1892
+ } satisfies MediaReplaceFailure);
1893
+ }
1894
+
1895
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
1896
+ // carries neither). A planned entry always has a manifest row (the usage index is built from the
1897
+ // manifest), so the lookup hits; an id-only fallback keeps the type total if a row is ever absent.
1898
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
1899
+ const entries: MediaReplacePreviewEntry[] = plan.entries.map((e) => {
1900
+ const row = byKey.get(`${e.concept}/${e.id}`);
1901
+ return {
1902
+ concept: e.concept,
1903
+ id: e.id,
1904
+ title: row?.title ?? e.id,
1905
+ permalink: row?.permalink,
1906
+ placements: e.placements,
1907
+ };
1908
+ });
1909
+
1910
+ return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
1911
+ }
1912
+
1913
+ /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
1914
+ * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
1915
+ * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
1916
+ * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
1917
+ * (unlike delete, which only gates an in-use asset): a replace silently repoints published content,
1918
+ * so it always demands the type-to-confirm. An empty stored slug is never satisfiable, exactly like
1919
+ * delete. The plan runs strict, so an unverifiable usage read fails the replace closed (commits
1920
+ * nothing) rather than rewriting some references and leaving others.
1921
+ *
1922
+ * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
1923
+ * bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
1924
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
1925
+ async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1926
+ const editor = requireSession(event);
1927
+ const token = await mintToken(event.platform?.env ?? {});
1928
+
1929
+ const form = await event.request.formData();
1930
+ const oldHash = String(form.get('oldHash') ?? '');
1931
+ const newHash = String(form.get('newHash') ?? '');
1932
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) throw error(400, 'Invalid media hash');
1933
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1934
+
1935
+ // The new asset's optimistic record rides the post (the same untrusted-record contract as save).
1936
+ // Find the row for newHash; its absence is a malformed or missing replacement, a 400.
1937
+ const record = parseMediaEntries(form.get('media')).find((r) => r.hash === newHash);
1938
+ if (!record) {
1939
+ return fail(400, {
1940
+ error: 'The replacement upload is missing or invalid.',
1941
+ hash: oldHash,
1942
+ usage: [],
1943
+ foundIn: 0,
1944
+ } satisfies MediaReplaceFailure);
1945
+ }
1946
+
1947
+ // The old asset must be committed on main to be replaceable here. A branch-only upload has no main
1948
+ // row; it is replaced by editing its draft, not here.
1949
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1950
+ const row = manifest[oldHash];
1951
+ if (!row) {
1952
+ return fail(404, {
1953
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1954
+ hash: oldHash,
1955
+ usage: [],
1956
+ foundIn: 0,
1957
+ } satisfies MediaReplaceFailure);
1958
+ }
1959
+
1960
+ // Media-enabled guard only: replace does no R2 write (the new bytes are already stored, the old
1961
+ // bytes are kept), so there is no bucket binding to resolve. Media-off still refuses before any
1962
+ // git write.
1963
+ if (!runtime.resolvedAssets.enabled) {
1964
+ return fail(503, { error: 'Media is not enabled for this site.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
1965
+ }
1966
+
1967
+ // Re-derive the plan from a FRESH content-manifest read (never trust a client plan). The planner
1968
+ // runs strict, so an unverifiable branch read throws; catch it and fail the replace closed (commit
1969
+ // nothing) rather than rewriting a partial set of references. The repointed token keeps the OLD
1970
+ // asset's slug (server-authoritative `row.slug`): a replace changes only the content hash, so the
1971
+ // name in every reference stays the same (the new bytes resolve by hash regardless of the slug).
1972
+ const newToken = replacementToken(row.slug, record.hash);
1973
+ let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
1974
+ try {
1975
+ plan = await planMediaRewrite<RepointPlacement>({
1976
+ backend: runtime.backend,
1977
+ token,
1978
+ concepts: runtime.concepts,
1979
+ contentManifest: await readManifest(token),
1980
+ hash: oldHash,
1981
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1982
+ });
1983
+ } catch {
1984
+ return fail(503, {
1985
+ error: 'Could not verify where this asset is used. Try again.',
1986
+ hash: oldHash,
1987
+ usage: [],
1988
+ foundIn: 0,
1989
+ } satisfies MediaReplaceFailure);
1990
+ }
1991
+
1992
+ // The typed-slug gate, ALWAYS required for replace. A blank stored slug can never be satisfied by
1993
+ // the empty default, so it is treated as never-confirmed (the confirm cannot be bypassed).
1994
+ if (row.slug === '' || confirmSlug !== row.slug) {
1995
+ log.warn('media.replace_blocked', { editor: editor.email, hash: oldHash, foundIn: plan.affectedCount });
1996
+ return fail(409, {
1997
+ error: `Type ${row.slug} to confirm replacing it in ${plan.affectedCount} ${plan.affectedCount === 1 ? 'entry' : 'entries'}.`,
1998
+ hash: oldHash,
1999
+ usage: [],
2000
+ foundIn: plan.affectedCount,
2001
+ } satisfies MediaReplaceFailure);
2002
+ }
2003
+
2004
+ // Commit atomically: every rewritten entry plus the new media.json row (the OLD row stays, so the
2005
+ // old bytes keep a row). One commit, the same conflict handling as delete.
2006
+ const changes: FileChange[] = plan.entries.map((e) => ({ path: e.path, content: e.newMarkdown }));
2007
+ changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
2008
+
2009
+ const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
2010
+ try {
2011
+ await commitFiles(
2012
+ runtime.backend,
2013
+ changes,
2014
+ { message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
2015
+ token,
2016
+ );
2017
+ log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
2018
+ } catch (err) {
2019
+ commitFailure(commitFields, err, '/admin/media',
2020
+ 'The site changed since you opened it. Reload and try again.');
2021
+ }
2022
+ throw redirect(303, '/admin/media?replaced=1');
2023
+ }
2024
+
2025
+ /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
2026
+ * asset's default alt across every published main entry that references it, bucketing each placement
2027
+ * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
2028
+ * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
2029
+ * The plan runs strict (fail-closed): an unverifiable usage read returns a 503 rather than a partial
2030
+ * plan, so the dialog never shows a count it cannot stand behind.
2031
+ *
2032
+ * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
2033
+ * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
2034
+ * ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
2035
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
2036
+ async function mediaAltPreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaAltPreviewPlan> {
2037
+ // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
2038
+ // replace-preview actions. A failed check refuses before the session read or any GitHub call.
2039
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
2040
+ return fail(403, { error: 'csrf' } satisfies MediaAltPropagateFailure);
2041
+ }
2042
+ requireSession(event);
2043
+
2044
+ let payload: { hash?: unknown };
2045
+ try {
2046
+ payload = JSON.parse(await event.request.text());
2047
+ } catch {
2048
+ return fail(400, { error: 'Could not read the request.' } satisfies MediaAltPropagateFailure);
2049
+ }
2050
+ const hash = String(payload.hash ?? '');
2051
+ if (!MEDIA_HASH_RE.test(hash)) {
2052
+ return fail(400, { error: 'Invalid media hash.' } satisfies MediaAltPropagateFailure);
2053
+ }
2054
+
2055
+ const token = await mintToken(event.platform?.env ?? {});
2056
+ // The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
2057
+ // asset with no committed row has no default alt to push, so refuse.
2058
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2059
+ const row = mediaManifest[hash];
2060
+ if (!row) {
2061
+ return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
2062
+ }
2063
+
2064
+ // Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
2065
+ // it and fail closed, the same posture replace and delete take.
2066
+ const contentManifest = await readManifest(token);
2067
+ let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
2068
+ try {
2069
+ plan = await planMediaRewrite<AltPlacement>({
2070
+ backend: runtime.backend,
2071
+ token,
2072
+ concepts: runtime.concepts,
2073
+ contentManifest,
2074
+ hash,
2075
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite: false }),
2076
+ });
2077
+ } catch {
2078
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' } satisfies MediaAltPropagateFailure);
2079
+ }
2080
+
2081
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
2082
+ // carries neither), and aggregate the bucket counts across every placement.
2083
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
2084
+ const counts = { willFill: 0, customized: 0, decorativeSkipped: 0 };
2085
+ const entries: MediaAltPreviewEntry[] = plan.entries.map((e) => {
2086
+ for (const p of e.placements) {
2087
+ if (p.bucket === 'will-fill') counts.willFill += 1;
2088
+ else if (p.bucket === 'customized') counts.customized += 1;
2089
+ else counts.decorativeSkipped += 1;
2090
+ }
2091
+ const manifestRow = byKey.get(`${e.concept}/${e.id}`);
2092
+ return {
2093
+ concept: e.concept,
2094
+ id: e.id,
2095
+ title: manifestRow?.title ?? e.id,
2096
+ permalink: manifestRow?.permalink,
2097
+ placements: e.placements,
2098
+ };
2099
+ });
2100
+
2101
+ return { entries, branchDelta: plan.branchDelta, counts };
2102
+ }
2103
+
2104
+ /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
2105
+ * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
2106
+ * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
2107
+ * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
2108
+ * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
2109
+ * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
2110
+ * that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
2111
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
2112
+ async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2113
+ const editor = requireSession(event);
2114
+ const token = await mintToken(event.platform?.env ?? {});
2115
+
2116
+ const form = await event.request.formData();
2117
+ const hash = String(form.get('hash') ?? '');
2118
+ if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
2119
+ // The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
2120
+ const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
2121
+
2122
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2123
+ const row = mediaManifest[hash];
2124
+ if (!row) {
2125
+ return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
2126
+ }
2127
+
2128
+ // Media-enabled guard only: alt fill does no R2 write, so there is no bucket binding to resolve.
2129
+ if (!runtime.resolvedAssets.enabled) {
2130
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaAltPropagateFailure);
2131
+ }
2132
+
2133
+ // Re-derive from a FRESH content-manifest read with the actual overwrite choice. Strict, so an
2134
+ // unverifiable branch read throws; catch it and fail closed (commit nothing).
2135
+ let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
2136
+ try {
2137
+ plan = await planMediaRewrite<AltPlacement>({
2138
+ backend: runtime.backend,
2139
+ token,
2140
+ concepts: runtime.concepts,
2141
+ contentManifest: await readManifest(token),
2142
+ hash,
2143
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
2144
+ });
2145
+ } catch {
2146
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' } satisfies MediaAltPropagateFailure);
2147
+ }
2148
+
2149
+ // Commit only the entries the transform actually changed. A reported-but-unchanged placement (a
2150
+ // kept custom alt, a decorative hero) has after === before, so an entry with only those is a no-op
2151
+ // and is excluded. Nothing changed at all is a successful no-op: skip the commit, still redirect.
2152
+ const changed = plan.entries.filter((e) => e.placements.some((p) => p.after !== p.before));
2153
+ if (changed.length === 0) throw redirect(303, '/admin/media?altPropagated=1');
2154
+
2155
+ const changes: FileChange[] = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
2156
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
2157
+ try {
2158
+ await commitFiles(
2159
+ runtime.backend,
2160
+ changes,
2161
+ { message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
2162
+ token,
2163
+ );
2164
+ log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
2165
+ } catch (err) {
2166
+ commitFailure(commitFields, err, '/admin/media',
2167
+ 'The site changed since you opened it. Reload and try again.');
2168
+ }
2169
+ throw redirect(303, '/admin/media?altPropagated=1');
2170
+ }
2171
+
2172
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
1456
2173
  }
1457
2174
 
1458
2175
  /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,