@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,19 @@
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, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
12
15
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
13
- import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
14
- import { listMarkdown, readRaw, commitFile, commitFiles, type FileChange } from '../github/repo.js';
15
- import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
16
+ import type { BackendEnv } from '../github/credentials.js';
17
+ import type { Backend } from '../github/backend.js';
18
+ import type { FileChange } from '../github/repo.js';
16
19
  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, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
20
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, inboundReferences, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
19
21
  import { deriveGettingStarted, type GettingStarted } from '../content/getting-started.js';
20
22
  import { markdownReference, type MarkdownReferenceRow } from '../components/markdown-reference.js';
21
23
  import { isConflict } from '../github/types.js';
@@ -51,7 +53,7 @@ import type { BranchRef } from '../media/rewrite-plan.js';
51
53
  import { planBulkDelete } from '../media/bulk-delete-plan.js';
52
54
  import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
53
55
  import type { CookieJar, EventBase } from './types.js';
54
- import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
56
+ import type { CairnRuntime, ConceptDescriptor, NamedField, PreviewConfig, ResolvedPreview } from '../content/types.js';
55
57
  import type { Editor, Role } from '../auth/types.js';
56
58
  // R2Bucket is named only inside uploadAction to cast the raw binding for r2Store. It is a type-only
57
59
  // import that never appears in an exported signature, so it does not reach the public `.d.ts`.
@@ -132,7 +134,7 @@ export interface EditData {
132
134
  conceptId: string;
133
135
  id: string;
134
136
  label: string;
135
- fields: FrontmatterField[];
137
+ fields: NamedField[];
136
138
  frontmatter: Record<string, unknown>;
137
139
  body: string;
138
140
  title: string;
@@ -299,7 +301,7 @@ export interface HelpData {
299
301
  }
300
302
 
301
303
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
302
- export interface ContentEvent extends EventBase<GithubKeyEnv> {
304
+ export interface ContentEvent extends EventBase<BackendEnv> {
303
305
  params: Record<string, string>;
304
306
  /**
305
307
  * SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
@@ -339,10 +341,12 @@ export interface TidyClient {
339
341
 
340
342
  export interface ContentRoutesDeps {
341
343
  /**
342
- * Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
343
- * A bare string works too; the routes await whatever comes back.
344
+ * Override the resolved content backend. A test injects a live `Backend` (a `makeGithubBackend`
345
+ * over a fetch double, or an in-memory fake) so the read and commit paths run with no real token
346
+ * mint. When set it replaces the per-handler `locals.backend ?? runtime.backend.connect(env)`
347
+ * resolve; a production caller leaves it unset and the dev double rides `event.locals.backend`.
344
348
  */
345
- mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
349
+ backend?: Backend;
346
350
  /**
347
351
  * Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
348
352
  * SDK client. Injected in tests so `messages.create` is stubbed and no network call (or real key)
@@ -656,8 +660,15 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
656
660
  *
657
661
  */
658
662
  export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
659
- const mintToken =
660
- deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
663
+ /**
664
+ * Resolve the live content backend for one request. A test seam (`deps.backend`) wins, then the
665
+ * dev double's `event.locals.backend`, then the production `runtime.backend.connect(env)`. The
666
+ * GitHub provider mints and caches its installation token lazily behind `connect`, so a
667
+ * per-request resolve re-signs only on a cache miss.
668
+ */
669
+ function resolveBackend(event: ContentEvent): Backend {
670
+ return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
671
+ }
661
672
 
662
673
  // The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
663
674
  // (deps.anthropic) so messages.create is stubbed and no network call or real key is ever needed. The
@@ -670,8 +681,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
670
681
  * Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
671
682
  * Always read from main: pending branches carry no manifest copy.
672
683
  */
673
- async function readManifest(token: string): Promise<Manifest> {
674
- const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
684
+ async function readManifest(backend: Backend): Promise<Manifest> {
685
+ const raw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
675
686
  return raw === null ? emptyManifest() : parseManifest(raw);
676
687
  }
677
688
 
@@ -719,8 +730,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
719
730
  // than failing the whole admin shell or showing a wrong publish-all count.
720
731
  let pendingEntries: { concept: string; id: string }[] | null = null;
721
732
  try {
722
- const token = await mintToken(event.platform?.env ?? {});
723
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
733
+ const backend = resolveBackend(event);
734
+ const names = await backend.listBranches(PENDING_PREFIX);
724
735
  pendingEntries = names.flatMap((name) => {
725
736
  const entry = pendingEntryOf(name);
726
737
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
@@ -753,9 +764,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
753
764
  let manifest = emptyManifest();
754
765
  let pending: { concept: string; id: string }[] = [];
755
766
  try {
756
- const token = await mintToken(event.platform?.env ?? {});
757
- manifest = await readManifest(token);
758
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
767
+ const backend = resolveBackend(event);
768
+ manifest = await readManifest(backend);
769
+ const names = await backend.listBranches(PENDING_PREFIX);
759
770
  pending = names.flatMap((name) => {
760
771
  const entry = pendingEntryOf(name);
761
772
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
@@ -783,12 +794,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
783
794
  */
784
795
  async function summarize(
785
796
  file: { id: string; path: string },
786
- token: string,
797
+ backend: Backend,
787
798
  status: EntrySummary['status'],
788
- repo = runtime.backend,
799
+ ref = backend.defaultBranch,
789
800
  ): Promise<EntrySummary> {
790
801
  try {
791
- const raw = await readRaw(repo, file.path, token);
802
+ const raw = await backend.readFile(file.path, ref);
792
803
  if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
793
804
  const { frontmatter, body } = parseMarkdown(raw);
794
805
  const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
@@ -807,26 +818,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
807
818
  * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
808
819
  * an id-only row, so a ghost ref still lists.
809
820
  */
810
- function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
811
- return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
812
- ...runtime.backend,
813
- branch: pendingBranch(concept.id, id),
814
- });
821
+ function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], backend: Backend): Promise<EntrySummary> {
822
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, backend, status, pendingBranch(concept.id, id));
815
823
  }
816
824
 
817
825
  /**
818
826
  * The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
819
827
  * and read each one for its row, with edited and new rows reading branch-first.
820
828
  */
821
- async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
822
- const files = await listMarkdown(runtime.backend, concept.dir, token);
829
+ async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, backend: Backend): Promise<EntrySummary[]> {
830
+ const files = await backend.readEntries(concept.dir, backend.defaultBranch);
823
831
  const entries = await Promise.all(
824
- files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))),
832
+ files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', backend) : summarize(f, backend, 'published'))),
825
833
  );
826
834
  // A ref with no main file is a never-published entry; its row reads from its branch.
827
835
  const listed = new Set(files.map((f) => f.id));
828
836
  const newRows = await Promise.all(
829
- [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
837
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)),
830
838
  );
831
839
  return [...entries, ...newRows];
832
840
  }
@@ -846,16 +854,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
846
854
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
847
855
  const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
848
856
  const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
849
- let token: string;
850
- try {
851
- token = await mintToken(event.platform?.env ?? {});
852
- } catch {
853
- return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
854
- }
857
+ const backend = resolveBackend(event);
855
858
  try {
856
859
  const [manifestRaw, refs] = await Promise.all([
857
- readRaw(runtime.backend, runtime.manifestPath, token),
858
- listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
860
+ backend.readFile(runtime.manifestPath, backend.defaultBranch),
861
+ backend.listBranches(`${PENDING_PREFIX}${concept.id}/`),
859
862
  ]);
860
863
  const pendingIds = new Set(
861
864
  refs.flatMap((name) => {
@@ -866,7 +869,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
866
869
  // A repo with no committed manifest yet (a fresh site before its first publish) falls back
867
870
  // to the crawl; a manifest that parses but is empty is trusted as-is.
868
871
  if (manifestRaw === null) {
869
- return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
872
+ return { ...base, entries: await crawlEntries(concept, pendingIds, backend), error: null };
870
873
  }
871
874
  // Newest id first, the same order the crawl's file listing produced.
872
875
  const rows = parseManifest(manifestRaw)
@@ -875,13 +878,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
875
878
  const entries = await Promise.all(
876
879
  rows.map((e) =>
877
880
  pendingIds.has(e.id)
878
- ? pendingRow(concept, e.id, 'edited', token)
881
+ ? pendingRow(concept, e.id, 'edited', backend)
879
882
  : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const, summary: e.summary ?? null },
880
883
  ),
881
884
  );
882
885
  const listed = new Set(rows.map((e) => e.id));
883
886
  const newRows = await Promise.all(
884
- [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
887
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)),
885
888
  );
886
889
  return { ...base, entries: [...entries, ...newRows], error: null };
887
890
  } catch {
@@ -910,30 +913,27 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
910
913
  else if (event.url.searchParams.get('bulkDeleted') === '1') flash = 'bulkDeleted';
911
914
  else if (event.url.searchParams.get('orphansPurged') === '1') flash = 'orphansPurged';
912
915
  const flashError = event.url.searchParams.get('error');
913
- let token: string;
914
- try {
915
- token = await mintToken(event.platform?.env ?? {});
916
- } catch {
917
- return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
918
- }
916
+ const backend = resolveBackend(event);
919
917
 
920
918
  // Union the media manifest by hash: main's rows first, then any branch hash not already present.
921
919
  // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
922
920
  // absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
923
921
  // The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
924
922
  // not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
923
+ // The token mint is now lazy inside the first read, so a token or a network failure both land in
924
+ // this one degrade rather than the old separate could-not-authenticate tier.
925
925
  const union = new Map<string, MediaEntry>();
926
926
  let branchNames: string[] = [];
927
927
  try {
928
- const mediaRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
928
+ const mediaRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
929
929
  for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
930
930
  union.set(hash, e);
931
931
  }
932
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
932
+ const names = await backend.listBranches(PENDING_PREFIX);
933
933
  branchNames = names;
934
934
  const branchManifests = await Promise.all(
935
935
  names.map((name) =>
936
- readRaw({ ...runtime.backend, branch: name }, runtime.mediaManifestPath, token)
936
+ backend.readFile(runtime.mediaManifestPath, name)
937
937
  .then((raw) => parseMediaManifest(parseMediaJson(raw)))
938
938
  .catch(() => ({}) as Record<string, MediaEntry>),
939
939
  ),
@@ -954,11 +954,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
954
954
  // here keeps the asset list intact with an empty overlay, since the screen still lists assets.
955
955
  let usage: Record<string, MediaUsageInfo> = {};
956
956
  try {
957
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
957
+ const manifestRaw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
958
958
  const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
959
959
  // Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
960
960
  // best-effort behavior (a failed branch read degrades that one branch, not the screen).
961
- const index = await buildUsageIndex(runtime.backend, token, runtime.concepts, manifest, { branches: branchNames });
961
+ const index = await buildUsageIndex(backend, runtime.concepts, manifest, { branches: branchNames });
962
962
  for (const [hash, entries] of index) {
963
963
  usage[hash] = { count: distinctEntryCount(entries), entries };
964
964
  }
@@ -990,33 +990,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
990
990
  id = composeDatedId(date, slug, concept.datePrefix);
991
991
  }
992
992
 
993
- const token = await mintToken(event.platform?.env ?? {});
994
- const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
993
+ const backend = resolveBackend(event);
994
+ const existing = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
995
995
  if (existing !== null) return bounce('An entry with that slug already exists.');
996
996
  // A pending branch is an entry too (saved but not yet published); refuse to clobber it.
997
- if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
997
+ if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
998
998
  return bounce('An unpublished entry with that slug already exists.');
999
999
  }
1000
1000
 
1001
1001
  throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
1002
1002
  }
1003
1003
 
1004
- /** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
1005
- function formValues(fields: FrontmatterField[], frontmatter: Record<string, unknown>): Record<string, unknown> {
1006
- const out: Record<string, unknown> = {};
1007
- for (const field of fields) {
1008
- const value = frontmatter[field.name];
1009
- if (field.type === 'date') out[field.name] = dateInputValue(value);
1010
- else if (field.type === 'boolean') out[field.name] = value === true;
1011
- else if (field.type === 'tags' || field.type === 'freetags') out[field.name] = Array.isArray(value) ? value.map(String) : [];
1012
- // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
1013
- // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
1014
- else if (field.type === 'image') out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
1015
- else out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
1016
- }
1017
- return out;
1018
- }
1019
-
1020
1004
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
1021
1005
  async function editLoad(event: ContentEvent): Promise<EditData> {
1022
1006
  requireSession(event);
@@ -1024,7 +1008,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1024
1008
  const id = event.params.id ?? '';
1025
1009
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
1026
1010
  const isNew = event.url.searchParams.get('new') === '1';
1027
- const token = await mintToken(event.platform?.env ?? {});
1011
+ const backend = resolveBackend(event);
1028
1012
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
1029
1013
  const path = `${concept.dir}/${filenameFromId(id)}`;
1030
1014
  // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
@@ -1041,20 +1025,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1041
1025
  // rejected read degrades to null so the edit never throws on a missing or unreadable dictionary;
1042
1026
  // the projection below treats null as an empty word list (the editor falls back to dialect-only).
1043
1027
  const [headSha, mainRaw, manifestRaw, mediaRaw, dictionaryRaw] = await Promise.all([
1044
- branchHeadSha(runtime.backend, branch, token),
1045
- readRaw(runtime.backend, path, token),
1046
- readRaw(runtime.backend, runtime.manifestPath, token),
1028
+ backend.branchHead(branch),
1029
+ backend.readFile(path, backend.defaultBranch),
1030
+ backend.readFile(runtime.manifestPath, backend.defaultBranch),
1047
1031
  runtime.resolvedAssets.enabled
1048
- ? readRaw(runtime.backend, runtime.mediaManifestPath, token).catch(() => null)
1032
+ ? backend.readFile(runtime.mediaManifestPath, backend.defaultBranch).catch(() => null)
1049
1033
  : Promise.resolve(null),
1050
- readRaw(runtime.backend, dictionaryFilePath(), token).catch(() => null),
1034
+ backend.readFile(dictionaryFilePath(), backend.defaultBranch).catch(() => null),
1051
1035
  ]);
1052
1036
  const pending = headSha !== null;
1053
- const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
1037
+ const raw = pending ? await backend.readFile(path, branch) : mainRaw;
1054
1038
  if (raw === null && !isNew) throw error(404, 'Entry not found');
1055
1039
  const published = mainRaw !== null;
1056
1040
 
1057
1041
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
1042
+ // A fresh entry opens prefilled from each field's `default`, resolving a `'today'` date against a
1043
+ // request-time clock. The defaults sit under the empty parsed frontmatter, never over a real read.
1044
+ const loadFrontmatter = isNew
1045
+ ? { ...initialValues(concept.schema, new Date()), ...parsed.frontmatter }
1046
+ : parsed.frontmatter;
1058
1047
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
1059
1048
 
1060
1049
  const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
@@ -1115,7 +1104,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1115
1104
  id,
1116
1105
  label: concept.label,
1117
1106
  fields: concept.fields,
1118
- frontmatter: formValues(concept.fields, parsed.frontmatter),
1107
+ frontmatter: formValues(concept.fields, loadFrontmatter),
1119
1108
  body: parsed.body,
1120
1109
  title,
1121
1110
  isNew,
@@ -1210,7 +1199,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1210
1199
  manifest: Manifest;
1211
1200
  /** The draft-target tokens the body links to, for save's warning query. */
1212
1201
  draftLinks: string[];
1213
- token: string;
1202
+ /** The absent-or-draft reference targets, for save's non-blocking reference warning. */
1203
+ referenceWarnings: string[];
1204
+ /** The backend this save resolved, so publish reuses it without a second resolve. */
1205
+ backend: Backend;
1214
1206
  /**
1215
1207
  * The merged media.json change this save committed to the branch, when media is on and the
1216
1208
  * post carried records. Publish reuses it verbatim so the main commit promotes the exact same
@@ -1246,7 +1238,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1246
1238
  }
1247
1239
 
1248
1240
  const markdown = serializeMarkdown(result.data, body);
1249
- const token = await mintToken(event.platform?.env ?? {});
1241
+ const backend = resolveBackend(event);
1250
1242
 
1251
1243
  // Merge the editor's optimistic media records into the media manifest, gated on media being on
1252
1244
  // and at least one valid record posted. The base is read from the default branch (never the
@@ -1258,7 +1250,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1258
1250
  if (runtime.resolvedAssets.enabled) {
1259
1251
  const records = parseMediaEntries(form.get('media'));
1260
1252
  if (records.length > 0) {
1261
- const baseRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
1253
+ const baseRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
1262
1254
  let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
1263
1255
  for (const record of records) {
1264
1256
  mediaManifest = upsertMediaEntry(mediaManifest, record);
@@ -1269,7 +1261,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1269
1261
 
1270
1262
  // Upsert this entry's row into main's manifest in memory, for the link guard here and for
1271
1263
  // the publish commit. The save commits no manifest change; publish lands the upsert on main.
1272
- const manifest = await readManifest(token);
1264
+ const manifest = await readManifest(backend);
1273
1265
  const row = manifestEntryFromFile(concept, { path, raw: markdown });
1274
1266
  const upserted = upsertEntry(manifest, row);
1275
1267
 
@@ -1298,31 +1290,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1298
1290
  } satisfies SaveFailure);
1299
1291
  }
1300
1292
 
1293
+ // Frontmatter reference warning: classify each typed reference edge against the same upserted
1294
+ // manifest. This is best-effort against the committed (possibly stale) main manifest and advisory
1295
+ // like draftLinks, NEVER the integrity guarantee; references have no prerender re-resolve backstop,
1296
+ // so verifyReferences at the build is the only authority. A reference NEVER blocks the save: unlike
1297
+ // a body link, an absent or draft target only warns, since the build gate fails a true dangling.
1298
+ const referenceWarnings: string[] = [];
1299
+ for (const edge of extractReferenceEdges(result.data, concept.fields)) {
1300
+ if (edge.concept === concept.id && edge.id === id) continue;
1301
+ const target = byKey.get(`${edge.concept}/${edge.id}`);
1302
+ if (!target || target.draft) referenceWarnings.push(`${edge.concept}/${edge.id}`);
1303
+ }
1304
+
1301
1305
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
1302
1306
  // commit only the entry file there. Main stays untouched until publish, so the branch differs
1303
1307
  // from main at exactly this entry's path.
1304
1308
  const branch = pendingBranch(concept.id, id);
1305
- if ((await branchHeadSha(runtime.backend, branch, token)) === null) {
1306
- const mainHead = await branchHeadSha(runtime.backend, runtime.backend.branch, token);
1309
+ if ((await backend.branchHead(branch)) === null) {
1310
+ // The default-branch head read distinguishes a first save from a re-save; a null is the
1311
+ // unreadable-default-branch case the create cannot recover from, so fail with the 500.
1312
+ const mainHead = await backend.branchHead(backend.defaultBranch);
1307
1313
  if (mainHead === null) throw error(500, 'Cannot read the default branch');
1308
- await createBranch(runtime.backend, branch, mainHead, token);
1314
+ await backend.createBranch(branch, backend.defaultBranch);
1309
1315
  }
1310
1316
 
1311
1317
  const commitFields = { concept: concept.id, id, editor: editor.email, branch };
1312
1318
  let branchSha: string;
1313
1319
  try {
1314
- branchSha = await commitFiles(
1315
- { ...runtime.backend, branch },
1320
+ branchSha = await backend.commit(
1321
+ branch,
1316
1322
  mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }],
1317
- { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
1318
- token,
1323
+ { name: editor.displayName, email: editor.email },
1324
+ `Update ${concept.label.toLowerCase()}: ${id}`,
1319
1325
  );
1320
1326
  log.info('commit.succeeded', commitFields);
1321
1327
  } catch (err) {
1322
1328
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
1323
1329
  'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
1324
1330
  }
1325
- return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
1331
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, referenceWarnings, backend, mediaChange };
1326
1332
  }
1327
1333
 
1328
1334
  /**
@@ -1338,9 +1344,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1338
1344
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
1339
1345
  const held = await saveToBranch(event, editor, concept, id);
1340
1346
  if (!('branchSha' in held)) return held;
1341
- const savedQuery = held.draftLinks.length
1347
+ let savedQuery = held.draftLinks.length
1342
1348
  ? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
1343
1349
  : 'saved=1';
1350
+ if (held.referenceWarnings.length)
1351
+ savedQuery += `&refs=${encodeURIComponent(held.referenceWarnings.join(','))}`;
1344
1352
  throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
1345
1353
  }
1346
1354
 
@@ -1359,7 +1367,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1359
1367
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
1360
1368
  const held = await saveToBranch(event, editor, concept, id);
1361
1369
  if (!('branchSha' in held)) return held;
1362
- const { path, markdown, branch, branchSha, manifest, token, mediaChange } = held;
1370
+ const { path, markdown, branch, branchSha, manifest, backend, mediaChange } = held;
1363
1371
 
1364
1372
  // The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
1365
1373
  // no re-read or re-merge here). Promote it to main alongside the body and the content manifest
@@ -1379,7 +1387,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1379
1387
  try {
1380
1388
  const { frontmatter } = parseMarkdown(markdown);
1381
1389
  address = entryIdentity(concept, path, frontmatter).permalink;
1382
- const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1390
+ const addressIndex = await buildAddressIndex(backend, runtime.concepts, manifest);
1383
1391
  collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
1384
1392
  } catch (err) {
1385
1393
  // Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
@@ -1390,11 +1398,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1390
1398
 
1391
1399
  const commitFields = { concept: concept.id, id, editor: editor.email };
1392
1400
  try {
1393
- await commitFiles(
1394
- runtime.backend,
1401
+ await backend.commit(
1402
+ backend.defaultBranch,
1395
1403
  changes,
1396
- { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
1397
- token,
1404
+ { name: editor.displayName, email: editor.email },
1405
+ `Publish ${concept.label.toLowerCase()}: ${id}`,
1398
1406
  );
1399
1407
  log.info('entry.published', { ...commitFields, batch: false });
1400
1408
  // Only after the publish lands: a diagnostic that a live address now has a new owner.
@@ -1414,8 +1422,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1414
1422
  // Only after the main commit lands, and only when the branch head is still the commit this
1415
1423
  // action made: a head that moved is a concurrent save, and deleting it would destroy edits.
1416
1424
  // No log event for the skip; the pending badge is the surface.
1417
- if ((await branchHeadSha(runtime.backend, branch, token)) === branchSha) {
1418
- await deleteBranch(runtime.backend, branch, token);
1425
+ if ((await backend.branchHead(branch)) === branchSha) {
1426
+ await backend.deleteBranch(branch);
1419
1427
  }
1420
1428
  throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
1421
1429
  }
@@ -1430,12 +1438,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1430
1438
  const editor = requireSession(event);
1431
1439
  const first = runtime.concepts[0];
1432
1440
  if (!first) throw error(404, 'No content types configured');
1433
- const token = await mintToken(event.platform?.env ?? {});
1441
+ const backend = resolveBackend(event);
1434
1442
  const listPage = `/admin/${first.id}`;
1435
1443
 
1436
1444
  // Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
1437
1445
  // than failing the whole batch on it.
1438
- const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
1446
+ const names = await backend.listBranches(PENDING_PREFIX);
1439
1447
  const pending = names.flatMap((name) => {
1440
1448
  const entry = pendingEntryOf(name);
1441
1449
  return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
@@ -1448,15 +1456,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1448
1456
  // clean it up); it carries nothing to publish.
1449
1457
  const reads = await Promise.all(
1450
1458
  pending.map(async (entry) => {
1451
- const sha = await branchHeadSha(runtime.backend, entry.branch, token);
1452
- const raw = await readRaw({ ...runtime.backend, branch: entry.branch }, entry.path, token);
1459
+ const sha = await backend.branchHead(entry.branch);
1460
+ const raw = await backend.readFile(entry.path, entry.branch);
1453
1461
  return { ...entry, sha, raw };
1454
1462
  }),
1455
1463
  );
1456
1464
 
1457
1465
  // Fold main's manifest once over every row, so the batch lands content and index together,
1458
1466
  // the same shape as a single publish.
1459
- let next = await readManifest(token);
1467
+ let next = await readManifest(backend);
1460
1468
  const changes: FileChange[] = [];
1461
1469
  const published: { concept: string; id: string; branch: string; sha: string }[] = [];
1462
1470
  for (const entry of reads) {
@@ -1473,11 +1481,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1473
1481
 
1474
1482
  const noun = published.length === 1 ? 'entry' : 'entries';
1475
1483
  try {
1476
- await commitFiles(
1477
- runtime.backend,
1484
+ await backend.commit(
1485
+ backend.defaultBranch,
1478
1486
  changes,
1479
- { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } },
1480
- token,
1487
+ { name: editor.displayName, email: editor.email },
1488
+ `Publish ${published.length} ${noun}`,
1481
1489
  );
1482
1490
  for (const entry of published) {
1483
1491
  log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
@@ -1501,8 +1509,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1501
1509
  // abort the remaining deletes.
1502
1510
  for (const entry of published) {
1503
1511
  try {
1504
- if ((await branchHeadSha(runtime.backend, entry.branch, token)) === entry.sha) {
1505
- await deleteBranch(runtime.backend, entry.branch, token);
1512
+ if ((await backend.branchHead(entry.branch)) === entry.sha) {
1513
+ await backend.deleteBranch(entry.branch);
1506
1514
  }
1507
1515
  } catch {
1508
1516
  // The entry is live; the straggler just shows as still pending until the next publish.
@@ -1520,12 +1528,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1520
1528
  const concept = conceptOf(runtime, event.params);
1521
1529
  const id = event.params.id ?? '';
1522
1530
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
1523
- const token = await mintToken(event.platform?.env ?? {});
1531
+ const backend = resolveBackend(event);
1524
1532
 
1525
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
1533
+ await backend.deleteBranch(pendingBranch(concept.id, id));
1526
1534
  log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
1527
1535
 
1528
- const onMain = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
1536
+ const onMain = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
1529
1537
  if (onMain !== null) throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
1530
1538
  throw redirect(303, `/admin/${concept.id}`);
1531
1539
  }
@@ -1544,11 +1552,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1544
1552
  editor: Editor,
1545
1553
  ): Promise<ReturnType<typeof fail> | never> {
1546
1554
  const path = `${concept.dir}/${filenameFromId(id)}`;
1547
- const token = await mintToken(event.platform?.env ?? {});
1555
+ const backend = resolveBackend(event);
1548
1556
 
1549
1557
  // An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
1550
1558
  // check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
1551
- const manifest = await readManifest(token);
1559
+ const manifest = await readManifest(backend);
1552
1560
  const inbound = inboundLinks(manifest, concept.id, id);
1553
1561
  if (inbound.length) {
1554
1562
  return fail(409, {
@@ -1558,12 +1566,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1558
1566
  } satisfies DeleteRefusal);
1559
1567
  }
1560
1568
 
1569
+ // Cross-branch reference gate (fail-closed). A strict reference index unions main's published edges
1570
+ // and every open cairn/* branch; unlike the main-only body-link gate above, it does NOT degrade to
1571
+ // allow when it cannot read, because the build's verifyReferences backstop only sees main. A
1572
+ // transient branch-read failure that looked like "no references" would let a delete strand an
1573
+ // inbound edge held in an unpublished draft, so refuse with a 503 rather than proceed.
1574
+ let refIndex: Awaited<ReturnType<typeof buildReferenceIndex>>;
1575
+ try {
1576
+ refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
1577
+ } catch {
1578
+ return fail(503, {
1579
+ error: 'Could not verify where this entry is referenced. Try again.',
1580
+ inboundLinks: [],
1581
+ id,
1582
+ } satisfies DeleteRefusal);
1583
+ }
1584
+ const refRows = refIndex.get(`${concept.id}/${id}`) ?? [];
1585
+ if (refRows.length > 0) {
1586
+ // Carry each referencing entry into the InboundLink shape the blockers list renders. A branch row
1587
+ // has no permalink (the edit is unpublished), so default it to empty.
1588
+ const referencingEntries: InboundLink[] = refRows.map((row) => ({
1589
+ concept: row.concept,
1590
+ id: row.id,
1591
+ title: row.title,
1592
+ permalink: row.permalink ?? '',
1593
+ }));
1594
+ const n = referencingEntries.length;
1595
+ return fail(409, {
1596
+ error: `Cannot delete ${id}: ${n} ${n === 1 ? 'entry references' : 'entries reference'} it.`,
1597
+ inboundLinks: referencingEntries,
1598
+ id,
1599
+ } satisfies DeleteRefusal);
1600
+ }
1601
+
1561
1602
  // When the entry was never published (absent from main), the branch delete is the whole
1562
1603
  // operation; main has nothing to commit, so the only honest log record is the discard of
1563
1604
  // the pending edits.
1564
- const onMain = await readRaw(runtime.backend, path, token);
1605
+ const onMain = await backend.readFile(path, backend.defaultBranch);
1565
1606
  if (onMain === null) {
1566
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
1607
+ await backend.deleteBranch(pendingBranch(concept.id, id));
1567
1608
  log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
1568
1609
  throw redirect(303, `/admin/${concept.id}`);
1569
1610
  }
@@ -1571,14 +1612,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1571
1612
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
1572
1613
  const commitFields = { concept: concept.id, id, editor: editor.email };
1573
1614
  try {
1574
- await commitFiles(
1575
- runtime.backend,
1615
+ await backend.commit(
1616
+ backend.defaultBranch,
1576
1617
  [
1577
1618
  { path, content: null },
1578
1619
  { path: runtime.manifestPath, content: nextManifest },
1579
1620
  ],
1580
- { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
1581
- token,
1621
+ { name: editor.displayName, email: editor.email },
1622
+ `Delete ${concept.label.toLowerCase()}: ${id}`,
1582
1623
  );
1583
1624
  log.info('commit.succeeded', commitFields);
1584
1625
  } catch (err) {
@@ -1590,7 +1631,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1590
1631
  // recoverable (it lists as a never-published row a discard can clean up), matching
1591
1632
  // publish's posture, so the entry's deletion still completes.
1592
1633
  try {
1593
- await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
1634
+ await backend.deleteBranch(pendingBranch(concept.id, id));
1594
1635
  } catch {
1595
1636
  // The entry is gone from main; the straggler shows as a pending row until discarded.
1596
1637
  }
@@ -1627,11 +1668,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1627
1668
  const concept = conceptOf(runtime, event.params);
1628
1669
  const id = event.params.id ?? '';
1629
1670
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
1630
- const token = await mintToken(event.platform?.env ?? {});
1671
+ const backend = resolveBackend(event);
1631
1672
 
1632
1673
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
1633
1674
  // them, so refuse until the editor publishes or discards.
1634
- if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
1675
+ if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
1635
1676
  return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
1636
1677
  }
1637
1678
 
@@ -1654,23 +1695,57 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1654
1695
  // Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
1655
1696
  // single readRaw cannot tell apart: a static collision with an existing entry, and a
1656
1697
  // concurrent-rename race where another editor renamed onto this path between load and submit.
1657
- const clobber = await readRaw(runtime.backend, newPath, token);
1698
+ const clobber = await backend.readFile(newPath, backend.defaultBranch);
1658
1699
  if (clobber !== null) {
1659
1700
  return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
1660
1701
  }
1661
1702
 
1662
1703
  const [entryRaw, manifest] = await Promise.all([
1663
- readRaw(runtime.backend, oldPath, token),
1664
- readManifest(token),
1704
+ backend.readFile(oldPath, backend.defaultBranch),
1705
+ readManifest(backend),
1665
1706
  ]);
1666
1707
  if (entryRaw === null) throw error(404, 'Entry not found');
1667
1708
 
1709
+ // Cross-branch reference gate (fail-closed). A reference index unions main's published edges and
1710
+ // every open cairn/* branch; if it cannot be built (a transient branch read failure), refuse
1711
+ // rather than rename a still-referenced target and strand the inbound edge.
1712
+ let refIndex: Awaited<ReturnType<typeof buildReferenceIndex>>;
1713
+ try {
1714
+ refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
1715
+ } catch {
1716
+ return fail(409, { error: 'Could not verify references. Try again.' } satisfies RenameFailure);
1717
+ }
1718
+
1719
+ // Refuse when a THIRD-PARTY open branch holds an inbound reference (symmetric with the pending-edits
1720
+ // guard). The strict index unions main and every branch, so filter before refusing: gate
1721
+ // origin.kind === 'branch' FIRST (a published row has no .branch, so a bare branch-name compare would
1722
+ // trip on every main-side inbound and over-refuse), then exclude the entry's OWN pending branch
1723
+ // (already refused above and absent by construction here). Published (main) inbound rows are NOT
1724
+ // refused; they are repointed below.
1725
+ const ownBranch = pendingBranch(concept.id, id);
1726
+ const conflictBranches = (refIndex.get(`${concept.id}/${id}`) ?? [])
1727
+ .filter((row) => row.origin.kind === 'branch' && row.origin.branch !== ownBranch)
1728
+ .map((row) => `${row.concept}/${row.id}`);
1729
+ if (conflictBranches.length > 0) {
1730
+ const names = [...new Set(conflictBranches)].join(', ');
1731
+ return fail(409, { error: `Another editor has unpublished edits referencing this entry: ${names}. Ask them to publish or discard, then rename.` } satisfies RenameFailure);
1732
+ }
1733
+
1668
1734
  const oldHref = formatCairnToken({ concept: concept.id, id });
1669
1735
  const newHref = formatCairnToken({ concept: concept.id, id: newId });
1670
1736
 
1671
- // The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
1672
- // the new path so the row carries the new id and permalink by construction.
1673
- const movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
1737
+ // The moved file keeps its content, except a self-token rewrite and a self-reference rewrite.
1738
+ let movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
1739
+ // The moved entry is excluded from inboundReferences, so it must repoint its OWN frontmatter
1740
+ // self-references (e.g. `related` listing its own old id), or the re-derived row would carry the
1741
+ // old id and verifyReferences would flag a dangling edge at the deploy gate.
1742
+ for (const f of concept.fields) {
1743
+ if (f.type === 'reference' || (f.type === 'array' && f.item.type === 'reference')) {
1744
+ movedRaw = rewriteFrontmatterReference(movedRaw, f.name, id, newId);
1745
+ }
1746
+ }
1747
+ // Re-derive its manifest row from the new path so the row carries the new id and permalink by
1748
+ // construction (and the rewritten self-reference edge at the new id).
1674
1749
  const changes: FileChange[] = [
1675
1750
  { path: oldPath, content: null },
1676
1751
  { path: newPath, content: movedRaw },
@@ -1678,28 +1753,61 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1678
1753
  let next = removeEntry(manifest, concept.id, id);
1679
1754
  next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
1680
1755
 
1681
- // Rewrite every inbound linker's body and re-derive its row, so its outbound edge points at the
1682
- // new id. A linker missing from the repo is skipped; the build backstop catches any drift.
1756
+ // Repoint every inbound linker so its outbound edges point at the new id, both body `cairn:` links
1757
+ // and frontmatter reference fields. One entry can hold BOTH kinds at the same target, and the Git
1758
+ // Trees API resolves a duplicate path to the LAST entry, so a separate FileChange per kind would let
1759
+ // the second clobber the first. Union the two inbound sets keyed by linker PATH, read each file once
1760
+ // from main, apply every rewrite to the SAME buffer, then push ONE FileChange per path and re-derive
1761
+ // its row from the merged buffer. inboundReferences reads the committed (last-writer-wins stale)
1762
+ // manifest, so a real inbound edge not yet recorded there is left to verifyReferences at the deploy
1763
+ // gate; third-party open-branch inbounds were already refused above, so these are main-only.
1764
+ interface InboundRepoint {
1765
+ concept: string;
1766
+ id: string;
1767
+ hasLink: boolean;
1768
+ fields: string[];
1769
+ }
1770
+ const repoints = new Map<string, InboundRepoint>();
1771
+ const linkerPathFor = (linkerConcept: ConceptDescriptor, linkerId: string): string =>
1772
+ `${linkerConcept.dir}/${filenameFromId(linkerId)}`;
1683
1773
  for (const linker of inboundLinks(manifest, concept.id, id)) {
1684
1774
  const linkerConcept = findConcept(runtime.concepts, linker.concept);
1685
1775
  if (!linkerConcept) continue;
1686
- const linkerPath = `${linkerConcept.dir}/${filenameFromId(linker.id)}`;
1687
- const linkerRaw = await readRaw(runtime.backend, linkerPath, token);
1776
+ const path = linkerPathFor(linkerConcept, linker.id);
1777
+ const existing = repoints.get(path);
1778
+ if (existing) existing.hasLink = true;
1779
+ else repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: true, fields: [] });
1780
+ }
1781
+ for (const linker of inboundReferences(manifest, concept.id, id)) {
1782
+ const linkerConcept = findConcept(runtime.concepts, linker.concept);
1783
+ if (!linkerConcept) continue;
1784
+ const path = linkerPathFor(linkerConcept, linker.id);
1785
+ const existing = repoints.get(path);
1786
+ if (existing) existing.fields = [...new Set([...existing.fields, ...linker.fields])];
1787
+ else repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: false, fields: linker.fields });
1788
+ }
1789
+ for (const [linkerPath, repoint] of repoints) {
1790
+ const linkerConcept = findConcept(runtime.concepts, repoint.concept);
1791
+ if (!linkerConcept) continue;
1792
+ let linkerRaw = await backend.readFile(linkerPath, backend.defaultBranch);
1688
1793
  if (linkerRaw === null) continue;
1689
- const rewritten = rewriteCairnLink(linkerRaw, oldHref, newHref);
1690
- changes.push({ path: linkerPath, content: rewritten });
1691
- next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
1794
+ if (repoint.hasLink) linkerRaw = rewriteCairnLink(linkerRaw, oldHref, newHref);
1795
+ for (const field of repoint.fields) {
1796
+ linkerRaw = rewriteFrontmatterReference(linkerRaw, field, id, newId);
1797
+ }
1798
+ changes.push({ path: linkerPath, content: linkerRaw });
1799
+ next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: linkerRaw }));
1692
1800
  }
1693
1801
 
1694
1802
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
1695
1803
 
1696
1804
  const commitFields = { concept: concept.id, id: newId, editor: editor.email };
1697
1805
  try {
1698
- await commitFiles(
1699
- runtime.backend,
1806
+ await backend.commit(
1807
+ backend.defaultBranch,
1700
1808
  changes,
1701
- { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } },
1702
- token,
1809
+ { name: editor.displayName, email: editor.email },
1810
+ `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`,
1703
1811
  );
1704
1812
  log.info('commit.succeeded', commitFields);
1705
1813
  } catch (err) {
@@ -1871,7 +1979,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1871
1979
  */
1872
1980
  async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1873
1981
  const editor = requireSession(event);
1874
- const token = await mintToken(event.platform?.env ?? {});
1982
+ const backend = resolveBackend(event);
1875
1983
 
1876
1984
  const form = await event.request.formData();
1877
1985
  const hash = String(form.get('hash') ?? '');
@@ -1879,7 +1987,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1879
1987
 
1880
1988
  // The asset must be committed on the default branch to be deletable here. A branch-only upload
1881
1989
  // (the common 2b case before publish) has no main row; removing it is a discard of the draft.
1882
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1990
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
1883
1991
  const row = manifest[hash];
1884
1992
  if (!row) {
1885
1993
  return fail(404, {
@@ -1896,7 +2004,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1896
2004
  // make a still-referenced asset look orphaned and skip the typed-slug confirm.
1897
2005
  let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1898
2006
  try {
1899
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
2007
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
1900
2008
  } catch {
1901
2009
  // Fail closed: we could not verify every place the asset is used, so refuse rather than risk
1902
2010
  // deleting bytes a branch still references.
@@ -1946,11 +2054,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1946
2054
  // Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
1947
2055
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
1948
2056
  try {
1949
- await commitFiles(
1950
- runtime.backend,
2057
+ await backend.commit(
2058
+ backend.defaultBranch,
1951
2059
  [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }],
1952
- { message: `Delete media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
1953
- token,
2060
+ { name: editor.displayName, email: editor.email },
2061
+ `Delete media: ${row.slug}`,
1954
2062
  );
1955
2063
  log.info('commit.succeeded', commitFields);
1956
2064
  } catch (err) {
@@ -1986,7 +2094,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1986
2094
  */
1987
2095
  async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
1988
2096
  const editor = requireSession(event);
1989
- const token = await mintToken(event.platform?.env ?? {});
2097
+ const backend = resolveBackend(event);
1990
2098
 
1991
2099
  // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1992
2100
  // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
@@ -2007,7 +2115,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2007
2115
  const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
2008
2116
 
2009
2117
  // Read the fresh media manifest (the deletable rows come from here, by hash).
2010
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2118
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2011
2119
 
2012
2120
  // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
2013
2121
  // the commit, exactly like single delete.
@@ -2027,7 +2135,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2027
2135
  // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
2028
2136
  let index: Awaited<ReturnType<typeof buildUsageIndex>>;
2029
2137
  try {
2030
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
2138
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
2031
2139
  } catch {
2032
2140
  return fail(503, { error: 'Could not verify where these assets are used. Try again.' } satisfies MediaBulkFailure);
2033
2141
  }
@@ -2044,11 +2152,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2044
2152
  for (const hash of plan.deletable) next = removeMediaEntry(next, hash);
2045
2153
  const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
2046
2154
  try {
2047
- await commitFiles(
2048
- runtime.backend,
2155
+ await backend.commit(
2156
+ backend.defaultBranch,
2049
2157
  [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }],
2050
- { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } },
2051
- token,
2158
+ { name: editor.displayName, email: editor.email },
2159
+ `Delete ${plan.deletable.length} media assets`,
2052
2160
  );
2053
2161
  log.info('commit.succeeded', commitFields);
2054
2162
  } catch (err) {
@@ -2094,7 +2202,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2094
2202
  */
2095
2203
  async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
2096
2204
  requireSession(event);
2097
- const token = await mintToken(event.platform?.env ?? {});
2205
+ const backend = resolveBackend(event);
2098
2206
 
2099
2207
  // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
2100
2208
  // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
@@ -2109,7 +2217,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2109
2217
  }
2110
2218
 
2111
2219
  // Read the fresh media manifest for the reconcile's manifest side.
2112
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2220
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2113
2221
 
2114
2222
  // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
2115
2223
  // and the strict usage build (a branch read that must complete in full) are both unsafe to use
@@ -2119,7 +2227,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2119
2227
  let index: Awaited<ReturnType<typeof buildUsageIndex>>;
2120
2228
  try {
2121
2229
  reconcile = await runReconcile(rawBucket as unknown as ReconcileBucket, manifest);
2122
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
2230
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
2123
2231
  } catch {
2124
2232
  return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' } satisfies MediaBulkFailure);
2125
2233
  }
@@ -2156,7 +2264,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2156
2264
  */
2157
2265
  async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
2158
2266
  const editor = requireSession(event);
2159
- const token = await mintToken(event.platform?.env ?? {});
2267
+ const backend = resolveBackend(event);
2160
2268
 
2161
2269
  // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
2162
2270
  // deletes through the MediaStore seam, so wrap the raw binding.
@@ -2183,7 +2291,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2183
2291
  }
2184
2292
 
2185
2293
  // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
2186
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2294
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2187
2295
 
2188
2296
  // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
2189
2297
  // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
@@ -2191,7 +2299,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2191
2299
  // treated as a true orphan. Build exactly one index, never one per key.
2192
2300
  let index: Awaited<ReturnType<typeof buildUsageIndex>>;
2193
2301
  try {
2194
- index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
2302
+ index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
2195
2303
  } catch {
2196
2304
  return fail(503, { error: 'Could not verify where these files are used. Try again.' } satisfies MediaBulkFailure);
2197
2305
  }
@@ -2234,13 +2342,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2234
2342
  */
2235
2343
  async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2236
2344
  const editor = requireSession(event);
2237
- const token = await mintToken(event.platform?.env ?? {});
2345
+ const backend = resolveBackend(event);
2238
2346
 
2239
2347
  const form = await event.request.formData();
2240
2348
  const hash = String(form.get('hash') ?? '');
2241
2349
  if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
2242
2350
 
2243
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2351
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2244
2352
  const row = manifest[hash];
2245
2353
  if (!row) {
2246
2354
  return fail(404, { error: 'That asset is not committed.' } satisfies MediaUpdateFailure);
@@ -2256,11 +2364,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2256
2364
  const edited: MediaEntry = { ...row, displayName: displayName || slug, slug, alt };
2257
2365
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
2258
2366
  try {
2259
- await commitFiles(
2260
- runtime.backend,
2367
+ await backend.commit(
2368
+ backend.defaultBranch,
2261
2369
  [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }],
2262
- { message: `Update media: ${edited.slug}`, author: { name: editor.displayName, email: editor.email } },
2263
- token,
2370
+ { name: editor.displayName, email: editor.email },
2371
+ `Update media: ${edited.slug}`,
2264
2372
  );
2265
2373
  log.info('commit.succeeded', commitFields);
2266
2374
  } catch (err) {
@@ -2317,8 +2425,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2317
2425
  return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
2318
2426
  }
2319
2427
 
2320
- const token = await mintToken(event.platform?.env ?? {});
2321
- const contentManifest = await readManifest(token);
2428
+ const backend = resolveBackend(event);
2429
+ const contentManifest = await readManifest(backend);
2322
2430
  const newToken = replacementToken(slug, newHash);
2323
2431
 
2324
2432
  // Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
@@ -2327,8 +2435,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2327
2435
  let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
2328
2436
  try {
2329
2437
  plan = await planMediaRewrite<RepointPlacement>({
2330
- backend: runtime.backend,
2331
- token,
2438
+ backend,
2332
2439
  concepts: runtime.concepts,
2333
2440
  contentManifest,
2334
2441
  hash: oldHash,
@@ -2377,7 +2484,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2377
2484
  */
2378
2485
  async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2379
2486
  const editor = requireSession(event);
2380
- const token = await mintToken(event.platform?.env ?? {});
2487
+ const backend = resolveBackend(event);
2381
2488
 
2382
2489
  const form = await event.request.formData();
2383
2490
  const oldHash = String(form.get('oldHash') ?? '');
@@ -2399,7 +2506,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2399
2506
 
2400
2507
  // The old asset must be committed on main to be replaceable here. A branch-only upload has no main
2401
2508
  // row; it is replaced by editing its draft, not here.
2402
- const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2509
+ const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2403
2510
  const row = manifest[oldHash];
2404
2511
  if (!row) {
2405
2512
  return fail(404, {
@@ -2426,10 +2533,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2426
2533
  let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
2427
2534
  try {
2428
2535
  plan = await planMediaRewrite<RepointPlacement>({
2429
- backend: runtime.backend,
2430
- token,
2536
+ backend,
2431
2537
  concepts: runtime.concepts,
2432
- contentManifest: await readManifest(token),
2538
+ contentManifest: await readManifest(backend),
2433
2539
  hash: oldHash,
2434
2540
  transform: (md) => repointMediaRef(md, oldHash, newToken),
2435
2541
  });
@@ -2461,11 +2567,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2461
2567
 
2462
2568
  const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
2463
2569
  try {
2464
- await commitFiles(
2465
- runtime.backend,
2570
+ await backend.commit(
2571
+ backend.defaultBranch,
2466
2572
  changes,
2467
- { message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
2468
- token,
2573
+ { name: editor.displayName, email: editor.email },
2574
+ `Replace media: ${row.slug}`,
2469
2575
  );
2470
2576
  log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
2471
2577
  } catch (err) {
@@ -2507,10 +2613,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2507
2613
  return fail(400, { error: 'Invalid media hash.' } satisfies MediaAltPropagateFailure);
2508
2614
  }
2509
2615
 
2510
- const token = await mintToken(event.platform?.env ?? {});
2616
+ const backend = resolveBackend(event);
2511
2617
  // The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
2512
2618
  // asset with no committed row has no default alt to push, so refuse.
2513
- const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2619
+ const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2514
2620
  const row = mediaManifest[hash];
2515
2621
  if (!row) {
2516
2622
  return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
@@ -2518,12 +2624,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2518
2624
 
2519
2625
  // Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
2520
2626
  // it and fail closed, the same posture replace and delete take.
2521
- const contentManifest = await readManifest(token);
2627
+ const contentManifest = await readManifest(backend);
2522
2628
  let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
2523
2629
  try {
2524
2630
  plan = await planMediaRewrite<AltPlacement>({
2525
- backend: runtime.backend,
2526
- token,
2631
+ backend,
2527
2632
  concepts: runtime.concepts,
2528
2633
  contentManifest,
2529
2634
  hash,
@@ -2568,7 +2673,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2568
2673
  */
2569
2674
  async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2570
2675
  const editor = requireSession(event);
2571
- const token = await mintToken(event.platform?.env ?? {});
2676
+ const backend = resolveBackend(event);
2572
2677
 
2573
2678
  const form = await event.request.formData();
2574
2679
  const hash = String(form.get('hash') ?? '');
@@ -2576,7 +2681,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2576
2681
  // The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
2577
2682
  const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
2578
2683
 
2579
- const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
2684
+ const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
2580
2685
  const row = mediaManifest[hash];
2581
2686
  if (!row) {
2582
2687
  return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
@@ -2592,10 +2697,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2592
2697
  let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
2593
2698
  try {
2594
2699
  plan = await planMediaRewrite<AltPlacement>({
2595
- backend: runtime.backend,
2596
- token,
2700
+ backend,
2597
2701
  concepts: runtime.concepts,
2598
- contentManifest: await readManifest(token),
2702
+ contentManifest: await readManifest(backend),
2599
2703
  hash,
2600
2704
  transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
2601
2705
  });
@@ -2612,11 +2716,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2612
2716
  const changes: FileChange[] = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
2613
2717
  const commitFields = { concept: 'media', id: hash, editor: editor.email };
2614
2718
  try {
2615
- await commitFiles(
2616
- runtime.backend,
2719
+ await backend.commit(
2720
+ backend.defaultBranch,
2617
2721
  changes,
2618
- { message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
2619
- token,
2722
+ { name: editor.displayName, email: editor.email },
2723
+ `Propagate alt: ${row.slug}`,
2620
2724
  );
2621
2725
  log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
2622
2726
  } catch (err) {
@@ -2643,24 +2747,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2643
2747
  * the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
2644
2748
  * the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
2645
2749
  * word that already landed is preserved and the result is the same sorted set regardless of order.
2646
- * Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
2647
- * under the commit, which the caller catches to retry once.
2750
+ * Returns the merged word list. Throws CommitConflictError (via backend.commit) when the branch
2751
+ * moves under the commit, which the caller catches to retry once.
2648
2752
  */
2649
- async function mergeAndCommitDictionary(token: string, additions: string[], editor: Editor): Promise<string[]> {
2753
+ async function mergeAndCommitDictionary(backend: Backend, additions: string[], editor: Editor): Promise<string[]> {
2650
2754
  const path = dictionaryFilePath();
2651
2755
  // The existing file as its canonical sorted set, so a no-op add is detected against the same
2652
2756
  // normalization the commit would write (an already-sorted file never re-commits just to reorder).
2653
- const canonicalExisting = mergeDictionaryWords(parseDictionary(await readRaw(runtime.backend, path, token)), []);
2757
+ const canonicalExisting = mergeDictionaryWords(parseDictionary(await backend.readFile(path, backend.defaultBranch)), []);
2654
2758
  const merged = mergeDictionaryWords(canonicalExisting, additions);
2655
2759
  // Nothing new (every addition was already present): skip the commit so an idempotent add never
2656
2760
  // pushes an empty commit that would redeploy the site. The merged set is still returned so the
2657
2761
  // client reconciles its pending additions away.
2658
2762
  if (merged.length === canonicalExisting.length) return merged;
2659
- await commitFiles(
2660
- runtime.backend,
2763
+ await backend.commit(
2764
+ backend.defaultBranch,
2661
2765
  [{ path, content: serializeDictionary(merged) }],
2662
- { message: `Add to dictionary: ${additions.join(', ')}`, author: { name: editor.displayName, email: editor.email } },
2663
- token,
2766
+ { name: editor.displayName, email: editor.email },
2767
+ `Add to dictionary: ${additions.join(', ')}`,
2664
2768
  );
2665
2769
  return merged;
2666
2770
  }
@@ -2713,8 +2817,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2713
2817
  /**
2714
2818
  * Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
2715
2819
  * the same committed YAML the nav editor writes, with the session editor as author. The transport is
2716
- * the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
2717
- * `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
2820
+ * the nav save's exactly: a form POST carrying the conventions JSON, a head-guarded
2821
+ * `backend.commit`, and a stale-head `isConflict` bounced back as a reload prompt. Only the conventions
2718
2822
  * block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
2719
2823
  * never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
2720
2824
  * enabled, so the gate state's absent editor tier can never be saved past.
@@ -2735,20 +2839,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2735
2839
  }
2736
2840
 
2737
2841
  const path = siteConfigPath();
2738
- const token = await mintToken(event.platform?.env ?? {});
2739
- const raw = await readRaw(runtime.backend, path, token);
2842
+ const backend = resolveBackend(event);
2843
+ // Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
2844
+ // merges. The settings write lands on the default branch and triggers a deploy, so it is
2845
+ // fail-closed: a concurrent commit to the config moves the head off this value and the commit
2846
+ // throws a conflict, surfacing the reload-and-reapply prompt below rather than a last-writer-wins.
2847
+ const head = await backend.branchHead(backend.defaultBranch);
2848
+ const raw = await backend.readFile(path, backend.defaultBranch);
2740
2849
  if (raw === null) throw error(404, 'Site config not found');
2741
2850
  // Parse first so a malformed file fails before the write rather than committing onto a broken base.
2742
2851
  parseSiteConfig(raw);
2743
2852
 
2744
2853
  const commitFields = { concept: 'settings', id: 'tidy', editor: editor.email };
2745
2854
  try {
2746
- await commitFile(
2747
- runtime.backend,
2748
- path,
2749
- setTidy(raw, conventions),
2750
- { message: 'Update tidy settings', author: { name: editor.displayName, email: editor.email } },
2751
- token,
2855
+ await backend.commit(
2856
+ backend.defaultBranch,
2857
+ [{ path, content: setTidy(raw, conventions) }],
2858
+ { name: editor.displayName, email: editor.email },
2859
+ 'Update tidy settings',
2860
+ head ?? undefined,
2752
2861
  );
2753
2862
  log.info('commit.succeeded', commitFields);
2754
2863
  } catch (err) {
@@ -2771,7 +2880,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2771
2880
  * `{ words }`. It reads the current file from the default branch, inserts the validated words in
2772
2881
  * sorted order if absent (idempotent), and commits through the GitHub-App pipeline.
2773
2882
  *
2774
- * The commit is SHA-guarded with commit-and-retry: commitFiles throws CommitConflictError when the
2883
+ * The commit is SHA-guarded with commit-and-retry: backend.commit throws CommitConflictError when the
2775
2884
  * branch moved under it, which is caught here to re-read the new head, re-merge the same additions
2776
2885
  * (the sorted insert is order-independent, so a concurrent editor's word is preserved), and retry
2777
2886
  * once. The response is the merged word list, so the client drops the now-committed words from its
@@ -2808,10 +2917,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2808
2917
  return fail(400, { error: 'No valid word to add to the dictionary.' } satisfies DictionaryAddFailure);
2809
2918
  }
2810
2919
 
2811
- const token = await mintToken(event.platform?.env ?? {});
2920
+ const backend = resolveBackend(event);
2812
2921
  const commitFields = { concept: 'dictionary', id: additions[0]!, editor: editor.email };
2813
2922
  try {
2814
- const words = await mergeAndCommitDictionary(token, additions, editor);
2923
+ const words = await mergeAndCommitDictionary(backend, additions, editor);
2815
2924
  log.info('dictionary.added', { editor: editor.email, words: additions });
2816
2925
  return { words };
2817
2926
  } catch (err) {
@@ -2820,7 +2929,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2820
2929
  // retry once. The merge is order-independent, so a concurrent editor's word that landed in the
2821
2930
  // window is preserved and the two adds converge on the same sorted set.
2822
2931
  try {
2823
- const words = await mergeAndCommitDictionary(token, additions, editor);
2932
+ const words = await mergeAndCommitDictionary(backend, additions, editor);
2824
2933
  log.info('dictionary.added', { editor: editor.email, words: additions, retried: true });
2825
2934
  return { words };
2826
2935
  } catch (retryErr) {
@@ -2950,7 +3059,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2950
3059
  return { corrected, model: message.model, usage: message.usage };
2951
3060
  }
2952
3061
 
2953
- 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 };
3062
+ 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 };
2954
3063
  }
2955
3064
 
2956
3065
  /**