@glw907/cairn-cms 0.68.0 → 0.76.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 (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
@@ -5,17 +5,16 @@
5
5
  import { redirect, error, fail } from '@sveltejs/kit';
6
6
  import { findConcept } from '../content/concepts.js';
7
7
  import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
8
- import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
+ import { extractReferenceEdges, rewriteFrontmatterReference } from '../content/references.js';
9
+ import { buildReferenceIndex } from '../content/reference-index.js';
10
+ import { frontmatterFromForm, formValues, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
11
+ import { initialValues } from '../content/fieldset.js';
9
12
  import { deriveExcerpt } from '../content/excerpt.js';
10
13
  import { asString, entryIdentity } from '../content/identity.js';
11
14
  import { buildAddressIndex, mainAddressIndex, addressCollision } from '../content/advisories.js';
12
15
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
13
- import { appCredentials } from '../github/credentials.js';
14
- import { listMarkdown, readRaw, commitFile, commitFiles } from '../github/repo.js';
15
- import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
16
16
  import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
17
- import { cachedInstallationToken } from '../github/signing.js';
18
- import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
17
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, inboundReferences } from '../content/manifest.js';
19
18
  import { deriveGettingStarted } from '../content/getting-started.js';
20
19
  import { markdownReference } from '../components/markdown-reference.js';
21
20
  import { isConflict } from '../github/types.js';
@@ -103,7 +102,15 @@ function conceptOf(runtime, params) {
103
102
  *
104
103
  */
105
104
  export function createContentRoutes(runtime, deps = {}) {
106
- const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
105
+ /**
106
+ * Resolve the live content backend for one request. A test seam (`deps.backend`) wins, then the
107
+ * dev double's `event.locals.backend`, then the production `runtime.backend.connect(env)`. The
108
+ * GitHub provider mints and caches its installation token lazily behind `connect`, so a
109
+ * per-request resolve re-signs only on a cache miss.
110
+ */
111
+ function resolveBackend(event) {
112
+ return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
113
+ }
107
114
  // The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
108
115
  // (deps.anthropic) so messages.create is stubbed and no network call or real key is ever needed. The
109
116
  // SDK client satisfies TidyClient structurally; the cast names that to the compiler.
@@ -113,8 +120,8 @@ export function createContentRoutes(runtime, deps = {}) {
113
120
  * Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
114
121
  * Always read from main: pending branches carry no manifest copy.
115
122
  */
116
- async function readManifest(token) {
117
- const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
123
+ async function readManifest(backend) {
124
+ const raw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
118
125
  return raw === null ? emptyManifest() : parseManifest(raw);
119
126
  }
120
127
  /**
@@ -162,8 +169,8 @@ export function createContentRoutes(runtime, deps = {}) {
162
169
  // than failing the whole admin shell or showing a wrong publish-all count.
163
170
  let pendingEntries = null;
164
171
  try {
165
- const token = await mintToken(event.platform?.env ?? {});
166
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
172
+ const backend = resolveBackend(event);
173
+ const names = await backend.listBranches(PENDING_PREFIX);
167
174
  pendingEntries = names.flatMap((name) => {
168
175
  const entry = pendingEntryOf(name);
169
176
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
@@ -196,9 +203,9 @@ export function createContentRoutes(runtime, deps = {}) {
196
203
  let manifest = emptyManifest();
197
204
  let pending = [];
198
205
  try {
199
- const token = await mintToken(event.platform?.env ?? {});
200
- manifest = await readManifest(token);
201
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
206
+ const backend = resolveBackend(event);
207
+ manifest = await readManifest(backend);
208
+ const names = await backend.listBranches(PENDING_PREFIX);
202
209
  pending = names.flatMap((name) => {
203
210
  const entry = pendingEntryOf(name);
204
211
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
@@ -224,9 +231,9 @@ export function createContentRoutes(runtime, deps = {}) {
224
231
  * Read a file's frontmatter for its list row, degrading to the id on any read failure. The
225
232
  * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
226
233
  */
227
- async function summarize(file, token, status, repo = runtime.backend) {
234
+ async function summarize(file, backend, status, ref = backend.defaultBranch) {
228
235
  try {
229
- const raw = await readRaw(repo, file.path, token);
236
+ const raw = await backend.readFile(file.path, ref);
230
237
  if (raw === null)
231
238
  return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
232
239
  const { frontmatter, body } = parseMarkdown(raw);
@@ -246,22 +253,19 @@ export function createContentRoutes(runtime, deps = {}) {
246
253
  * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
247
254
  * an id-only row, so a ghost ref still lists.
248
255
  */
249
- function pendingRow(concept, id, status, token) {
250
- return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
251
- ...runtime.backend,
252
- branch: pendingBranch(concept.id, id),
253
- });
256
+ function pendingRow(concept, id, status, backend) {
257
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, backend, status, pendingBranch(concept.id, id));
254
258
  }
255
259
  /**
256
260
  * The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
257
261
  * and read each one for its row, with edited and new rows reading branch-first.
258
262
  */
259
- async function crawlEntries(concept, pendingIds, token) {
260
- const files = await listMarkdown(runtime.backend, concept.dir, token);
261
- const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
263
+ async function crawlEntries(concept, pendingIds, backend) {
264
+ const files = await backend.readEntries(concept.dir, backend.defaultBranch);
265
+ const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', backend) : summarize(f, backend, 'published'))));
262
266
  // A ref with no main file is a never-published entry; its row reads from its branch.
263
267
  const listed = new Set(files.map((f) => f.id));
264
- const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
268
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)));
265
269
  return [...entries, ...newRows];
266
270
  }
267
271
  /**
@@ -279,17 +283,11 @@ export function createContentRoutes(runtime, deps = {}) {
279
283
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
280
284
  const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
281
285
  const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
282
- let token;
283
- try {
284
- token = await mintToken(event.platform?.env ?? {});
285
- }
286
- catch {
287
- return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
288
- }
286
+ const backend = resolveBackend(event);
289
287
  try {
290
288
  const [manifestRaw, refs] = await Promise.all([
291
- readRaw(runtime.backend, runtime.manifestPath, token),
292
- listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
289
+ backend.readFile(runtime.manifestPath, backend.defaultBranch),
290
+ backend.listBranches(`${PENDING_PREFIX}${concept.id}/`),
293
291
  ]);
294
292
  const pendingIds = new Set(refs.flatMap((name) => {
295
293
  const entry = pendingEntryOf(name);
@@ -298,17 +296,17 @@ export function createContentRoutes(runtime, deps = {}) {
298
296
  // A repo with no committed manifest yet (a fresh site before its first publish) falls back
299
297
  // to the crawl; a manifest that parses but is empty is trusted as-is.
300
298
  if (manifestRaw === null) {
301
- return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
299
+ return { ...base, entries: await crawlEntries(concept, pendingIds, backend), error: null };
302
300
  }
303
301
  // Newest id first, the same order the crawl's file listing produced.
304
302
  const rows = parseManifest(manifestRaw)
305
303
  .entries.filter((e) => e.concept === concept.id)
306
304
  .sort((a, b) => b.id.localeCompare(a.id));
307
305
  const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
308
- ? pendingRow(concept, e.id, 'edited', token)
306
+ ? pendingRow(concept, e.id, 'edited', backend)
309
307
  : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published', summary: e.summary ?? null }));
310
308
  const listed = new Set(rows.map((e) => e.id));
311
- const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
309
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)));
312
310
  return { ...base, entries: [...entries, ...newRows], error: null };
313
311
  }
314
312
  catch {
@@ -342,28 +340,24 @@ export function createContentRoutes(runtime, deps = {}) {
342
340
  else if (event.url.searchParams.get('orphansPurged') === '1')
343
341
  flash = 'orphansPurged';
344
342
  const flashError = event.url.searchParams.get('error');
345
- let token;
346
- try {
347
- token = await mintToken(event.platform?.env ?? {});
348
- }
349
- catch {
350
- return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
351
- }
343
+ const backend = resolveBackend(event);
352
344
  // Union the media manifest by hash: main's rows first, then any branch hash not already present.
353
345
  // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
354
346
  // absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
355
347
  // The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
356
348
  // not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
349
+ // The token mint is now lazy inside the first read, so a token or a network failure both land in
350
+ // this one degrade rather than the old separate could-not-authenticate tier.
357
351
  const union = new Map();
358
352
  let branchNames = [];
359
353
  try {
360
- const mediaRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
354
+ const mediaRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
361
355
  for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
362
356
  union.set(hash, e);
363
357
  }
364
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
358
+ const names = await backend.listBranches(PENDING_PREFIX);
365
359
  branchNames = names;
366
- const branchManifests = await Promise.all(names.map((name) => readRaw({ ...runtime.backend, branch: name }, runtime.mediaManifestPath, token)
360
+ const branchManifests = await Promise.all(names.map((name) => backend.readFile(runtime.mediaManifestPath, name)
367
361
  .then((raw) => parseMediaManifest(parseMediaJson(raw)))
368
362
  .catch(() => ({}))));
369
363
  for (const manifest of branchManifests) {
@@ -383,11 +377,11 @@ export function createContentRoutes(runtime, deps = {}) {
383
377
  // here keeps the asset list intact with an empty overlay, since the screen still lists assets.
384
378
  let usage = {};
385
379
  try {
386
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
380
+ const manifestRaw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
387
381
  const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
388
382
  // Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
389
383
  // best-effort behavior (a failed branch read degrades that one branch, not the screen).
390
- const index = await buildUsageIndex(runtime.backend, token, runtime.concepts, manifest, { branches: branchNames });
384
+ const index = await buildUsageIndex(backend, runtime.concepts, manifest, { branches: branchNames });
391
385
  for (const [hash, entries] of index) {
392
386
  usage[hash] = { count: distinctEntryCount(entries), entries };
393
387
  }
@@ -418,36 +412,16 @@ export function createContentRoutes(runtime, deps = {}) {
418
412
  }
419
413
  id = composeDatedId(date, slug, concept.datePrefix);
420
414
  }
421
- const token = await mintToken(event.platform?.env ?? {});
422
- const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
415
+ const backend = resolveBackend(event);
416
+ const existing = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
423
417
  if (existing !== null)
424
418
  return bounce('An entry with that slug already exists.');
425
419
  // A pending branch is an entry too (saved but not yet published); refuse to clobber it.
426
- if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
420
+ if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
427
421
  return bounce('An unpublished entry with that slug already exists.');
428
422
  }
429
423
  throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
430
424
  }
431
- /** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
432
- function formValues(fields, frontmatter) {
433
- const out = {};
434
- for (const field of fields) {
435
- const value = frontmatter[field.name];
436
- if (field.type === 'date')
437
- out[field.name] = dateInputValue(value);
438
- else if (field.type === 'boolean')
439
- out[field.name] = value === true;
440
- else if (field.type === 'tags' || field.type === 'freetags')
441
- out[field.name] = Array.isArray(value) ? value.map(String) : [];
442
- // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
443
- // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
444
- else if (field.type === 'image')
445
- out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
446
- else
447
- out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
448
- }
449
- return out;
450
- }
451
425
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
452
426
  async function editLoad(event) {
453
427
  requireSession(event);
@@ -456,7 +430,7 @@ export function createContentRoutes(runtime, deps = {}) {
456
430
  if (!isValidId(id))
457
431
  throw error(400, 'Invalid entry id');
458
432
  const isNew = event.url.searchParams.get('new') === '1';
459
- const token = await mintToken(event.platform?.env ?? {});
433
+ const backend = resolveBackend(event);
460
434
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
461
435
  const path = `${concept.dir}/${filenameFromId(id)}`;
462
436
  // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
@@ -473,20 +447,25 @@ export function createContentRoutes(runtime, deps = {}) {
473
447
  // rejected read degrades to null so the edit never throws on a missing or unreadable dictionary;
474
448
  // the projection below treats null as an empty word list (the editor falls back to dialect-only).
475
449
  const [headSha, mainRaw, manifestRaw, mediaRaw, dictionaryRaw] = await Promise.all([
476
- branchHeadSha(runtime.backend, branch, token),
477
- readRaw(runtime.backend, path, token),
478
- readRaw(runtime.backend, runtime.manifestPath, token),
450
+ backend.branchHead(branch),
451
+ backend.readFile(path, backend.defaultBranch),
452
+ backend.readFile(runtime.manifestPath, backend.defaultBranch),
479
453
  runtime.resolvedAssets.enabled
480
- ? readRaw(runtime.backend, runtime.mediaManifestPath, token).catch(() => null)
454
+ ? backend.readFile(runtime.mediaManifestPath, backend.defaultBranch).catch(() => null)
481
455
  : Promise.resolve(null),
482
- readRaw(runtime.backend, dictionaryFilePath(), token).catch(() => null),
456
+ backend.readFile(dictionaryFilePath(), backend.defaultBranch).catch(() => null),
483
457
  ]);
484
458
  const pending = headSha !== null;
485
- const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
459
+ const raw = pending ? await backend.readFile(path, branch) : mainRaw;
486
460
  if (raw === null && !isNew)
487
461
  throw error(404, 'Entry not found');
488
462
  const published = mainRaw !== null;
489
463
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
464
+ // A fresh entry opens prefilled from each field's `default`, resolving a `'today'` date against a
465
+ // request-time clock. The defaults sit under the empty parsed frontmatter, never over a real read.
466
+ const loadFrontmatter = isNew
467
+ ? { ...initialValues(concept.schema, new Date()), ...parsed.frontmatter }
468
+ : parsed.frontmatter;
490
469
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
491
470
  const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
492
471
  let linkTargets = [];
@@ -544,7 +523,7 @@ export function createContentRoutes(runtime, deps = {}) {
544
523
  id,
545
524
  label: concept.label,
546
525
  fields: concept.fields,
547
- frontmatter: formValues(concept.fields, parsed.frontmatter),
526
+ frontmatter: formValues(concept.fields, loadFrontmatter),
548
527
  body: parsed.body,
549
528
  title,
550
529
  isNew,
@@ -630,7 +609,7 @@ export function createContentRoutes(runtime, deps = {}) {
630
609
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
631
610
  }
632
611
  const markdown = serializeMarkdown(result.data, body);
633
- const token = await mintToken(event.platform?.env ?? {});
612
+ const backend = resolveBackend(event);
634
613
  // Merge the editor's optimistic media records into the media manifest, gated on media being on
635
614
  // and at least one valid record posted. The base is read from the default branch (never the
636
615
  // pending branch), so each save's union starts from main's committed rows, and decision 1's
@@ -641,7 +620,7 @@ export function createContentRoutes(runtime, deps = {}) {
641
620
  if (runtime.resolvedAssets.enabled) {
642
621
  const records = parseMediaEntries(form.get('media'));
643
622
  if (records.length > 0) {
644
- const baseRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
623
+ const baseRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
645
624
  let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
646
625
  for (const record of records) {
647
626
  mediaManifest = upsertMediaEntry(mediaManifest, record);
@@ -651,7 +630,7 @@ export function createContentRoutes(runtime, deps = {}) {
651
630
  }
652
631
  // Upsert this entry's row into main's manifest in memory, for the link guard here and for
653
632
  // the publish commit. The save commits no manifest change; publish lands the upsert on main.
654
- const manifest = await readManifest(token);
633
+ const manifest = await readManifest(backend);
655
634
  const row = manifestEntryFromFile(concept, { path, raw: markdown });
656
635
  const upserted = upsertEntry(manifest, row);
657
636
  // Save guard: resolve the body's cairn links against main's manifest with this entry upserted,
@@ -681,26 +660,41 @@ export function createContentRoutes(runtime, deps = {}) {
681
660
  body,
682
661
  });
683
662
  }
663
+ // Frontmatter reference warning: classify each typed reference edge against the same upserted
664
+ // manifest. This is best-effort against the committed (possibly stale) main manifest and advisory
665
+ // like draftLinks, NEVER the integrity guarantee; references have no prerender re-resolve backstop,
666
+ // so verifyReferences at the build is the only authority. A reference NEVER blocks the save: unlike
667
+ // a body link, an absent or draft target only warns, since the build gate fails a true dangling.
668
+ const referenceWarnings = [];
669
+ for (const edge of extractReferenceEdges(result.data, concept.fields)) {
670
+ if (edge.concept === concept.id && edge.id === id)
671
+ continue;
672
+ const target = byKey.get(`${edge.concept}/${edge.id}`);
673
+ if (!target || target.draft)
674
+ referenceWarnings.push(`${edge.concept}/${edge.id}`);
675
+ }
684
676
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
685
677
  // commit only the entry file there. Main stays untouched until publish, so the branch differs
686
678
  // from main at exactly this entry's path.
687
679
  const branch = pendingBranch(concept.id, id);
688
- if ((await branchHeadSha(runtime.backend, branch, token)) === null) {
689
- const mainHead = await branchHeadSha(runtime.backend, runtime.backend.branch, token);
680
+ if ((await backend.branchHead(branch)) === null) {
681
+ // The default-branch head read distinguishes a first save from a re-save; a null is the
682
+ // unreadable-default-branch case the create cannot recover from, so fail with the 500.
683
+ const mainHead = await backend.branchHead(backend.defaultBranch);
690
684
  if (mainHead === null)
691
685
  throw error(500, 'Cannot read the default branch');
692
- await createBranch(runtime.backend, branch, mainHead, token);
686
+ await backend.createBranch(branch, backend.defaultBranch);
693
687
  }
694
688
  const commitFields = { concept: concept.id, id, editor: editor.email, branch };
695
689
  let branchSha;
696
690
  try {
697
- 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);
691
+ branchSha = await backend.commit(branch, mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }], { name: editor.displayName, email: editor.email }, `Update ${concept.label.toLowerCase()}: ${id}`);
698
692
  log.info('commit.succeeded', commitFields);
699
693
  }
700
694
  catch (err) {
701
695
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
702
696
  }
703
- return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
697
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, referenceWarnings, backend, mediaChange };
704
698
  }
705
699
  /**
706
700
  * Save an edit: validate, then commit to the entry's pending branch with the session editor
@@ -717,9 +711,11 @@ export function createContentRoutes(runtime, deps = {}) {
717
711
  const held = await saveToBranch(event, editor, concept, id);
718
712
  if (!('branchSha' in held))
719
713
  return held;
720
- const savedQuery = held.draftLinks.length
714
+ let savedQuery = held.draftLinks.length
721
715
  ? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
722
716
  : 'saved=1';
717
+ if (held.referenceWarnings.length)
718
+ savedQuery += `&refs=${encodeURIComponent(held.referenceWarnings.join(','))}`;
723
719
  throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
724
720
  }
725
721
  /**
@@ -739,7 +735,7 @@ export function createContentRoutes(runtime, deps = {}) {
739
735
  const held = await saveToBranch(event, editor, concept, id);
740
736
  if (!('branchSha' in held))
741
737
  return held;
742
- const { path, markdown, branch, branchSha, manifest, token, mediaChange } = held;
738
+ const { path, markdown, branch, branchSha, manifest, backend, mediaChange } = held;
743
739
  // The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
744
740
  // no re-read or re-merge here). Promote it to main alongside the body and the content manifest
745
741
  // in one atomic commit, or commit those two alone when the save touched no media.
@@ -758,7 +754,7 @@ export function createContentRoutes(runtime, deps = {}) {
758
754
  try {
759
755
  const { frontmatter } = parseMarkdown(markdown);
760
756
  address = entryIdentity(concept, path, frontmatter).permalink;
761
- const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
757
+ const addressIndex = await buildAddressIndex(backend, runtime.concepts, manifest);
762
758
  collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
763
759
  }
764
760
  catch (err) {
@@ -769,7 +765,7 @@ export function createContentRoutes(runtime, deps = {}) {
769
765
  }
770
766
  const commitFields = { concept: concept.id, id, editor: editor.email };
771
767
  try {
772
- await commitFiles(runtime.backend, changes, { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
768
+ await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Publish ${concept.label.toLowerCase()}: ${id}`);
773
769
  log.info('entry.published', { ...commitFields, batch: false });
774
770
  // Only after the publish lands: a diagnostic that a live address now has a new owner.
775
771
  if (collision) {
@@ -788,8 +784,8 @@ export function createContentRoutes(runtime, deps = {}) {
788
784
  // Only after the main commit lands, and only when the branch head is still the commit this
789
785
  // action made: a head that moved is a concurrent save, and deleting it would destroy edits.
790
786
  // No log event for the skip; the pending badge is the surface.
791
- if ((await branchHeadSha(runtime.backend, branch, token)) === branchSha) {
792
- await deleteBranch(runtime.backend, branch, token);
787
+ if ((await backend.branchHead(branch)) === branchSha) {
788
+ await backend.deleteBranch(branch);
793
789
  }
794
790
  throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
795
791
  }
@@ -804,11 +800,11 @@ export function createContentRoutes(runtime, deps = {}) {
804
800
  const first = runtime.concepts[0];
805
801
  if (!first)
806
802
  throw error(404, 'No content types configured');
807
- const token = await mintToken(event.platform?.env ?? {});
803
+ const backend = resolveBackend(event);
808
804
  const listPage = `/admin/${first.id}`;
809
805
  // Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
810
806
  // than failing the whole batch on it.
811
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
807
+ const names = await backend.listBranches(PENDING_PREFIX);
812
808
  const pending = names.flatMap((name) => {
813
809
  const entry = pendingEntryOf(name);
814
810
  return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
@@ -819,13 +815,13 @@ export function createContentRoutes(runtime, deps = {}) {
819
815
  // entry stays pending). A ghost ref whose entry file is missing is skipped (discard can
820
816
  // clean it up); it carries nothing to publish.
821
817
  const reads = await Promise.all(pending.map(async (entry) => {
822
- const sha = await branchHeadSha(runtime.backend, entry.branch, token);
823
- const raw = await readRaw({ ...runtime.backend, branch: entry.branch }, entry.path, token);
818
+ const sha = await backend.branchHead(entry.branch);
819
+ const raw = await backend.readFile(entry.path, entry.branch);
824
820
  return { ...entry, sha, raw };
825
821
  }));
826
822
  // Fold main's manifest once over every row, so the batch lands content and index together,
827
823
  // the same shape as a single publish.
828
- let next = await readManifest(token);
824
+ let next = await readManifest(backend);
829
825
  const changes = [];
830
826
  const published = [];
831
827
  for (const entry of reads) {
@@ -842,7 +838,7 @@ export function createContentRoutes(runtime, deps = {}) {
842
838
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
843
839
  const noun = published.length === 1 ? 'entry' : 'entries';
844
840
  try {
845
- await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } }, token);
841
+ await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Publish ${published.length} ${noun}`);
846
842
  for (const entry of published) {
847
843
  log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
848
844
  }
@@ -866,8 +862,8 @@ export function createContentRoutes(runtime, deps = {}) {
866
862
  // abort the remaining deletes.
867
863
  for (const entry of published) {
868
864
  try {
869
- if ((await branchHeadSha(runtime.backend, entry.branch, token)) === entry.sha) {
870
- await deleteBranch(runtime.backend, entry.branch, token);
865
+ if ((await backend.branchHead(entry.branch)) === entry.sha) {
866
+ await backend.deleteBranch(entry.branch);
871
867
  }
872
868
  }
873
869
  catch {
@@ -886,10 +882,10 @@ export function createContentRoutes(runtime, deps = {}) {
886
882
  const id = event.params.id ?? '';
887
883
  if (!isValidId(id))
888
884
  throw error(400, 'Invalid entry id');
889
- const token = await mintToken(event.platform?.env ?? {});
890
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
885
+ const backend = resolveBackend(event);
886
+ await backend.deleteBranch(pendingBranch(concept.id, id));
891
887
  log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
892
- const onMain = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
888
+ const onMain = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
893
889
  if (onMain !== null)
894
890
  throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
895
891
  throw redirect(303, `/admin/${concept.id}`);
@@ -903,10 +899,10 @@ export function createContentRoutes(runtime, deps = {}) {
903
899
  */
904
900
  async function deleteEntry(event, concept, id, editor) {
905
901
  const path = `${concept.dir}/${filenameFromId(id)}`;
906
- const token = await mintToken(event.platform?.env ?? {});
902
+ const backend = resolveBackend(event);
907
903
  // An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
908
904
  // check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
909
- const manifest = await readManifest(token);
905
+ const manifest = await readManifest(backend);
910
906
  const inbound = inboundLinks(manifest, concept.id, id);
911
907
  if (inbound.length) {
912
908
  return fail(409, {
@@ -915,22 +911,55 @@ export function createContentRoutes(runtime, deps = {}) {
915
911
  id,
916
912
  });
917
913
  }
914
+ // Cross-branch reference gate (fail-closed). A strict reference index unions main's published edges
915
+ // and every open cairn/* branch; unlike the main-only body-link gate above, it does NOT degrade to
916
+ // allow when it cannot read, because the build's verifyReferences backstop only sees main. A
917
+ // transient branch-read failure that looked like "no references" would let a delete strand an
918
+ // inbound edge held in an unpublished draft, so refuse with a 503 rather than proceed.
919
+ let refIndex;
920
+ try {
921
+ refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
922
+ }
923
+ catch {
924
+ return fail(503, {
925
+ error: 'Could not verify where this entry is referenced. Try again.',
926
+ inboundLinks: [],
927
+ id,
928
+ });
929
+ }
930
+ const refRows = refIndex.get(`${concept.id}/${id}`) ?? [];
931
+ if (refRows.length > 0) {
932
+ // Carry each referencing entry into the InboundLink shape the blockers list renders. A branch row
933
+ // has no permalink (the edit is unpublished), so default it to empty.
934
+ const referencingEntries = refRows.map((row) => ({
935
+ concept: row.concept,
936
+ id: row.id,
937
+ title: row.title,
938
+ permalink: row.permalink ?? '',
939
+ }));
940
+ const n = referencingEntries.length;
941
+ return fail(409, {
942
+ error: `Cannot delete ${id}: ${n} ${n === 1 ? 'entry references' : 'entries reference'} it.`,
943
+ inboundLinks: referencingEntries,
944
+ id,
945
+ });
946
+ }
918
947
  // When the entry was never published (absent from main), the branch delete is the whole
919
948
  // operation; main has nothing to commit, so the only honest log record is the discard of
920
949
  // the pending edits.
921
- const onMain = await readRaw(runtime.backend, path, token);
950
+ const onMain = await backend.readFile(path, backend.defaultBranch);
922
951
  if (onMain === null) {
923
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
952
+ await backend.deleteBranch(pendingBranch(concept.id, id));
924
953
  log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
925
954
  throw redirect(303, `/admin/${concept.id}`);
926
955
  }
927
956
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
928
957
  const commitFields = { concept: concept.id, id, editor: editor.email };
929
958
  try {
930
- await commitFiles(runtime.backend, [
959
+ await backend.commit(backend.defaultBranch, [
931
960
  { path, content: null },
932
961
  { path: runtime.manifestPath, content: nextManifest },
933
- ], { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
962
+ ], { name: editor.displayName, email: editor.email }, `Delete ${concept.label.toLowerCase()}: ${id}`);
934
963
  log.info('commit.succeeded', commitFields);
935
964
  }
936
965
  catch (err) {
@@ -941,7 +970,7 @@ export function createContentRoutes(runtime, deps = {}) {
941
970
  // recoverable (it lists as a never-published row a discard can clean up), matching
942
971
  // publish's posture, so the entry's deletion still completes.
943
972
  try {
944
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
973
+ await backend.deleteBranch(pendingBranch(concept.id, id));
945
974
  }
946
975
  catch {
947
976
  // The entry is gone from main; the straggler shows as a pending row until discarded.
@@ -979,10 +1008,10 @@ export function createContentRoutes(runtime, deps = {}) {
979
1008
  const id = event.params.id ?? '';
980
1009
  if (!isValidId(id))
981
1010
  throw error(400, 'Invalid entry id');
982
- const token = await mintToken(event.platform?.env ?? {});
1011
+ const backend = resolveBackend(event);
983
1012
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
984
1013
  // them, so refuse until the editor publishes or discards.
985
- if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
1014
+ if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
986
1015
  return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
987
1016
  }
988
1017
  const form = await event.request.formData();
@@ -1003,45 +1032,103 @@ export function createContentRoutes(runtime, deps = {}) {
1003
1032
  // Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
1004
1033
  // single readRaw cannot tell apart: a static collision with an existing entry, and a
1005
1034
  // concurrent-rename race where another editor renamed onto this path between load and submit.
1006
- const clobber = await readRaw(runtime.backend, newPath, token);
1035
+ const clobber = await backend.readFile(newPath, backend.defaultBranch);
1007
1036
  if (clobber !== null) {
1008
1037
  return fail(409, { error: 'An entry with that slug already exists.' });
1009
1038
  }
1010
1039
  const [entryRaw, manifest] = await Promise.all([
1011
- readRaw(runtime.backend, oldPath, token),
1012
- readManifest(token),
1040
+ backend.readFile(oldPath, backend.defaultBranch),
1041
+ readManifest(backend),
1013
1042
  ]);
1014
1043
  if (entryRaw === null)
1015
1044
  throw error(404, 'Entry not found');
1045
+ // Cross-branch reference gate (fail-closed). A reference index unions main's published edges and
1046
+ // every open cairn/* branch; if it cannot be built (a transient branch read failure), refuse
1047
+ // rather than rename a still-referenced target and strand the inbound edge.
1048
+ let refIndex;
1049
+ try {
1050
+ refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
1051
+ }
1052
+ catch {
1053
+ return fail(409, { error: 'Could not verify references. Try again.' });
1054
+ }
1055
+ // Refuse when a THIRD-PARTY open branch holds an inbound reference (symmetric with the pending-edits
1056
+ // guard). The strict index unions main and every branch, so filter before refusing: gate
1057
+ // origin.kind === 'branch' FIRST (a published row has no .branch, so a bare branch-name compare would
1058
+ // trip on every main-side inbound and over-refuse), then exclude the entry's OWN pending branch
1059
+ // (already refused above and absent by construction here). Published (main) inbound rows are NOT
1060
+ // refused; they are repointed below.
1061
+ const ownBranch = pendingBranch(concept.id, id);
1062
+ const conflictBranches = (refIndex.get(`${concept.id}/${id}`) ?? [])
1063
+ .filter((row) => row.origin.kind === 'branch' && row.origin.branch !== ownBranch)
1064
+ .map((row) => `${row.concept}/${row.id}`);
1065
+ if (conflictBranches.length > 0) {
1066
+ const names = [...new Set(conflictBranches)].join(', ');
1067
+ return fail(409, { error: `Another editor has unpublished edits referencing this entry: ${names}. Ask them to publish or discard, then rename.` });
1068
+ }
1016
1069
  const oldHref = formatCairnToken({ concept: concept.id, id });
1017
1070
  const newHref = formatCairnToken({ concept: concept.id, id: newId });
1018
- // The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
1019
- // the new path so the row carries the new id and permalink by construction.
1020
- const movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
1071
+ // The moved file keeps its content, except a self-token rewrite and a self-reference rewrite.
1072
+ let movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
1073
+ // The moved entry is excluded from inboundReferences, so it must repoint its OWN frontmatter
1074
+ // self-references (e.g. `related` listing its own old id), or the re-derived row would carry the
1075
+ // old id and verifyReferences would flag a dangling edge at the deploy gate.
1076
+ for (const f of concept.fields) {
1077
+ if (f.type === 'reference' || (f.type === 'array' && f.item.type === 'reference')) {
1078
+ movedRaw = rewriteFrontmatterReference(movedRaw, f.name, id, newId);
1079
+ }
1080
+ }
1081
+ // Re-derive its manifest row from the new path so the row carries the new id and permalink by
1082
+ // construction (and the rewritten self-reference edge at the new id).
1021
1083
  const changes = [
1022
1084
  { path: oldPath, content: null },
1023
1085
  { path: newPath, content: movedRaw },
1024
1086
  ];
1025
1087
  let next = removeEntry(manifest, concept.id, id);
1026
1088
  next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
1027
- // Rewrite every inbound linker's body and re-derive its row, so its outbound edge points at the
1028
- // new id. A linker missing from the repo is skipped; the build backstop catches any drift.
1089
+ const repoints = new Map();
1090
+ const linkerPathFor = (linkerConcept, linkerId) => `${linkerConcept.dir}/${filenameFromId(linkerId)}`;
1029
1091
  for (const linker of inboundLinks(manifest, concept.id, id)) {
1030
1092
  const linkerConcept = findConcept(runtime.concepts, linker.concept);
1031
1093
  if (!linkerConcept)
1032
1094
  continue;
1033
- const linkerPath = `${linkerConcept.dir}/${filenameFromId(linker.id)}`;
1034
- const linkerRaw = await readRaw(runtime.backend, linkerPath, token);
1095
+ const path = linkerPathFor(linkerConcept, linker.id);
1096
+ const existing = repoints.get(path);
1097
+ if (existing)
1098
+ existing.hasLink = true;
1099
+ else
1100
+ repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: true, fields: [] });
1101
+ }
1102
+ for (const linker of inboundReferences(manifest, concept.id, id)) {
1103
+ const linkerConcept = findConcept(runtime.concepts, linker.concept);
1104
+ if (!linkerConcept)
1105
+ continue;
1106
+ const path = linkerPathFor(linkerConcept, linker.id);
1107
+ const existing = repoints.get(path);
1108
+ if (existing)
1109
+ existing.fields = [...new Set([...existing.fields, ...linker.fields])];
1110
+ else
1111
+ repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: false, fields: linker.fields });
1112
+ }
1113
+ for (const [linkerPath, repoint] of repoints) {
1114
+ const linkerConcept = findConcept(runtime.concepts, repoint.concept);
1115
+ if (!linkerConcept)
1116
+ continue;
1117
+ let linkerRaw = await backend.readFile(linkerPath, backend.defaultBranch);
1035
1118
  if (linkerRaw === null)
1036
1119
  continue;
1037
- const rewritten = rewriteCairnLink(linkerRaw, oldHref, newHref);
1038
- changes.push({ path: linkerPath, content: rewritten });
1039
- next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
1120
+ if (repoint.hasLink)
1121
+ linkerRaw = rewriteCairnLink(linkerRaw, oldHref, newHref);
1122
+ for (const field of repoint.fields) {
1123
+ linkerRaw = rewriteFrontmatterReference(linkerRaw, field, id, newId);
1124
+ }
1125
+ changes.push({ path: linkerPath, content: linkerRaw });
1126
+ next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: linkerRaw }));
1040
1127
  }
1041
1128
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
1042
1129
  const commitFields = { concept: concept.id, id: newId, editor: editor.email };
1043
1130
  try {
1044
- await commitFiles(runtime.backend, changes, { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } }, token);
1131
+ await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`);
1045
1132
  log.info('commit.succeeded', commitFields);
1046
1133
  }
1047
1134
  catch (err) {
@@ -1201,14 +1288,14 @@ export function createContentRoutes(runtime, deps = {}) {
1201
1288
  */
1202
1289
  async function mediaDeleteAction(event) {
1203
1290
  const editor = requireSession(event);
1204
- const token = await mintToken(event.platform?.env ?? {});
1291
+ const backend = resolveBackend(event);
1205
1292
  const form = await event.request.formData();
1206
1293
  const hash = String(form.get('hash') ?? '');
1207
1294
  if (!MEDIA_HASH_RE.test(hash))
1208
1295
  throw error(400, 'Invalid media hash');
1209
1296
  // The asset must be committed on the default branch to be deletable here. A branch-only upload
1210
1297
  // (the common 2b case before publish) has no main row; removing it is a discard of the draft.
1211
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1298
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1212
1299
  const row = manifest[hash];
1213
1300
  if (!row) {
1214
1301
  return fail(404, {
@@ -1224,7 +1311,7 @@ export function createContentRoutes(runtime, deps = {}) {
1224
1311
  // make a still-referenced asset look orphaned and skip the typed-slug confirm.
1225
1312
  let index;
1226
1313
  try {
1227
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1314
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
1228
1315
  }
1229
1316
  catch {
1230
1317
  // Fail closed: we could not verify every place the asset is used, so refuse rather than risk
@@ -1272,7 +1359,7 @@ export function createContentRoutes(runtime, deps = {}) {
1272
1359
  // Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
1273
1360
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
1274
1361
  try {
1275
- 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);
1362
+ await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }], { name: editor.displayName, email: editor.email }, `Delete media: ${row.slug}`);
1276
1363
  log.info('commit.succeeded', commitFields);
1277
1364
  }
1278
1365
  catch (err) {
@@ -1306,7 +1393,7 @@ export function createContentRoutes(runtime, deps = {}) {
1306
1393
  */
1307
1394
  async function mediaBulkDelete(event) {
1308
1395
  const editor = requireSession(event);
1309
- const token = await mintToken(event.platform?.env ?? {});
1396
+ const backend = resolveBackend(event);
1310
1397
  // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1311
1398
  // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
1312
1399
  // dropped silently rather than surfaced as a skip (it was never a real selection).
@@ -1327,7 +1414,7 @@ export function createContentRoutes(runtime, deps = {}) {
1327
1414
  }
1328
1415
  const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
1329
1416
  // Read the fresh media manifest (the deletable rows come from here, by hash).
1330
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1417
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1331
1418
  // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
1332
1419
  // the commit, exactly like single delete.
1333
1420
  const resolved = runtime.resolvedAssets;
@@ -1345,7 +1432,7 @@ export function createContentRoutes(runtime, deps = {}) {
1345
1432
  // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
1346
1433
  let index;
1347
1434
  try {
1348
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1435
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
1349
1436
  }
1350
1437
  catch {
1351
1438
  return fail(503, { error: 'Could not verify where these assets are used. Try again.' });
@@ -1362,7 +1449,7 @@ export function createContentRoutes(runtime, deps = {}) {
1362
1449
  next = removeMediaEntry(next, hash);
1363
1450
  const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
1364
1451
  try {
1365
- await commitFiles(runtime.backend, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }], { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } }, token);
1452
+ await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }], { name: editor.displayName, email: editor.email }, `Delete ${plan.deletable.length} media assets`);
1366
1453
  log.info('commit.succeeded', commitFields);
1367
1454
  }
1368
1455
  catch (err) {
@@ -1405,7 +1492,7 @@ export function createContentRoutes(runtime, deps = {}) {
1405
1492
  */
1406
1493
  async function mediaOrphanScan(event) {
1407
1494
  requireSession(event);
1408
- const token = await mintToken(event.platform?.env ?? {});
1495
+ const backend = resolveBackend(event);
1409
1496
  // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
1410
1497
  // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
1411
1498
  const resolved = runtime.resolvedAssets;
@@ -1418,7 +1505,7 @@ export function createContentRoutes(runtime, deps = {}) {
1418
1505
  return fail(503, { error: 'The media bucket is not bound.' });
1419
1506
  }
1420
1507
  // Read the fresh media manifest for the reconcile's manifest side.
1421
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1508
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1422
1509
  // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
1423
1510
  // and the strict usage build (a branch read that must complete in full) are both unsafe to use
1424
1511
  // partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
@@ -1427,7 +1514,7 @@ export function createContentRoutes(runtime, deps = {}) {
1427
1514
  let index;
1428
1515
  try {
1429
1516
  reconcile = await runReconcile(rawBucket, manifest);
1430
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1517
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
1431
1518
  }
1432
1519
  catch {
1433
1520
  return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' });
@@ -1463,7 +1550,7 @@ export function createContentRoutes(runtime, deps = {}) {
1463
1550
  */
1464
1551
  async function mediaPurgeOrphans(event) {
1465
1552
  const editor = requireSession(event);
1466
- const token = await mintToken(event.platform?.env ?? {});
1553
+ const backend = resolveBackend(event);
1467
1554
  // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
1468
1555
  // deletes through the MediaStore seam, so wrap the raw binding.
1469
1556
  const resolved = runtime.resolvedAssets;
@@ -1486,14 +1573,14 @@ export function createContentRoutes(runtime, deps = {}) {
1486
1573
  return fail(400, { error: 'Type the number of files to confirm the purge.' });
1487
1574
  }
1488
1575
  // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
1489
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1576
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1490
1577
  // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
1491
1578
  // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
1492
1579
  // branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
1493
1580
  // treated as a true orphan. Build exactly one index, never one per key.
1494
1581
  let index;
1495
1582
  try {
1496
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1583
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
1497
1584
  }
1498
1585
  catch {
1499
1586
  return fail(503, { error: 'Could not verify where these files are used. Try again.' });
@@ -1536,12 +1623,12 @@ export function createContentRoutes(runtime, deps = {}) {
1536
1623
  */
1537
1624
  async function mediaUpdateAction(event) {
1538
1625
  const editor = requireSession(event);
1539
- const token = await mintToken(event.platform?.env ?? {});
1626
+ const backend = resolveBackend(event);
1540
1627
  const form = await event.request.formData();
1541
1628
  const hash = String(form.get('hash') ?? '');
1542
1629
  if (!MEDIA_HASH_RE.test(hash))
1543
1630
  throw error(400, 'Invalid media hash');
1544
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1631
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1545
1632
  const row = manifest[hash];
1546
1633
  if (!row) {
1547
1634
  return fail(404, { error: 'That asset is not committed.' });
@@ -1555,7 +1642,7 @@ export function createContentRoutes(runtime, deps = {}) {
1555
1642
  const edited = { ...row, displayName: displayName || slug, slug, alt };
1556
1643
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
1557
1644
  try {
1558
- 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);
1645
+ await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }], { name: editor.displayName, email: editor.email }, `Update media: ${edited.slug}`);
1559
1646
  log.info('commit.succeeded', commitFields);
1560
1647
  }
1561
1648
  catch (err) {
@@ -1608,8 +1695,8 @@ export function createContentRoutes(runtime, deps = {}) {
1608
1695
  if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
1609
1696
  return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 });
1610
1697
  }
1611
- const token = await mintToken(event.platform?.env ?? {});
1612
- const contentManifest = await readManifest(token);
1698
+ const backend = resolveBackend(event);
1699
+ const contentManifest = await readManifest(backend);
1613
1700
  const newToken = replacementToken(slug, newHash);
1614
1701
  // Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
1615
1702
  // throws out of here rather than degrading to an absent reference; catch it and fail closed, the
@@ -1617,8 +1704,7 @@ export function createContentRoutes(runtime, deps = {}) {
1617
1704
  let plan;
1618
1705
  try {
1619
1706
  plan = await planMediaRewrite({
1620
- backend: runtime.backend,
1621
- token,
1707
+ backend,
1622
1708
  concepts: runtime.concepts,
1623
1709
  contentManifest,
1624
1710
  hash: oldHash,
@@ -1665,7 +1751,7 @@ export function createContentRoutes(runtime, deps = {}) {
1665
1751
  */
1666
1752
  async function mediaReplaceApply(event) {
1667
1753
  const editor = requireSession(event);
1668
- const token = await mintToken(event.platform?.env ?? {});
1754
+ const backend = resolveBackend(event);
1669
1755
  const form = await event.request.formData();
1670
1756
  const oldHash = String(form.get('oldHash') ?? '');
1671
1757
  const newHash = String(form.get('newHash') ?? '');
@@ -1685,7 +1771,7 @@ export function createContentRoutes(runtime, deps = {}) {
1685
1771
  }
1686
1772
  // The old asset must be committed on main to be replaceable here. A branch-only upload has no main
1687
1773
  // row; it is replaced by editing its draft, not here.
1688
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1774
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1689
1775
  const row = manifest[oldHash];
1690
1776
  if (!row) {
1691
1777
  return fail(404, {
@@ -1710,10 +1796,9 @@ export function createContentRoutes(runtime, deps = {}) {
1710
1796
  let plan;
1711
1797
  try {
1712
1798
  plan = await planMediaRewrite({
1713
- backend: runtime.backend,
1714
- token,
1799
+ backend,
1715
1800
  concepts: runtime.concepts,
1716
- contentManifest: await readManifest(token),
1801
+ contentManifest: await readManifest(backend),
1717
1802
  hash: oldHash,
1718
1803
  transform: (md) => repointMediaRef(md, oldHash, newToken),
1719
1804
  });
@@ -1743,7 +1828,7 @@ export function createContentRoutes(runtime, deps = {}) {
1743
1828
  changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
1744
1829
  const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
1745
1830
  try {
1746
- await commitFiles(runtime.backend, changes, { message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1831
+ await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Replace media: ${row.slug}`);
1747
1832
  log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
1748
1833
  }
1749
1834
  catch (err) {
@@ -1782,22 +1867,21 @@ export function createContentRoutes(runtime, deps = {}) {
1782
1867
  if (!MEDIA_HASH_RE.test(hash)) {
1783
1868
  return fail(400, { error: 'Invalid media hash.' });
1784
1869
  }
1785
- const token = await mintToken(event.platform?.env ?? {});
1870
+ const backend = resolveBackend(event);
1786
1871
  // The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
1787
1872
  // asset with no committed row has no default alt to push, so refuse.
1788
- const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1873
+ const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1789
1874
  const row = mediaManifest[hash];
1790
1875
  if (!row) {
1791
1876
  return fail(404, { error: 'That asset is not committed.' });
1792
1877
  }
1793
1878
  // Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
1794
1879
  // it and fail closed, the same posture replace and delete take.
1795
- const contentManifest = await readManifest(token);
1880
+ const contentManifest = await readManifest(backend);
1796
1881
  let plan;
1797
1882
  try {
1798
1883
  plan = await planMediaRewrite({
1799
- backend: runtime.backend,
1800
- token,
1884
+ backend,
1801
1885
  concepts: runtime.concepts,
1802
1886
  contentManifest,
1803
1887
  hash,
@@ -1843,14 +1927,14 @@ export function createContentRoutes(runtime, deps = {}) {
1843
1927
  */
1844
1928
  async function mediaAltApply(event) {
1845
1929
  const editor = requireSession(event);
1846
- const token = await mintToken(event.platform?.env ?? {});
1930
+ const backend = resolveBackend(event);
1847
1931
  const form = await event.request.formData();
1848
1932
  const hash = String(form.get('hash') ?? '');
1849
1933
  if (!MEDIA_HASH_RE.test(hash))
1850
1934
  throw error(400, 'Invalid media hash');
1851
1935
  // The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
1852
1936
  const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
1853
- const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1937
+ const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1854
1938
  const row = mediaManifest[hash];
1855
1939
  if (!row) {
1856
1940
  return fail(404, { error: 'That asset is not committed.' });
@@ -1864,10 +1948,9 @@ export function createContentRoutes(runtime, deps = {}) {
1864
1948
  let plan;
1865
1949
  try {
1866
1950
  plan = await planMediaRewrite({
1867
- backend: runtime.backend,
1868
- token,
1951
+ backend,
1869
1952
  concepts: runtime.concepts,
1870
- contentManifest: await readManifest(token),
1953
+ contentManifest: await readManifest(backend),
1871
1954
  hash,
1872
1955
  transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
1873
1956
  });
@@ -1884,7 +1967,7 @@ export function createContentRoutes(runtime, deps = {}) {
1884
1967
  const changes = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
1885
1968
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
1886
1969
  try {
1887
- await commitFiles(runtime.backend, changes, { message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1970
+ await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Propagate alt: ${row.slug}`);
1888
1971
  log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
1889
1972
  }
1890
1973
  catch (err) {
@@ -1908,21 +1991,21 @@ export function createContentRoutes(runtime, deps = {}) {
1908
1991
  * the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
1909
1992
  * the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
1910
1993
  * word that already landed is preserved and the result is the same sorted set regardless of order.
1911
- * Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
1912
- * under the commit, which the caller catches to retry once.
1994
+ * Returns the merged word list. Throws CommitConflictError (via backend.commit) when the branch
1995
+ * moves under the commit, which the caller catches to retry once.
1913
1996
  */
1914
- async function mergeAndCommitDictionary(token, additions, editor) {
1997
+ async function mergeAndCommitDictionary(backend, additions, editor) {
1915
1998
  const path = dictionaryFilePath();
1916
1999
  // The existing file as its canonical sorted set, so a no-op add is detected against the same
1917
2000
  // normalization the commit would write (an already-sorted file never re-commits just to reorder).
1918
- const canonicalExisting = mergeDictionaryWords(parseDictionary(await readRaw(runtime.backend, path, token)), []);
2001
+ const canonicalExisting = mergeDictionaryWords(parseDictionary(await backend.readFile(path, backend.defaultBranch)), []);
1919
2002
  const merged = mergeDictionaryWords(canonicalExisting, additions);
1920
2003
  // Nothing new (every addition was already present): skip the commit so an idempotent add never
1921
2004
  // pushes an empty commit that would redeploy the site. The merged set is still returned so the
1922
2005
  // client reconciles its pending additions away.
1923
2006
  if (merged.length === canonicalExisting.length)
1924
2007
  return merged;
1925
- await commitFiles(runtime.backend, [{ path, content: serializeDictionary(merged) }], { message: `Add to dictionary: ${additions.join(', ')}`, author: { name: editor.displayName, email: editor.email } }, token);
2008
+ await backend.commit(backend.defaultBranch, [{ path, content: serializeDictionary(merged) }], { name: editor.displayName, email: editor.email }, `Add to dictionary: ${additions.join(', ')}`);
1926
2009
  return merged;
1927
2010
  }
1928
2011
  /**
@@ -1970,8 +2053,8 @@ export function createContentRoutes(runtime, deps = {}) {
1970
2053
  /**
1971
2054
  * Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
1972
2055
  * the same committed YAML the nav editor writes, with the session editor as author. The transport is
1973
- * the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
1974
- * `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
2056
+ * the nav save's exactly: a form POST carrying the conventions JSON, a head-guarded
2057
+ * `backend.commit`, and a stale-head `isConflict` bounced back as a reload prompt. Only the conventions
1975
2058
  * block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
1976
2059
  * never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
1977
2060
  * enabled, so the gate state's absent editor tier can never be saved past.
@@ -1992,15 +2075,20 @@ export function createContentRoutes(runtime, deps = {}) {
1992
2075
  throw redirect(303, `/admin/settings?error=${encodeURIComponent(message)}`);
1993
2076
  }
1994
2077
  const path = siteConfigPath();
1995
- const token = await mintToken(event.platform?.env ?? {});
1996
- const raw = await readRaw(runtime.backend, path, token);
2078
+ const backend = resolveBackend(event);
2079
+ // Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
2080
+ // merges. The settings write lands on the default branch and triggers a deploy, so it is
2081
+ // fail-closed: a concurrent commit to the config moves the head off this value and the commit
2082
+ // throws a conflict, surfacing the reload-and-reapply prompt below rather than a last-writer-wins.
2083
+ const head = await backend.branchHead(backend.defaultBranch);
2084
+ const raw = await backend.readFile(path, backend.defaultBranch);
1997
2085
  if (raw === null)
1998
2086
  throw error(404, 'Site config not found');
1999
2087
  // Parse first so a malformed file fails before the write rather than committing onto a broken base.
2000
2088
  parseSiteConfig(raw);
2001
2089
  const commitFields = { concept: 'settings', id: 'tidy', editor: editor.email };
2002
2090
  try {
2003
- await commitFile(runtime.backend, path, setTidy(raw, conventions), { message: 'Update tidy settings', author: { name: editor.displayName, email: editor.email } }, token);
2091
+ await backend.commit(backend.defaultBranch, [{ path, content: setTidy(raw, conventions) }], { name: editor.displayName, email: editor.email }, 'Update tidy settings', head ?? undefined);
2004
2092
  log.info('commit.succeeded', commitFields);
2005
2093
  }
2006
2094
  catch (err) {
@@ -2021,7 +2109,7 @@ export function createContentRoutes(runtime, deps = {}) {
2021
2109
  * `{ words }`. It reads the current file from the default branch, inserts the validated words in
2022
2110
  * sorted order if absent (idempotent), and commits through the GitHub-App pipeline.
2023
2111
  *
2024
- * The commit is SHA-guarded with commit-and-retry: commitFiles throws CommitConflictError when the
2112
+ * The commit is SHA-guarded with commit-and-retry: backend.commit throws CommitConflictError when the
2025
2113
  * branch moved under it, which is caught here to re-read the new head, re-merge the same additions
2026
2114
  * (the sorted insert is order-independent, so a concurrent editor's word is preserved), and retry
2027
2115
  * once. The response is the merged word list, so the client drops the now-committed words from its
@@ -2056,10 +2144,10 @@ export function createContentRoutes(runtime, deps = {}) {
2056
2144
  if (additions.length === 0) {
2057
2145
  return fail(400, { error: 'No valid word to add to the dictionary.' });
2058
2146
  }
2059
- const token = await mintToken(event.platform?.env ?? {});
2147
+ const backend = resolveBackend(event);
2060
2148
  const commitFields = { concept: 'dictionary', id: additions[0], editor: editor.email };
2061
2149
  try {
2062
- const words = await mergeAndCommitDictionary(token, additions, editor);
2150
+ const words = await mergeAndCommitDictionary(backend, additions, editor);
2063
2151
  log.info('dictionary.added', { editor: editor.email, words: additions });
2064
2152
  return { words };
2065
2153
  }
@@ -2070,7 +2158,7 @@ export function createContentRoutes(runtime, deps = {}) {
2070
2158
  // retry once. The merge is order-independent, so a concurrent editor's word that landed in the
2071
2159
  // window is preserved and the two adds converge on the same sorted set.
2072
2160
  try {
2073
- const words = await mergeAndCommitDictionary(token, additions, editor);
2161
+ const words = await mergeAndCommitDictionary(backend, additions, editor);
2074
2162
  log.info('dictionary.added', { editor: editor.email, words: additions, retried: true });
2075
2163
  return { words };
2076
2164
  }
@@ -2194,7 +2282,7 @@ export function createContentRoutes(runtime, deps = {}) {
2194
2282
  log.info('tidy.done', { editor: editor.email, model: message.model, usage: message.usage });
2195
2283
  return { corrected, model: message.model, usage: message.usage };
2196
2284
  }
2197
- return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction, mintToken };
2285
+ return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction };
2198
2286
  }
2199
2287
  /**
2200
2288
  * The cap, in characters, on the stored alt text. The human fields are display copy, not content,