@glw907/cairn-cms 0.56.2 → 0.57.1

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 (173) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +22 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +64 -11
  47. package/dist/content/validate.js +31 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +578 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +20 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +80 -13
  138. package/src/lib/content/validate.ts +29 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +589 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -17,8 +17,15 @@ import { cachedInstallationToken } from '../github/signing.js';
17
17
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
18
18
  import { isConflict } from '../github/types.js';
19
19
  import { log } from '../log/index.js';
20
- import { issueCsrfToken } from './csrf.js';
20
+ import { issueCsrfToken, validateCsrfHeader } from './csrf.js';
21
21
  import { requireSession } from './guard.js';
22
+ import { sniffMediaType, isDeniedUpload, extForMediaType } from '../media/sniff.js';
23
+ import { hashBytes, shortHash, slugifyFilename, r2Key } from '../media/naming.js';
24
+ import { mediaToken } from '../media/reference.js';
25
+ import { r2Store } from '../media/store.js';
26
+ import { parseMediaEntries, parseMediaManifest, upsertMediaEntry, removeMediaEntry, serializeMediaManifest } from '../media/manifest.js';
27
+ import { mediaLibraryEntry } from '../media/library-entry.js';
28
+ import { buildUsageIndex } from '../media/usage.js';
22
29
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
23
30
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
24
31
  * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
@@ -47,6 +54,19 @@ export function createContentRoutes(runtime, deps = {}) {
47
54
  const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
48
55
  return raw === null ? emptyManifest() : parseManifest(raw);
49
56
  }
57
+ /** Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
58
+ * or corrupt file to null (an empty manifest). The committed file is always our own serialization,
59
+ * so the catch only guards a hand-edited or truncated file rather than a normal path. */
60
+ function parseMediaJson(raw) {
61
+ if (raw === null)
62
+ return null;
63
+ try {
64
+ return JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
50
70
  /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
51
71
  * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
52
72
  * the path confinement), or a concept this site does not configure. Every ref consumer
@@ -191,6 +211,78 @@ export function createContentRoutes(runtime, deps = {}) {
191
211
  return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
192
212
  }
193
213
  }
214
+ /** The admin Media Library load: union the media manifest across main and every open cairn/*
215
+ * branch (so a not-yet-published asset shows), project each row through the shared
216
+ * mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
217
+ * hash. The assets union and the usage overlay degrade independently: a usage-build failure
218
+ * still lists the assets with an empty overlay, and a wholesale read failure degrades to the
219
+ * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
220
+ async function mediaLibraryLoad(event) {
221
+ requireSession(event);
222
+ // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
223
+ // `?publishedAll` grammar: a deleted/updated success flag and a commit-conflict error. The
224
+ // conflict error rides its own slot so it never collides with the degraded-load `error` below.
225
+ let flash = null;
226
+ if (event.url.searchParams.get('deleted') === '1')
227
+ flash = 'deleted';
228
+ else if (event.url.searchParams.get('updated') === '1')
229
+ flash = 'updated';
230
+ const flashError = event.url.searchParams.get('error');
231
+ let token;
232
+ try {
233
+ token = await mintToken(event.platform?.env ?? {});
234
+ }
235
+ catch {
236
+ return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
237
+ }
238
+ // Union the media manifest by hash: main's rows first, then any branch hash not already present.
239
+ // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
240
+ // absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
241
+ // The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
242
+ // not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
243
+ const union = new Map();
244
+ let branchNames = [];
245
+ try {
246
+ const mediaRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
247
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
248
+ union.set(hash, e);
249
+ }
250
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
251
+ branchNames = names;
252
+ const branchManifests = await Promise.all(names.map((name) => readRaw({ ...runtime.backend, branch: name }, runtime.mediaManifestPath, token)
253
+ .then((raw) => parseMediaManifest(parseMediaJson(raw)))
254
+ .catch(() => ({}))));
255
+ for (const manifest of branchManifests) {
256
+ for (const [hash, e] of Object.entries(manifest)) {
257
+ if (!union.has(hash))
258
+ union.set(hash, e);
259
+ }
260
+ }
261
+ }
262
+ catch {
263
+ // A wholesale read failure leaves whatever rows were already unioned; the screen lists them
264
+ // with no usage overlay rather than failing.
265
+ return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.', flash, flashError };
266
+ }
267
+ const assets = [...union.values()].map(mediaLibraryEntry);
268
+ // Build the where-used overlay from main's content manifest plus the open branches. A failure
269
+ // here keeps the asset list intact with an empty overlay, since the screen still lists assets.
270
+ let usage = {};
271
+ try {
272
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
273
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
274
+ // Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
275
+ // best-effort behavior (a failed branch read degrades that one branch, not the screen).
276
+ const index = await buildUsageIndex(runtime.backend, token, runtime.concepts, manifest, { branches: branchNames });
277
+ for (const [hash, entries] of index) {
278
+ usage[hash] = { count: distinctEntryCount(entries), entries };
279
+ }
280
+ }
281
+ catch {
282
+ usage = {};
283
+ }
284
+ return { assets, usage, error: null, flash, flashError };
285
+ }
194
286
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
195
287
  async function createAction(event) {
196
288
  requireSession(event);
@@ -233,6 +325,10 @@ export function createContentRoutes(runtime, deps = {}) {
233
325
  out[field.name] = value === true;
234
326
  else if (field.type === 'tags' || field.type === 'freetags')
235
327
  out[field.name] = Array.isArray(value) ? value.map(String) : [];
328
+ // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
329
+ // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
330
+ else if (field.type === 'image')
331
+ out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
236
332
  else
237
333
  out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
238
334
  }
@@ -256,10 +352,16 @@ export function createContentRoutes(runtime, deps = {}) {
256
352
  // only when the probe found a branch, with the stage-1 main read serving as the published
257
353
  // signal either way.
258
354
  const branch = pendingBranch(concept.id, id);
259
- const [headSha, mainRaw, manifestRaw] = await Promise.all([
355
+ // The media manifest joins the concurrent batch only when media is on, read from the default
356
+ // branch (pending branches carry no copy). A rejected media read degrades to null so the edit
357
+ // never throws on a missing or unreadable media.json; the projection below treats null as empty.
358
+ const [headSha, mainRaw, manifestRaw, mediaRaw] = await Promise.all([
260
359
  branchHeadSha(runtime.backend, branch, token),
261
360
  readRaw(runtime.backend, path, token),
262
361
  readRaw(runtime.backend, runtime.manifestPath, token),
362
+ runtime.resolvedAssets.enabled
363
+ ? readRaw(runtime.backend, runtime.mediaManifestPath, token).catch(() => null)
364
+ : Promise.resolve(null),
263
365
  ]);
264
366
  const pending = headSha !== null;
265
367
  const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
@@ -282,6 +384,15 @@ export function createContentRoutes(runtime, deps = {}) {
282
384
  }));
283
385
  inbound = inboundLinks(manifest, concept.id, id);
284
386
  }
387
+ // Project the one committed media manifest read two ways: the minimal resolver triple the preview
388
+ // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
389
+ // A corrupt committed file degrades both to empty, not a throw.
390
+ const mediaTargets = {};
391
+ const mediaLibrary = {};
392
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
393
+ mediaTargets[hash] = { slug: e.slug, ext: e.ext, contentType: e.contentType };
394
+ mediaLibrary[hash] = mediaLibraryEntry(e);
395
+ }
285
396
  return {
286
397
  conceptId: concept.id,
287
398
  id,
@@ -296,6 +407,8 @@ export function createContentRoutes(runtime, deps = {}) {
296
407
  error: event.url.searchParams.get('error'),
297
408
  slug: slugFromId(id, datePrefix),
298
409
  linkTargets,
410
+ mediaTargets,
411
+ mediaLibrary,
299
412
  inboundLinks: inbound,
300
413
  pending,
301
414
  published,
@@ -343,6 +456,24 @@ export function createContentRoutes(runtime, deps = {}) {
343
456
  }
344
457
  const markdown = serializeMarkdown(result.data, body);
345
458
  const token = await mintToken(event.platform?.env ?? {});
459
+ // Merge the editor's optimistic media records into the media manifest, gated on media being on
460
+ // and at least one valid record posted. The base is read from the default branch (never the
461
+ // pending branch), so each save's union starts from main's committed rows, and decision 1's
462
+ // last-writer-wins-by-hash race is the accepted trade. The merged file rides the branch commit
463
+ // below and, carried on SaveHold, the publish commit, so both reuse the same content with no
464
+ // second read. When media is off or no records arrive, nothing touches media.json.
465
+ let mediaChange;
466
+ if (runtime.resolvedAssets.enabled) {
467
+ const records = parseMediaEntries(form.get('media'));
468
+ if (records.length > 0) {
469
+ const baseRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
470
+ let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
471
+ for (const record of records) {
472
+ mediaManifest = upsertMediaEntry(mediaManifest, record);
473
+ }
474
+ mediaChange = { path: runtime.mediaManifestPath, content: serializeMediaManifest(mediaManifest) };
475
+ }
476
+ }
346
477
  // Upsert this entry's row into main's manifest in memory, for the link guard here and for
347
478
  // the publish commit. The save commits no manifest change; publish lands the upsert on main.
348
479
  const manifest = await readManifest(token);
@@ -388,13 +519,13 @@ export function createContentRoutes(runtime, deps = {}) {
388
519
  const commitFields = { concept: concept.id, id, editor: editor.email, branch };
389
520
  let branchSha;
390
521
  try {
391
- branchSha = await commitFiles({ ...runtime.backend, branch }, [{ path, content: markdown }], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
522
+ branchSha = await commitFiles({ ...runtime.backend, branch }, mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
392
523
  log.info('commit.succeeded', commitFields);
393
524
  }
394
525
  catch (err) {
395
526
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
396
527
  }
397
- return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
528
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
398
529
  }
399
530
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
400
531
  * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
@@ -429,13 +560,19 @@ export function createContentRoutes(runtime, deps = {}) {
429
560
  const held = await saveToBranch(event, editor, concept, id);
430
561
  if (!('branchSha' in held))
431
562
  return held;
432
- const { path, markdown, branch, branchSha, manifest, token } = held;
563
+ const { path, markdown, branch, branchSha, manifest, token, mediaChange } = held;
564
+ // The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
565
+ // no re-read or re-merge here). Promote it to main alongside the body and the content manifest
566
+ // in one atomic commit, or commit those two alone when the save touched no media.
567
+ const changes = [
568
+ { path, content: markdown },
569
+ { path: runtime.manifestPath, content: serializeManifest(manifest) },
570
+ ];
571
+ if (mediaChange)
572
+ changes.push(mediaChange);
433
573
  const commitFields = { concept: concept.id, id, editor: editor.email };
434
574
  try {
435
- await commitFiles(runtime.backend, [
436
- { path, content: markdown },
437
- { path: runtime.manifestPath, content: serializeManifest(manifest) },
438
- ], { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
575
+ await commitFiles(runtime.backend, changes, { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
439
576
  log.info('entry.published', { ...commitFields, batch: false });
440
577
  }
441
578
  catch (err) {
@@ -698,5 +835,328 @@ export function createContentRoutes(runtime, deps = {}) {
698
835
  }
699
836
  throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
700
837
  }
701
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
838
+ /**
839
+ * Ingest an uploaded image: the JSON/fetch endpoint with the untrusted-input contract (spec piece
840
+ * 2, decisions 1 to 3). The body is the raw file bytes, read once; the human metadata travels in
841
+ * percent-encoded `X-Cairn-*` request headers. The server owns every committed field and trusts no
842
+ * client value: it sniffs the real type, screens the engine deny-list, re-hashes, re-derives the
843
+ * ext and slug, caps and sanitizes the human fields, and clamps the advisory dimensions. It stores
844
+ * put-first to R2 with content-addressed dedup (no second put for identical bytes, no
845
+ * compensating delete) and commits nothing to git.
846
+ *
847
+ * Wire contract: this is a SvelteKit form action, so for a JSON request SvelteKit serializes the
848
+ * result into a 200 JSON envelope `{ type, status, data }`. A `fail(status, ...)` rides the
849
+ * envelope's `status` field, NOT the HTTP response status (the HTTP status stays 200); a client
850
+ * parses `type`/`status` from the body, never `Response.status`. Success returns a plain
851
+ * `UploadResult` (also a 200 envelope). The action logs `media.upload_failed` on a refusal and
852
+ * `media.uploaded` on success.
853
+ *
854
+ * Session authority: behind `createAuthGuard` the guard is the production session gate. An
855
+ * unauthenticated admin POST is redirected 303 by the guard before this action runs (an opaque,
856
+ * status-0 response under the client's `redirect: 'manual'`), so the `fail(401, 'session-expired')`
857
+ * below is a belt-and-suspenders for a direct or un-guarded call, not the primary path.
858
+ */
859
+ async function uploadAction(event) {
860
+ // Read the editor up front for log attribution; the gate at step 4 enforces its presence. The
861
+ // pre-session gates (1 to 3) may log with an undefined editor email, which is fine.
862
+ const editor = event.locals.editor ?? null;
863
+ const refuse = (status, reason) => {
864
+ log.warn('media.upload_failed', { editor: editor?.email, reason });
865
+ return fail(status, { error: reason });
866
+ };
867
+ // 1. Media on.
868
+ const resolved = runtime.resolvedAssets;
869
+ if (!resolved.enabled)
870
+ return refuse(503, 'media-disabled');
871
+ // 2. Content-Length before the body is read: an absent or non-positive-integer length is a 411,
872
+ // an oversize length is a 413. Both refuse before the bytes are buffered. The header is
873
+ // client-advisory, so the real DoS bound is the Worker request-size limit, not maxUploadBytes:
874
+ // a lying client still buffers up to the platform ceiling before the post-read recheck (step 5).
875
+ const lengthHeader = event.request.headers.get('content-length');
876
+ const length = lengthHeader === null ? NaN : Number(lengthHeader);
877
+ if (!Number.isInteger(length) || length <= 0)
878
+ return refuse(411, 'length-required');
879
+ if (length > resolved.maxUploadBytes)
880
+ return refuse(413, 'too-large');
881
+ // 3. CSRF from the X-Cairn-CSRF header (no body clone): the action is the CSRF authority for the
882
+ // raw-body upload, since the guard runs its form-CSRF only on form content types.
883
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
884
+ return refuse(403, 'csrf');
885
+ }
886
+ // 4. JSON-aware session (belt-and-suspenders; see the docstring): behind the guard an
887
+ // unauthenticated POST is already 303'd before this runs. For a direct or un-guarded call,
888
+ // read the resolved editor directly and refuse with a 401 envelope rather than a 303 redirect.
889
+ if (!editor)
890
+ return refuse(401, 'session-expired');
891
+ // 5. Read the body once. Content-Length is client-advisory, so a lying client could send more
892
+ // than it declared; recheck the real size against the cap after the read.
893
+ const bytes = new Uint8Array(await event.request.arrayBuffer());
894
+ if (bytes.length > resolved.maxUploadBytes)
895
+ return refuse(413, 'too-large');
896
+ // 6. Server re-derivation: trust nothing the client declared.
897
+ const declaredType = event.request.headers.get('content-type') ?? undefined;
898
+ const sniffed = sniffMediaType(bytes);
899
+ if (isDeniedUpload(bytes, declaredType) || sniffed === null || !resolved.allowedTypes.includes(sniffed)) {
900
+ return refuse(415, 'unsupported-type');
901
+ }
902
+ const ext = extForMediaType(sniffed);
903
+ if (ext === null)
904
+ return refuse(415, 'unsupported-type');
905
+ const full = await hashBytes(bytes);
906
+ const hash = shortHash(full);
907
+ const decodedFilename = safeDecode(event.request.headers.get('x-cairn-filename'));
908
+ const slug = slugifyFilename(decodedFilename);
909
+ const originalFilename = sanitizeField(basename(decodedFilename), MAX_ORIGINAL_FILENAME);
910
+ const alt = sanitizeField(safeDecode(event.request.headers.get('x-cairn-alt')), MAX_ALT);
911
+ const displayNameRaw = sanitizeField(safeDecode(event.request.headers.get('x-cairn-display-name')), MAX_DISPLAY_NAME);
912
+ const displayName = displayNameRaw || slug;
913
+ const width = clampDimension(event.request.headers.get('x-cairn-width'));
914
+ const height = clampDimension(event.request.headers.get('x-cairn-height'));
915
+ // 7. Store put-first with R2-head dedup, commit nothing. The raw bucket binding lives on
916
+ // platform.env, which the engine reads through a structural cast (the engine does not declare
917
+ // App.Platform). r2Store wraps it as the narrow MediaStore seam; R2Bucket is named only for
918
+ // this cast and never in an exported signature.
919
+ const platformEnv = event.platform?.env ?? {};
920
+ const rawBucket = platformEnv[resolved.bucketBinding];
921
+ if (!rawBucket)
922
+ return refuse(503, 'binding-missing');
923
+ const store = r2Store(rawBucket);
924
+ const key = r2Key(hash, ext);
925
+ const existing = await store.head(key);
926
+ let reused;
927
+ let mismatch = false;
928
+ if (existing !== null) {
929
+ // The key derives from the 16-hex short hash (64 bits), so a distinct file could in principle
930
+ // collide on it. The put stores the full sha256 as custom metadata; verify it here. A stored
931
+ // sha256 that differs from this upload's full hash is a genuine short-hash collision: refuse,
932
+ // never serve the first file's bytes under the second's reference. A stored object with no
933
+ // sha256 (a legacy or manually-put object we cannot verify) proceeds as a dedup hit, best effort.
934
+ const storedSha = existing.customMetadata?.sha256;
935
+ if (storedSha !== undefined && storedSha !== full)
936
+ return refuse(409, 'hash-collision');
937
+ // Identical bytes are already stored: skip the put. A second upload does no second put, so a
938
+ // concurrent dedup-reuse is never clobbered. Flag a stored type that disagrees with this sniff.
939
+ reused = true;
940
+ mismatch = existing.httpMetadata?.contentType !== undefined && existing.httpMetadata.contentType !== sniffed;
941
+ }
942
+ else {
943
+ await store.put(key, bytes, { contentType: sniffed, cacheControl: 'public, max-age=31536000, immutable' }, { sha256: full });
944
+ reused = false;
945
+ }
946
+ const record = {
947
+ hash,
948
+ sha256: full,
949
+ slug,
950
+ displayName,
951
+ originalFilename,
952
+ alt,
953
+ ext,
954
+ contentType: sniffed,
955
+ bytes: bytes.length,
956
+ width,
957
+ height,
958
+ createdAt: new Date().toISOString(),
959
+ };
960
+ const reference = mediaToken({ slug, hash });
961
+ log.info('media.uploaded', { editor: editor.email, hash, bytes: bytes.length, contentType: sniffed, reused });
962
+ return { reference, record, reused, mismatch };
963
+ }
964
+ /** A media slug is the same lowercase-alphanumeric-with-hyphens grammar the reference token uses. */
965
+ const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
966
+ /** A 16-hex content-hash prefix, the immutable asset key. */
967
+ const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
968
+ /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
969
+ * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
970
+ * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
971
+ * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
972
+ * row removal FIRST, then delete the R2 object, so a failure after the commit leaves bytes with no
973
+ * row (a benign orphan) rather than a row pointing at deleted bytes (a broken delivery). Scope:
974
+ * 3c deletes assets committed on the default branch; a branch-only upload is removed by discarding
975
+ * its draft, not here.
976
+ *
977
+ * The published-usage side of the gate trusts the content manifest's mediaRefs (kept fresh by
978
+ * save/publish via manifestEntryFromFile), the same manifest-trust model the entry-delete gate
979
+ * uses; a raw git edit that adds a media reference without a save/publish or a manifest regenerate
980
+ * is not seen, matching the documented "regenerate after a raw edit" contract. The recheck reads
981
+ * in STRICT mode, so a transient branch-read failure fails the delete closed rather than mistaking
982
+ * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
983
+ * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
984
+ * hash, so a reference added in that window still resolves to bytes that may be gone, the same
985
+ * delete-races-an-edit window every safe delete carries. */
986
+ async function mediaDeleteAction(event) {
987
+ const editor = requireSession(event);
988
+ const token = await mintToken(event.platform?.env ?? {});
989
+ const form = await event.request.formData();
990
+ const hash = String(form.get('hash') ?? '');
991
+ if (!MEDIA_HASH_RE.test(hash))
992
+ throw error(400, 'Invalid media hash');
993
+ // The asset must be committed on the default branch to be deletable here. A branch-only upload
994
+ // (the common 2b case before publish) has no main row; removing it is a discard of the draft.
995
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
996
+ const row = manifest[hash];
997
+ if (!row) {
998
+ return fail(404, {
999
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1000
+ hash,
1001
+ usage: [],
1002
+ foundIn: 0,
1003
+ });
1004
+ }
1005
+ // The authoritative gate: a fresh usage read, never a client count. The index spans main's
1006
+ // content manifest and every open cairn/* branch. STRICT mode rethrows a branch-read failure
1007
+ // (rather than the display path's degrade-and-skip), so a transient branch read failing does not
1008
+ // make a still-referenced asset look orphaned and skip the typed-slug confirm.
1009
+ let index;
1010
+ try {
1011
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1012
+ }
1013
+ catch {
1014
+ // Fail closed: we could not verify every place the asset is used, so refuse rather than risk
1015
+ // deleting bytes a branch still references.
1016
+ return fail(503, {
1017
+ error: 'Could not verify where this asset is used. Try again.',
1018
+ hash,
1019
+ usage: [],
1020
+ foundIn: 0,
1021
+ });
1022
+ }
1023
+ const rows = index.get(hash) ?? [];
1024
+ const foundIn = distinctEntryCount(rows);
1025
+ if (rows.length > 0) {
1026
+ // In use: refuse unless the editor typed the slug to force it (the in-use face's confirmation).
1027
+ // An empty stored slug must never be satisfiable by the empty default, so a blank row.slug is
1028
+ // treated as never-confirmed: the typed confirm cannot be bypassed.
1029
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1030
+ if (row.slug === '' || confirmSlug !== row.slug) {
1031
+ log.warn('media.delete_blocked', { editor: editor.email, hash, foundIn });
1032
+ // Group published-first, then branch entries by branch name, so the list reads stably.
1033
+ const usage = [...rows].sort((a, b) => originRank(a) - originRank(b) || branchKey(a).localeCompare(branchKey(b)));
1034
+ return fail(409, {
1035
+ error: `Cannot delete ${row.slug}: found in ${foundIn} ${foundIn === 1 ? 'entry' : 'entries'}.`,
1036
+ hash,
1037
+ usage,
1038
+ foundIn,
1039
+ });
1040
+ }
1041
+ }
1042
+ // Resolve the R2 bucket before the commit, so a missing binding refuses before any write.
1043
+ const resolved = runtime.resolvedAssets;
1044
+ if (!resolved.enabled) {
1045
+ return fail(503, { error: 'Media is not enabled for this site.', hash, usage: [], foundIn });
1046
+ }
1047
+ const platformEnv = event.platform?.env ?? {};
1048
+ const rawBucket = platformEnv[resolved.bucketBinding];
1049
+ if (!rawBucket) {
1050
+ return fail(503, { error: 'The media bucket is not bound.', hash, usage: [], foundIn });
1051
+ }
1052
+ const store = r2Store(rawBucket);
1053
+ // Derive the R2 key BEFORE the commit. A corrupt ext throws here, so a bad key refuses before
1054
+ // any write rather than after the row is already removed (which would orphan the bytes).
1055
+ const objectKey = r2Key(hash, row.ext);
1056
+ // Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
1057
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1058
+ try {
1059
+ await commitFiles(runtime.backend, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }], { message: `Delete media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1060
+ log.info('commit.succeeded', commitFields);
1061
+ }
1062
+ catch (err) {
1063
+ commitFailure(commitFields, err, '/admin/media', 'The media manifest changed since you opened it. Reload and try again.');
1064
+ }
1065
+ // THEN delete the object. An absent object is a no-op (the R2 contract), so a dead row clears.
1066
+ await store.delete(objectKey);
1067
+ log.info('media.deleted', { editor: editor.email, hash });
1068
+ throw redirect(303, '/admin/media?deleted=1');
1069
+ }
1070
+ /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1071
+ * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1072
+ * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
1073
+ * next placement, never a propagating edit of the alt already committed in existing placements. */
1074
+ async function mediaUpdateAction(event) {
1075
+ const editor = requireSession(event);
1076
+ const token = await mintToken(event.platform?.env ?? {});
1077
+ const form = await event.request.formData();
1078
+ const hash = String(form.get('hash') ?? '');
1079
+ if (!MEDIA_HASH_RE.test(hash))
1080
+ throw error(400, 'Invalid media hash');
1081
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1082
+ const row = manifest[hash];
1083
+ if (!row) {
1084
+ return fail(404, { error: 'That asset is not committed.' });
1085
+ }
1086
+ const displayName = sanitizeField(String(form.get('displayName') ?? ''), MAX_DISPLAY_NAME);
1087
+ const slug = String(form.get('slug') ?? '').trim();
1088
+ const alt = sanitizeField(String(form.get('alt') ?? ''), MAX_ALT);
1089
+ if (!MEDIA_SLUG_RE.test(slug)) {
1090
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
1091
+ }
1092
+ const edited = { ...row, displayName: displayName || slug, slug, alt };
1093
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1094
+ try {
1095
+ await commitFiles(runtime.backend, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }], { message: `Update media: ${edited.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1096
+ log.info('commit.succeeded', commitFields);
1097
+ }
1098
+ catch (err) {
1099
+ commitFailure(commitFields, err, '/admin/media', 'The media manifest changed since you opened it. Reload and try again.');
1100
+ }
1101
+ throw redirect(303, '/admin/media?updated=1');
1102
+ }
1103
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1104
+ }
1105
+ /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1106
+ * so a generous cap rejects only abuse-scale input. */
1107
+ const MAX_ALT = 160;
1108
+ /** The cap, in characters, on the stored display name. */
1109
+ const MAX_DISPLAY_NAME = 120;
1110
+ /** The cap, in characters, on the stored original filename. */
1111
+ const MAX_ORIGINAL_FILENAME = 120;
1112
+ /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
1113
+ const MAX_DIMENSION = 60000;
1114
+ /** Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
1115
+ * so a hostile `X-Cairn-*` value cannot throw past the gate. */
1116
+ function safeDecode(value) {
1117
+ if (value === null)
1118
+ return '';
1119
+ try {
1120
+ return decodeURIComponent(value);
1121
+ }
1122
+ catch {
1123
+ return '';
1124
+ }
1125
+ }
1126
+ /** The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
1127
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record. */
1128
+ function basename(name) {
1129
+ const parts = name.split(/[/\\]/);
1130
+ return parts[parts.length - 1];
1131
+ }
1132
+ /** Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
1133
+ * refusal lists "Published on the site" first, then the edit-branch references. */
1134
+ function originRank(entry) {
1135
+ return entry.origin.kind === 'published' ? 0 : 1;
1136
+ }
1137
+ /** A where-used row's branch name for the secondary sort (the empty string for a published row,
1138
+ * which sorts ahead of any branch by `originRank` already). */
1139
+ function branchKey(entry) {
1140
+ return entry.origin.kind === 'branch' ? entry.origin.branch : '';
1141
+ }
1142
+ /** The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
1143
+ * same entry are two rows but one distinct entry, so count by concept/id. */
1144
+ function distinctEntryCount(rows) {
1145
+ return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
1146
+ }
1147
+ /** Strip control characters from a human field and cap it at `max` characters. Control characters
1148
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON. */
1149
+ function sanitizeField(value, max) {
1150
+ // eslint-disable-next-line no-control-regex
1151
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
1152
+ }
1153
+ /** Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
1154
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`). */
1155
+ function clampDimension(value) {
1156
+ if (value === null)
1157
+ return null;
1158
+ const n = Number(value);
1159
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DIMENSION)
1160
+ return null;
1161
+ return n;
702
1162
  }
@@ -14,5 +14,21 @@ export declare function issueCsrfToken(event: {
14
14
  url: URL;
15
15
  cookies: CookieJar;
16
16
  }): string;
17
+ /**
18
+ * Validate the double-submit token on a raw-body upload POST, reading the submitted token from the
19
+ * `X-Cairn-CSRF` request header rather than a form field. The upload's file bytes are the request
20
+ * body and are read once, so the form-field path (which clones the body to read `formData`) does not
21
+ * apply; the action carries the CSRF authority for uploads instead. Compares the header against the
22
+ * csrf cookie the loads issue, constant-time.
23
+ *
24
+ * Security rests on a custom request header being unsettable cross-origin without a CORS preflight:
25
+ * never add a permissive `Access-Control-Allow-Headers: x-cairn-csrf` (or an allow-origin) for
26
+ * `/admin` or `/media`, or this header witness collapses.
27
+ */
28
+ export declare function validateCsrfHeader(event: {
29
+ url: URL;
30
+ request: Request;
31
+ cookies: CookieJar;
32
+ }): boolean;
17
33
  /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
18
34
  export declare function validateCsrfToken(event: RequestContext): Promise<boolean>;
@@ -43,6 +43,24 @@ export function issueCsrfToken(event) {
43
43
  event.cookies.set(name, token, { path: '/', httpOnly: true, secure, sameSite: 'strict' });
44
44
  return token;
45
45
  }
46
+ /**
47
+ * Validate the double-submit token on a raw-body upload POST, reading the submitted token from the
48
+ * `X-Cairn-CSRF` request header rather than a form field. The upload's file bytes are the request
49
+ * body and are read once, so the form-field path (which clones the body to read `formData`) does not
50
+ * apply; the action carries the CSRF authority for uploads instead. Compares the header against the
51
+ * csrf cookie the loads issue, constant-time.
52
+ *
53
+ * Security rests on a custom request header being unsettable cross-origin without a CORS preflight:
54
+ * never add a permissive `Access-Control-Allow-Headers: x-cairn-csrf` (or an allow-origin) for
55
+ * `/admin` or `/media`, or this header witness collapses.
56
+ */
57
+ export function validateCsrfHeader(event) {
58
+ const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
59
+ if (!cookie)
60
+ return false;
61
+ const submitted = event.request.headers.get('x-cairn-csrf') ?? '';
62
+ return tokensMatch(submitted, cookie);
63
+ }
46
64
  /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
47
65
  export async function validateCsrfToken(event) {
48
66
  const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
@@ -4,7 +4,7 @@
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
- import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
7
+ import { isUnsafeFormRequest, originMatches, validateCsrfToken, validateCsrfHeader } from './csrf.js';
8
8
  import { applySecurityHeaders } from './admin-response.js';
9
9
  import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
10
10
  import { log } from '../log/index.js';
@@ -63,8 +63,15 @@ export function createAuthGuard() {
63
63
  return renderConditionResponse(REASON_CONDITION.bindings);
64
64
  }
65
65
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
66
- // 403 before resolve() runs. This covers the public login/auth posts too.
67
- if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
66
+ // 403 before resolve() runs. This covers the public login/auth posts too. The header witness is
67
+ // tried first: a valid X-Cairn-CSRF header clears the request without cloning the body, which is
68
+ // how the raw-body media upload (a text/plain POST) passes CSRF. A custom header cannot be set
69
+ // cross-origin without a CORS preflight, so it is as strong a token witness as the form field.
70
+ // Only with no valid header does the form-field path run and clone the body to read the token,
71
+ // the unchanged path for every ordinary admin form post.
72
+ if (isUnsafeFormRequest(event.request) &&
73
+ !validateCsrfHeader(event) &&
74
+ !(await validateCsrfToken(event))) {
68
75
  log.warn('guard.rejected', { reason: 'csrf', path: pathname });
69
76
  return renderConditionResponse('auth.csrf-token-invalid');
70
77
  }
@@ -2,7 +2,8 @@ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
2
2
  export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
- export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, ContentFormFailure, } from './content-routes.js';
5
+ export { createMediaRoute } from './media-route.js';
6
+ export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
6
7
  export { createNavRoutes } from './nav-routes.js';
7
8
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
8
9
  export { parseAdminPath, type AdminView } from './admin-dispatch.js';