@glw907/cairn-cms 0.62.2 → 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 (196) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/auth/types.d.ts +7 -0
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  5. package/dist/components/ComponentForm.svelte +44 -27
  6. package/dist/components/ComponentInsertDialog.svelte +22 -11
  7. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  8. package/dist/components/ConceptList.svelte +25 -4
  9. package/dist/components/EditPage.svelte +29 -107
  10. package/dist/components/EditPage.svelte.d.ts +2 -7
  11. package/dist/components/EntryPicker.svelte +117 -0
  12. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  13. package/dist/components/FieldInput.svelte +218 -0
  14. package/dist/components/FieldInput.svelte.d.ts +51 -0
  15. package/dist/components/IconPicker.svelte +2 -2
  16. package/dist/components/IconPicker.svelte.d.ts +2 -0
  17. package/dist/components/LinkPicker.svelte +8 -75
  18. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  19. package/dist/components/MediaHeroField.svelte +8 -5
  20. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  21. package/dist/components/ObjectGroupField.svelte +54 -0
  22. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  23. package/dist/components/ReferenceField.svelte +94 -0
  24. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  25. package/dist/components/RepeatableField.svelte +221 -0
  26. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  27. package/dist/components/cairn-admin.css +179 -2
  28. package/dist/components/preview-doc.js +5 -1
  29. package/dist/components/tidy-validate.js +1 -1
  30. package/dist/content/adapter.js +18 -0
  31. package/dist/content/advisories.d.ts +2 -2
  32. package/dist/content/advisories.js +3 -5
  33. package/dist/content/compose.d.ts +7 -6
  34. package/dist/content/compose.js +26 -20
  35. package/dist/content/concepts.d.ts +21 -15
  36. package/dist/content/concepts.js +55 -32
  37. package/dist/content/field-rules.d.ts +15 -0
  38. package/dist/content/field-rules.js +38 -0
  39. package/dist/content/fields.d.ts +169 -0
  40. package/dist/content/fields.js +41 -0
  41. package/dist/content/fieldset.d.ts +107 -0
  42. package/dist/content/fieldset.js +386 -0
  43. package/dist/content/frontmatter-region.d.ts +38 -0
  44. package/dist/content/frontmatter-region.js +75 -0
  45. package/dist/content/frontmatter.d.ts +35 -2
  46. package/dist/content/frontmatter.js +232 -11
  47. package/dist/content/manifest.d.ts +34 -0
  48. package/dist/content/manifest.js +80 -4
  49. package/dist/content/media-refs.d.ts +2 -2
  50. package/dist/content/media-rewrite.js +1 -69
  51. package/dist/content/reference-index.d.ts +56 -0
  52. package/dist/content/reference-index.js +95 -0
  53. package/dist/content/references.d.ts +40 -0
  54. package/dist/content/references.js +0 -0
  55. package/dist/content/standard-schema.d.ts +30 -0
  56. package/dist/content/standard-schema.js +4 -0
  57. package/dist/content/types.d.ts +127 -178
  58. package/dist/delivery/data.d.ts +2 -2
  59. package/dist/delivery/data.js +1 -1
  60. package/dist/delivery/public-routes.d.ts +10 -5
  61. package/dist/delivery/public-routes.js +25 -2
  62. package/dist/delivery/site-descriptors.d.ts +5 -1
  63. package/dist/delivery/site-descriptors.js +8 -3
  64. package/dist/delivery/site-indexes.d.ts +2 -2
  65. package/dist/delivery/site-resolver.d.ts +25 -0
  66. package/dist/delivery/site-resolver.js +49 -0
  67. package/dist/doctor/checks-local.js +6 -11
  68. package/dist/github/backend.d.ts +83 -0
  69. package/dist/github/backend.js +76 -0
  70. package/dist/github/credentials.d.ts +11 -5
  71. package/dist/github/credentials.js +3 -3
  72. package/dist/github/repo.d.ts +8 -19
  73. package/dist/github/repo.js +69 -80
  74. package/dist/github/types.d.ts +1 -1
  75. package/dist/github/types.js +4 -4
  76. package/dist/index.d.ts +18 -10
  77. package/dist/index.js +9 -5
  78. package/dist/islands/index.d.ts +12 -0
  79. package/dist/islands/index.js +83 -0
  80. package/dist/islands/types.d.ts +7 -0
  81. package/dist/islands/types.js +1 -0
  82. package/dist/log/events.d.ts +1 -1
  83. package/dist/media/index.d.ts +1 -1
  84. package/dist/media/index.js +1 -1
  85. package/dist/media/manifest.d.ts +11 -0
  86. package/dist/media/manifest.js +13 -0
  87. package/dist/media/rewrite-plan.d.ts +2 -3
  88. package/dist/media/rewrite-plan.js +2 -3
  89. package/dist/media/usage.d.ts +2 -2
  90. package/dist/media/usage.js +3 -5
  91. package/dist/nav/site-config.d.ts +0 -6
  92. package/dist/nav/site-config.js +6 -4
  93. package/dist/render/component-grammar.js +11 -11
  94. package/dist/render/component-reference.js +5 -3
  95. package/dist/render/component-validate.d.ts +4 -1
  96. package/dist/render/component-validate.js +10 -35
  97. package/dist/render/highlight.d.ts +9 -0
  98. package/dist/render/highlight.js +206 -0
  99. package/dist/render/pipeline.d.ts +0 -6
  100. package/dist/render/pipeline.js +13 -2
  101. package/dist/render/registry.d.ts +44 -36
  102. package/dist/render/registry.js +47 -6
  103. package/dist/render/rehype-dispatch.d.ts +6 -10
  104. package/dist/render/rehype-dispatch.js +38 -17
  105. package/dist/render/remark-directives.js +4 -5
  106. package/dist/render/sanitize-schema.d.ts +10 -0
  107. package/dist/render/sanitize-schema.js +30 -1
  108. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  109. package/dist/sveltekit/cairn-admin.js +3 -4
  110. package/dist/sveltekit/content-routes.d.ts +10 -8
  111. package/dist/sveltekit/content-routes.js +269 -181
  112. package/dist/sveltekit/guard.js +10 -0
  113. package/dist/sveltekit/health.d.ts +7 -3
  114. package/dist/sveltekit/health.js +9 -3
  115. package/dist/sveltekit/index.d.ts +1 -1
  116. package/dist/sveltekit/nav-routes.d.ts +6 -5
  117. package/dist/sveltekit/nav-routes.js +22 -20
  118. package/dist/sveltekit/types.d.ts +2 -0
  119. package/dist/vite/index.d.ts +3 -3
  120. package/dist/vite/index.js +17 -8
  121. package/package.json +17 -2
  122. package/src/lib/ambient.ts +7 -0
  123. package/src/lib/auth/types.ts +7 -0
  124. package/src/lib/components/CairnAdmin.svelte +2 -6
  125. package/src/lib/components/ComponentForm.svelte +48 -27
  126. package/src/lib/components/ComponentInsertDialog.svelte +26 -14
  127. package/src/lib/components/ConceptList.svelte +41 -4
  128. package/src/lib/components/EditPage.svelte +43 -119
  129. package/src/lib/components/EntryPicker.svelte +154 -0
  130. package/src/lib/components/FieldInput.svelte +262 -0
  131. package/src/lib/components/IconPicker.svelte +4 -2
  132. package/src/lib/components/LinkPicker.svelte +10 -81
  133. package/src/lib/components/MediaHeroField.svelte +12 -5
  134. package/src/lib/components/ObjectGroupField.svelte +97 -0
  135. package/src/lib/components/ReferenceField.svelte +126 -0
  136. package/src/lib/components/RepeatableField.svelte +310 -0
  137. package/src/lib/components/preview-doc.ts +5 -1
  138. package/src/lib/components/tidy-validate.ts +1 -1
  139. package/src/lib/content/adapter.ts +21 -0
  140. package/src/lib/content/advisories.ts +4 -7
  141. package/src/lib/content/compose.ts +30 -23
  142. package/src/lib/content/concepts.ts +68 -40
  143. package/src/lib/content/field-rules.ts +39 -0
  144. package/src/lib/content/fields.ts +178 -0
  145. package/src/lib/content/fieldset.ts +470 -0
  146. package/src/lib/content/frontmatter-region.ts +90 -0
  147. package/src/lib/content/frontmatter.ts +231 -15
  148. package/src/lib/content/manifest.ts +101 -4
  149. package/src/lib/content/media-refs.ts +2 -2
  150. package/src/lib/content/media-rewrite.ts +7 -80
  151. package/src/lib/content/reference-index.ts +159 -0
  152. package/src/lib/content/references.ts +0 -0
  153. package/src/lib/content/standard-schema.ts +25 -0
  154. package/src/lib/content/types.ts +128 -195
  155. package/src/lib/delivery/data.ts +2 -2
  156. package/src/lib/delivery/public-routes.ts +36 -4
  157. package/src/lib/delivery/site-descriptors.ts +8 -3
  158. package/src/lib/delivery/site-indexes.ts +2 -2
  159. package/src/lib/delivery/site-resolver.ts +64 -0
  160. package/src/lib/doctor/checks-local.ts +6 -14
  161. package/src/lib/github/backend.ts +161 -0
  162. package/src/lib/github/credentials.ts +10 -7
  163. package/src/lib/github/repo.ts +79 -83
  164. package/src/lib/github/types.ts +5 -5
  165. package/src/lib/index.ts +40 -18
  166. package/src/lib/islands/index.ts +84 -0
  167. package/src/lib/islands/types.ts +11 -0
  168. package/src/lib/log/events.ts +1 -0
  169. package/src/lib/media/index.ts +1 -0
  170. package/src/lib/media/manifest.ts +14 -0
  171. package/src/lib/media/rewrite-plan.ts +4 -6
  172. package/src/lib/media/usage.ts +4 -7
  173. package/src/lib/nav/site-config.ts +8 -9
  174. package/src/lib/render/component-grammar.ts +10 -10
  175. package/src/lib/render/component-reference.ts +4 -3
  176. package/src/lib/render/component-validate.ts +10 -35
  177. package/src/lib/render/highlight.ts +259 -0
  178. package/src/lib/render/pipeline.ts +13 -8
  179. package/src/lib/render/registry.ts +88 -42
  180. package/src/lib/render/rehype-dispatch.ts +47 -16
  181. package/src/lib/render/remark-directives.ts +4 -5
  182. package/src/lib/render/sanitize-schema.ts +32 -1
  183. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  184. package/src/lib/sveltekit/content-routes.ts +330 -221
  185. package/src/lib/sveltekit/guard.ts +15 -0
  186. package/src/lib/sveltekit/health.ts +13 -6
  187. package/src/lib/sveltekit/index.ts +2 -2
  188. package/src/lib/sveltekit/nav-routes.ts +33 -29
  189. package/src/lib/sveltekit/types.ts +5 -1
  190. package/src/lib/vite/index.ts +20 -11
  191. package/dist/content/schema.d.ts +0 -87
  192. package/dist/content/schema.js +0 -89
  193. package/dist/content/validate.d.ts +0 -17
  194. package/dist/content/validate.js +0 -93
  195. package/src/lib/content/schema.ts +0 -167
  196. package/src/lib/content/validate.ts +0 -90
@@ -1,11 +1,16 @@
1
1
  // cairn-cms: the one-call descriptor helper. A delivery site needs the same per-concept descriptors
2
2
  // the admin runtime uses; this delegates to the shared resolveConcepts so the pairing is one path, not
3
- // tribal knowledge. The YAML URL policy stays the single source of truth.
3
+ // tribal knowledge. Each concept declares its own routing and URL policy, the single source of truth.
4
4
  import { resolveConcepts } from '../content/concepts.js';
5
5
  import type { CairnAdapter, ConceptDescriptor } from '../content/types.js';
6
6
  import type { SiteConfig } from '../nav/site-config.js';
7
7
 
8
- /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
8
+ /**
9
+ * Per-concept descriptors for a site, from its adapter content. The `siteConfig` parameter is retained
10
+ * for API stability and the menus and site name it still carries; the URL policy now lives on each
11
+ * concept, so it is not read here.
12
+ */
9
13
  export function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[] {
10
- return resolveConcepts(adapter.content, siteConfig);
14
+ void siteConfig;
15
+ return resolveConcepts(adapter.content);
11
16
  }
@@ -5,7 +5,7 @@
5
5
  // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
6
  // bundle stays backend-free.
7
7
  import type { CairnAdapter, ConceptConfig } from '../content/types.js';
8
- import type { Infer } from '../content/schema.js';
8
+ import type { InferFieldset } from '../content/fieldset.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
10
  import { siteDescriptors } from './site-descriptors.js';
11
11
  import { createContentIndex, fromGlob } from './content-index.js';
@@ -24,7 +24,7 @@ export type SiteGlobs<A extends CairnAdapter> = {
24
24
  */
25
25
  export type SiteIndexes<A extends CairnAdapter> = {
26
26
  [K in keyof A['content']]: ContentIndex<
27
- NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
27
+ NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? InferFieldset<S> : Record<string, unknown>
28
28
  >;
29
29
  } & { readonly site: SiteResolver };
30
30
 
@@ -6,6 +6,7 @@
6
6
  import type { ConceptDescriptor } from '../content/types.js';
7
7
  import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
8
8
  import type { LinkResolve } from '../content/links.js';
9
+ import { extractReferenceEdges, type ReferenceEdge } from '../content/references.js';
9
10
 
10
11
  /** One concept's descriptor paired with its built index. */
11
12
  export interface ConceptIndex {
@@ -93,6 +94,69 @@ export function createSiteResolver(concepts: ConceptIndex[], opts: { validate?:
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * A reference edge resolved to its target's identity, for a public route to render a linked target.
99
+ * It reuses the target entry's own summary fields rather than re-deriving them, so a linked author
100
+ * card reads the same title and permalink the target's own page does. `summary` is the target's
101
+ * excerpt when present.
102
+ */
103
+ export interface ResolvedReference {
104
+ id: string;
105
+ concept: string;
106
+ title: string;
107
+ permalink: string;
108
+ summary?: string;
109
+ }
110
+
111
+ /** Project a resolved target entry into the identity a public route renders for a reference. */
112
+ function projectReference(edge: ReferenceEdge, target: ContentSummary): ResolvedReference {
113
+ const resolved: ResolvedReference = {
114
+ id: edge.id,
115
+ concept: edge.concept,
116
+ title: target.title,
117
+ permalink: target.permalink,
118
+ };
119
+ if (target.excerpt) resolved.summary = target.excerpt;
120
+ return resolved;
121
+ }
122
+
123
+ /**
124
+ * Resolve a concept's `reference` and `array(reference)` frontmatter edges to their target identities,
125
+ * keyed by the field name, so a public route renders a reference as a link to its target's page. The
126
+ * resolution lives here because only the cross-concept resolver reaches a different concept's entries:
127
+ * a posts entry's `author` edge targets a pages entry, which the posts index alone cannot read. A
128
+ * single `reference` field resolves to one `ResolvedReference`, an `array(reference)` to a
129
+ * `ResolvedReference[]` in edge order. An id with no live target is dropped rather than thrown: the
130
+ * build's `verifyReferences` gate already fails a true dangling edge, so an unresolved id at request
131
+ * time is a mid-flight or draft target, not a hard error. Resolve per call, since the target entries
132
+ * exist only after every per-concept index is unioned into the resolver.
133
+ */
134
+ export function resolveReferences(
135
+ site: SiteResolver,
136
+ descriptor: ConceptDescriptor,
137
+ frontmatter: Record<string, unknown>,
138
+ ): Record<string, ResolvedReference | ResolvedReference[]> {
139
+ const edges = extractReferenceEdges(frontmatter, descriptor.fields);
140
+ const resolved: Record<string, ResolvedReference | ResolvedReference[]> = {};
141
+ for (const field of descriptor.fields) {
142
+ const isSingle = field.type === 'reference';
143
+ const isArray = field.type === 'array' && field.item.type === 'reference';
144
+ if (!isSingle && !isArray) continue;
145
+ const fieldEdges = edges.filter((edge) => edge.field === field.name);
146
+ const hits: ResolvedReference[] = [];
147
+ for (const edge of fieldEdges) {
148
+ const target = site.concept(edge.concept)?.byId(edge.id);
149
+ if (target) hits.push(projectReference(edge, target));
150
+ }
151
+ if (isSingle) {
152
+ if (hits.length > 0) resolved[field.name] = hits[0];
153
+ } else {
154
+ resolved[field.name] = hits;
155
+ }
156
+ }
157
+ return resolved;
158
+ }
159
+
96
160
  /**
97
161
  * A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
98
162
  * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks.
@@ -5,11 +5,8 @@ import { fail, pass, skip } from './types.js';
5
5
  import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
6
6
  import { readWranglerConfig } from './wrangler-config.js';
7
7
  import { requireOrigin } from '../env.js';
8
- import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
8
+ import { parseSiteConfig } from '../nav/site-config.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
- import { normalizeConcepts } from '../content/concepts.js';
11
- import { defineFields } from '../content/schema.js';
12
- import type { ConceptConfig } from '../content/types.js';
13
10
 
14
11
  const NO_WRANGLER: CheckResult = skip('no wrangler.jsonc or wrangler.toml found');
15
12
 
@@ -156,16 +153,11 @@ export const configSiteConfig: DoctorCheck = {
156
153
  const text = await readSiteConfigText(ctx);
157
154
  if (text === null) return skip(`no site.config.yaml found (looked in ${SITE_CONFIG_PATHS.join(', ')})`);
158
155
  try {
159
- const policy = urlPolicyFrom(parseSiteConfig(text));
160
- // Run the engine's own URL-policy validation by declaring a synthetic empty concept
161
- // per policy key. Routing is concept-fixed in the engine (CONCEPT_ROUTING, never the
162
- // adapter), so the dated rules apply faithfully here. What a CLI cannot check without
163
- // evaluating the adapter is whether each policy key names a concept the site declares.
164
- const synthetic = Object.fromEntries(
165
- Object.keys(policy).map((id): [string, ConceptConfig] => [id, { dir: '', schema: defineFields([]) }])
166
- );
167
- normalizeConcepts(synthetic, policy);
168
- return pass('parsed and URL policy validated (the adapter concept set is not checkable from the CLI)');
156
+ // Parse-only. parseSiteConfig validates the root shape and, since Contract v2, hard-errors on a
157
+ // stale per-concept `content:` block (URL policy moved onto defineConcept). The per-concept URL
158
+ // policy is now validated at the concept declaration, which a CLI cannot reach without the adapter.
159
+ parseSiteConfig(text);
160
+ return pass('parsed (per-concept URL policy lives on the adapter concepts, not checkable from the CLI)');
169
161
  } catch (err) {
170
162
  return fail(err instanceof Error ? err.message : String(err));
171
163
  }
@@ -0,0 +1,161 @@
1
+ // cairn-cms: the Backend seam. A Backend is read, commit, and branch operations over files,
2
+ // never a query(): that line is the constraint that keeps a store swappable and a database out.
3
+ // The adapter holds a BackendProvider from githubApp(...); the engine resolves one live Backend
4
+ // per request via connect(env), with the GitHub App installation token minted and cached behind
5
+ // the seam. makeGithubBackend takes an injectable token getter so a test wires a literal token and
6
+ // the in-memory fetch double intercepts the same GitHub URLs the production getter would reach.
7
+ import { readRaw, listMarkdown, commitFiles } from './repo.js';
8
+ import type { FileChange } from './repo.js';
9
+ import { branchHeadSha, createBranch as createBranchRef, deleteBranch, listBranches } from './branches.js';
10
+ import { appCredentials } from './credentials.js';
11
+ import type { BackendEnv } from './credentials.js';
12
+ import { cachedInstallationToken } from './signing.js';
13
+ import { CommitConflictError } from './types.js';
14
+ import type { CommitAuthor, RepoFile } from './types.js';
15
+
16
+ // One BackendEnv declaration lives in credentials.js (the secret-channel owner). Re-export it here so
17
+ // the seam and connect() name the same type the public root surfaces, with no duplicate declaration.
18
+ export type { BackendEnv };
19
+
20
+ /**
21
+ * A live, connected content store pinned to a default branch. The GitHub implementation already
22
+ * holds a minted token behind it; the engine resolves one per request. Read, commit, and branch
23
+ * over files only: this interface never grows a query() method.
24
+ */
25
+ export interface Backend {
26
+ /** The site's default branch, for example "main". Callers reading published state pass it as the ref. */
27
+ readonly defaultBranch: string;
28
+
29
+ /** Raw file contents at a ref, or null when the path does not exist. */
30
+ readFile(path: string, ref: string): Promise<string | null>;
31
+
32
+ /** The markdown entries directly in a concept directory at a ref, newest id first. */
33
+ readEntries(dir: string, ref: string): Promise<RepoFile[]>;
34
+
35
+ /** A branch's head commit sha, or null when the branch does not exist. */
36
+ branchHead(branch: string): Promise<string | null>;
37
+
38
+ /** Branch names under a prefix, sorted. */
39
+ listBranches(prefix: string): Promise<string[]>;
40
+
41
+ /**
42
+ * Commit a set of path changes atomically on a branch; returns the new commit sha. When
43
+ * `expectedHead` is given the commit is fail-closed: it makes one attempt and throws
44
+ * CommitConflictError if the branch head is not `expectedHead`. Omitting it keeps the head-merge
45
+ * retry the entry and publish paths rely on.
46
+ */
47
+ commit(
48
+ branch: string,
49
+ changes: FileChange[],
50
+ author: CommitAuthor,
51
+ message: string,
52
+ expectedHead?: string,
53
+ ): Promise<string>;
54
+
55
+ /** Create a branch at another branch's current head. */
56
+ createBranch(name: string, fromBranch: string): Promise<void>;
57
+
58
+ /** Delete a branch; a missing branch is success. */
59
+ deleteBranch(name: string): Promise<void>;
60
+ }
61
+
62
+ /** The adapter's backend value: a provider that connect()s to a live Backend given the Worker env. */
63
+ export interface BackendProvider {
64
+ /** A stable tag for the implementation, for example "github-app". The non-request readers narrow on it. */
65
+ readonly kind: string;
66
+ /** The default branch, surfaced before connect() so compose-time code can read it. */
67
+ readonly branch: string;
68
+ /** Connect to a live Backend; the GitHub implementation mints and caches its token lazily. */
69
+ connect(env: BackendEnv): Backend;
70
+ }
71
+
72
+ /** What githubApp() returns: the generic provider plus the GitHub App's non-secret identity facts. */
73
+ export interface GithubAppProvider extends BackendProvider {
74
+ readonly kind: 'github-app';
75
+ readonly owner: string;
76
+ readonly repo: string;
77
+ readonly appId: string;
78
+ readonly installationId: string;
79
+ }
80
+
81
+ /**
82
+ * Narrow a provider to the GitHub App provider on its `kind` tag. The non-request readers (the
83
+ * health self-test, the build-time facts) call this before reading the GitHub-specific identity,
84
+ * since `BackendProvider.kind` is a bare string the compiler cannot narrow on its own.
85
+ */
86
+ export function isGithubApp(provider: BackendProvider): provider is GithubAppProvider {
87
+ return provider.kind === 'github-app';
88
+ }
89
+
90
+ /** The non-secret GitHub App identity an adapter carries in source; the private key stays a Worker secret. */
91
+ interface GithubAppConfig {
92
+ owner: string;
93
+ repo: string;
94
+ branch: string;
95
+ appId: string;
96
+ installationId: string;
97
+ }
98
+
99
+ /**
100
+ * The live GitHub Backend over the existing repo.ts and branches.ts transports. The token getter
101
+ * is injected rather than the env: connect() wires the production getter that mints and caches the
102
+ * installation token, and a unit test wires a literal so the in-memory fetch double intercepts the
103
+ * same GitHub URLs. Not barrel-exported; it is the internal test seam imported by path.
104
+ */
105
+ export function makeGithubBackend(config: GithubAppConfig, getToken: () => string | Promise<string>): Backend {
106
+ return {
107
+ defaultBranch: config.branch,
108
+ async readFile(path, ref) {
109
+ return readRaw({ ...config, branch: ref }, path, await getToken());
110
+ },
111
+ async readEntries(dir, ref) {
112
+ return listMarkdown({ ...config, branch: ref }, dir, await getToken());
113
+ },
114
+ async branchHead(branch) {
115
+ return branchHeadSha(config, branch, await getToken());
116
+ },
117
+ async listBranches(prefix) {
118
+ return listBranches(config, prefix, await getToken());
119
+ },
120
+ async commit(branch, changes, author, message, expectedHead) {
121
+ return commitFiles({ ...config, branch }, changes, { message, author }, await getToken(), expectedHead);
122
+ },
123
+ async createBranch(name, fromBranch) {
124
+ const tok = await getToken();
125
+ const head = await branchHeadSha(config, fromBranch, tok);
126
+ // A null head means the source branch is gone or unreadable. Throw a defined, catchable
127
+ // error the save path maps to its 500 rather than letting the createBranchRef POST fail raw.
128
+ if (head === null) throw new CommitConflictError(`${fromBranch} (unreadable source)`);
129
+ await createBranchRef(config, name, head, tok);
130
+ },
131
+ async deleteBranch(name) {
132
+ await deleteBranch(config, name, await getToken());
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The default content backend: a GitHub App over a repo branch. Returns a provider carrying the
139
+ * App's non-secret identity (so the build-time, health, and doctor readers narrow on kind and read
140
+ * it) whose connect(env) mints and caches the installation token from the Worker's private-key
141
+ * secret. The missing-secret CairnError stays on first token use, inside connect.
142
+ */
143
+ export function githubApp(config: {
144
+ owner: string;
145
+ repo: string;
146
+ branch: string;
147
+ appId: string;
148
+ installationId: string;
149
+ }): GithubAppProvider {
150
+ return {
151
+ kind: 'github-app',
152
+ branch: config.branch,
153
+ owner: config.owner,
154
+ repo: config.repo,
155
+ appId: config.appId,
156
+ installationId: config.installationId,
157
+ connect(env) {
158
+ return makeGithubBackend(config, () => cachedInstallationToken(appCredentials(config, env)));
159
+ },
160
+ };
161
+ }
@@ -3,22 +3,25 @@
3
3
  // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
4
4
  // TypeError. Mirrors requireDb/requireOrigin in env.ts.
5
5
  import { CairnError } from '../diagnostics/index.js';
6
- import type { BackendConfig } from '../content/types.js';
7
6
  import type { AppCredentials } from './types.js';
8
7
 
9
- /** The Worker secret holding the GitHub App private key: base64 of the PEM, single line. */
10
- export interface GithubKeyEnv {
8
+ /**
9
+ * The Worker secret carrier `Backend.connect` reads: the GitHub App private key as base64 of the
10
+ * PEM, single line. A consumer's `App.Platform.env` block names it. Aliased as the engine's
11
+ * `BackendEnv` since the backend seam owns the secret channel.
12
+ */
13
+ export interface BackendEnv {
11
14
  GITHUB_APP_PRIVATE_KEY_B64?: string;
12
15
  }
13
16
 
14
17
  /**
15
- * Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
18
+ * Assemble the `AppCredentials` the signer needs from the GitHub App's identity (app id,
16
19
  * installation) and the Worker's private-key secret. Throws a CairnError naming
17
20
  * `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
18
21
  */
19
22
  export function appCredentials(
20
- backend: Pick<BackendConfig, 'appId' | 'installationId'>,
21
- env: GithubKeyEnv,
23
+ identity: { appId: string; installationId: string },
24
+ env: BackendEnv,
22
25
  ): AppCredentials {
23
26
  const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
24
27
  if (!privateKeyB64) {
@@ -26,5 +29,5 @@ export function appCredentials(
26
29
  message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
27
30
  });
28
31
  }
29
- return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
32
+ return { appId: identity.appId, installationId: identity.installationId, privateKeyB64 };
30
33
  }
@@ -86,56 +86,6 @@ export async function readRaw(repo: RepoRef, path: string, token?: string): Prom
86
86
  return res.text();
87
87
  }
88
88
 
89
- /** Standard (padded) base64 of UTF-8 text, the form the contents API expects. */
90
- function toBase64(text: string): string {
91
- return btoa(Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b)).join(''));
92
- }
93
-
94
- /** The current blob sha for a path, or null if the file does not yet exist. */
95
- export async function fileSha(repo: RepoRef, path: string, token: string): Promise<string | null> {
96
- const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github+json', token) });
97
- if (res.status === 404) return null;
98
- if (!res.ok) throw new Error(`GitHub stat ${path} failed: ${res.status}`);
99
- return ((await res.json()) as { sha: string }).sha;
100
- }
101
-
102
- /**
103
- * Commit `content` to `path` on the configured branch through the contents API. The author is
104
- * the editor; the committer is omitted, so GitHub attributes it to the App (`cairn-cms[bot]`).
105
- * Updates the file in place when it exists (passing its sha), creates it otherwise. Returns the
106
- * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`,
107
- * so the save fails safe: re-fetch and ask the editor to reapply, never a merge.
108
- *
109
- * Caller preconditions this layer cannot enforce, and the save action (Plan 05) must:
110
- * `path` is confined to the concept's configured directory (the App token can write anywhere
111
- * in the repo, so an unvalidated path could overwrite CI config or source), and `author` is
112
- * derived from the verified server-side session, never from request input.
113
- */
114
- export async function commitFile(
115
- repo: RepoRef,
116
- path: string,
117
- content: string,
118
- opts: { message: string; author: CommitAuthor },
119
- token: string,
120
- ): Promise<string> {
121
- const sha = await fileSha(repo, path, token);
122
- const url = `${API}/repos/${repo.owner}/${repo.repo}/contents/${path.replace(/^\/+|\/+$/g, '')}`;
123
- const res = await fetch(url, {
124
- method: 'PUT',
125
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
126
- body: JSON.stringify({
127
- message: opts.message,
128
- content: toBase64(content),
129
- branch: repo.branch,
130
- author: opts.author,
131
- ...(sha ? { sha } : {}),
132
- }),
133
- });
134
- if (res.status === 409) throw new CommitConflictError(path);
135
- if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
136
- return ((await res.json()) as { commit: { sha: string } }).commit.sha;
137
- }
138
-
139
89
  /** A path change for an atomic commit: write `content`, or delete the path when `content` is null. */
140
90
  export interface FileChange {
141
91
  path: string;
@@ -186,6 +136,69 @@ function treeChanges(changes: FileChange[]): TreeChange[] {
186
136
  /** Retries after the initial attempt when the branch moves under an atomic commit. */
187
137
  const COMMIT_RETRIES = 3;
188
138
 
139
+ /**
140
+ * Build a tree on `parent`'s tree, create the commit, and PATCH the branch ref to it. Returns the
141
+ * new commit sha, or null when the ref PATCH is a non-fast-forward (the head moved). A tree-create
142
+ * 422 (an unprocessable delete) becomes a `CommitConflictError`, and any other non-fast-forward
143
+ * detail is left to the caller to map.
144
+ */
145
+ async function commitOnTree(
146
+ repo: RepoRef,
147
+ parent: string,
148
+ tree: TreeChange[],
149
+ opts: { message: string; author: CommitAuthor },
150
+ token: string,
151
+ ): Promise<{ sha: string } | { conflict: true }> {
152
+ const baseTree = await commitTreeSha(repo, parent, token);
153
+
154
+ const treeRes = await fetch(gitUrl(repo, 'trees'), {
155
+ method: 'POST',
156
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ base_tree: baseTree, tree }),
158
+ });
159
+ if (!treeRes.ok) {
160
+ // A 422 means an entry is unprocessable against the base tree, which a delete of an
161
+ // already-removed path produces (a concurrent delete or rename got there first). Treat it as
162
+ // the same non-fast-forward conflict the ref PATCH surfaces, so the caller fails safe with the
163
+ // reload-and-retry path instead of a raw 500.
164
+ if (treeRes.status === 422) throw new CommitConflictError(`${repo.branch} (tree create)`);
165
+ throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
166
+ }
167
+ const newTree = ((await treeRes.json()) as { sha: string }).sha;
168
+
169
+ const commitRes = await fetch(gitUrl(repo, 'commits'), {
170
+ method: 'POST',
171
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ message: opts.message, tree: newTree, parents: [parent], author: opts.author }),
173
+ });
174
+ if (!commitRes.ok) throw new Error(`GitHub commit create failed: ${commitRes.status} ${await commitRes.text()}`);
175
+ const newCommit = ((await commitRes.json()) as { sha: string }).sha;
176
+
177
+ const refRes = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(repo.branch)}`), {
178
+ method: 'PATCH',
179
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ sha: newCommit, force: false }),
181
+ });
182
+ if (refRes.ok) return { sha: newCommit };
183
+ // A non-fast-forward means the branch moved; the caller decides whether to retry or fail closed.
184
+ // Any other failure is not a race, so surface it.
185
+ if (refRes.status !== 422) throw new Error(`GitHub ref update failed: ${refRes.status} ${await refRes.text()}`);
186
+ return { conflict: true };
187
+ }
188
+
189
+ /** Fail-closed commit on a known head: a non-fast-forward becomes a `CommitConflictError`. */
190
+ async function commitOnHead(
191
+ repo: RepoRef,
192
+ head: string,
193
+ tree: TreeChange[],
194
+ opts: { message: string; author: CommitAuthor },
195
+ token: string,
196
+ ): Promise<string> {
197
+ const result = await commitOnTree(repo, head, tree, opts, token);
198
+ if ('conflict' in result) throw new CommitConflictError(`${repo.branch} (head moved)`);
199
+ return result.sha;
200
+ }
201
+
189
202
  /**
190
203
  * Commit several path changes in one commit over the Git Data API. The author is the editor; the
191
204
  * committer is omitted, so GitHub attributes the commit to the App. Returns the new commit sha.
@@ -197,51 +210,34 @@ const COMMIT_RETRIES = 3;
197
210
  *
198
211
  * An empty change set is rejected, since it would otherwise push an empty commit that triggers a
199
212
  * site redeploy for no content change.
213
+ *
214
+ * When `expectedHead` is supplied the commit is fail-closed: it makes a single attempt with no
215
+ * retry, throws a `CommitConflictError` when the branch head is not `expectedHead` (a concurrent
216
+ * commit landed), and otherwise commits onto that head. The nav and settings writes, which land on
217
+ * the default branch and trigger a deploy, pass it so a same-branch race surfaces the editor's
218
+ * reload-and-reapply prompt rather than a silent last-writer-wins. Omitting it keeps the
219
+ * head-merge retry the entry and publish paths rely on.
200
220
  */
201
221
  export async function commitFiles(
202
222
  repo: RepoRef,
203
223
  changes: FileChange[],
204
224
  opts: { message: string; author: CommitAuthor },
205
225
  token: string,
226
+ expectedHead?: string,
206
227
  ): Promise<string> {
207
228
  if (changes.length === 0) throw new Error('commitFiles: no changes to commit');
208
229
  const tree = treeChanges(changes);
230
+ if (expectedHead !== undefined) {
231
+ const head = await headCommitSha(repo, token);
232
+ if (head !== expectedHead) throw new CommitConflictError(`${repo.branch} (head moved)`);
233
+ return commitOnHead(repo, head, tree, opts, token);
234
+ }
209
235
  for (let attempt = 0; attempt <= COMMIT_RETRIES; attempt++) {
210
236
  const parent = await headCommitSha(repo, token);
211
- const baseTree = await commitTreeSha(repo, parent, token);
212
-
213
- const treeRes = await fetch(gitUrl(repo, 'trees'), {
214
- method: 'POST',
215
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ base_tree: baseTree, tree }),
217
- });
218
- if (!treeRes.ok) {
219
- // A 422 means an entry is unprocessable against the base tree, which a delete of an
220
- // already-removed path produces (a concurrent delete or rename got there first). Treat it as
221
- // the same non-fast-forward conflict the ref PATCH surfaces, so the caller fails safe with the
222
- // reload-and-retry path instead of a raw 500.
223
- if (treeRes.status === 422) throw new CommitConflictError(`${repo.branch} (tree create)`);
224
- throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
225
- }
226
- const newTree = ((await treeRes.json()) as { sha: string }).sha;
227
-
228
- const commitRes = await fetch(gitUrl(repo, 'commits'), {
229
- method: 'POST',
230
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
231
- body: JSON.stringify({ message: opts.message, tree: newTree, parents: [parent], author: opts.author }),
232
- });
233
- if (!commitRes.ok) throw new Error(`GitHub commit create failed: ${commitRes.status} ${await commitRes.text()}`);
234
- const newCommit = ((await commitRes.json()) as { sha: string }).sha;
235
-
236
- const refRes = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(repo.branch)}`), {
237
- method: 'PATCH',
238
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
239
- body: JSON.stringify({ sha: newCommit, force: false }),
240
- });
241
- if (refRes.ok) return newCommit;
237
+ const result = await commitOnTree(repo, parent, tree, opts, token);
242
238
  // A non-fast-forward means the branch moved; retry on the new head so a concurrent commit
243
- // is preserved. Any other failure is not a race, so surface it.
244
- if (refRes.status !== 422) throw new Error(`GitHub ref update failed: ${refRes.status} ${await refRes.text()}`);
239
+ // is preserved.
240
+ if ('sha' in result) return result.sha;
245
241
  }
246
242
  throw new CommitConflictError(`${repo.branch} (atomic commit)`);
247
243
  }
@@ -1,9 +1,9 @@
1
- // cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
2
- // reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
3
- // `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
4
- // wanted with no conversion.
1
+ // cairn-cms: the GitHub backend's plain data types and its one typed error. The GitHub App
2
+ // provider config (`githubApp`'s input) carries these repo coordinates; `RepoRef` is the
3
+ // `{ owner, repo, branch }` subset the read and commit transports take, so the provider config
4
+ // is assignable wherever a `RepoRef` is wanted with no conversion.
5
5
 
6
- /** Repo coordinates pinned to a branch: the structural subset of `BackendConfig` the read and commit paths need. */
6
+ /** Repo coordinates pinned to a branch: the `{ owner, repo, branch }` subset the read and commit paths need. */
7
7
  export interface RepoRef {
8
8
  owner: string;
9
9
  repo: string;