@glw907/cairn-cms 0.5.1 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/auth/crypto.d.ts +13 -0
  2. package/dist/auth/crypto.d.ts.map +1 -0
  3. package/dist/auth/crypto.js +31 -0
  4. package/dist/auth/store.d.ts +41 -0
  5. package/dist/auth/store.d.ts.map +1 -0
  6. package/dist/auth/store.js +115 -0
  7. package/dist/auth/types.d.ts +25 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/components/AdminLayout.svelte +58 -164
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -18
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +36 -20
  14. package/dist/components/ComponentPalette.svelte.d.ts +11 -4
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -1
  16. package/dist/components/ConceptList.svelte +81 -0
  17. package/dist/components/ConceptList.svelte.d.ts +13 -0
  18. package/dist/components/ConceptList.svelte.d.ts.map +1 -0
  19. package/dist/components/ConfirmPage.svelte +23 -20
  20. package/dist/components/ConfirmPage.svelte.d.ts +6 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
  22. package/dist/components/EditPage.svelte +155 -136
  23. package/dist/components/EditPage.svelte.d.ts +16 -8
  24. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  25. package/dist/components/LoginPage.svelte +42 -52
  26. package/dist/components/LoginPage.svelte.d.ts +12 -0
  27. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  28. package/dist/components/ManageEditors.svelte +81 -0
  29. package/dist/components/ManageEditors.svelte.d.ts +23 -0
  30. package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
  31. package/dist/components/MarkdownEditor.svelte +81 -0
  32. package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
  33. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
  34. package/dist/components/NavTree.svelte +73 -63
  35. package/dist/components/NavTree.svelte.d.ts +13 -4
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -1
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +3 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +5 -4
  41. package/dist/content/compose.d.ts +7 -0
  42. package/dist/content/compose.d.ts.map +1 -0
  43. package/dist/content/compose.js +32 -0
  44. package/dist/content/concepts.d.ts +17 -0
  45. package/dist/content/concepts.d.ts.map +1 -0
  46. package/dist/content/concepts.js +41 -0
  47. package/dist/content/frontmatter.d.ts +18 -0
  48. package/dist/content/frontmatter.d.ts.map +1 -0
  49. package/dist/content/frontmatter.js +58 -0
  50. package/dist/content/ids.d.ts +17 -0
  51. package/dist/content/ids.d.ts.map +1 -0
  52. package/dist/content/ids.js +33 -0
  53. package/dist/content/types.d.ts +210 -0
  54. package/dist/content/types.d.ts.map +1 -0
  55. package/dist/content/types.js +1 -0
  56. package/dist/content/validate.d.ts +13 -0
  57. package/dist/content/validate.d.ts.map +1 -0
  58. package/dist/content/validate.js +45 -0
  59. package/dist/email.d.ts +25 -12
  60. package/dist/email.d.ts.map +1 -1
  61. package/dist/email.js +24 -24
  62. package/dist/env.d.ts +24 -0
  63. package/dist/env.d.ts.map +1 -0
  64. package/dist/env.js +29 -0
  65. package/dist/github/credentials.d.ts +12 -0
  66. package/dist/github/credentials.d.ts.map +1 -0
  67. package/dist/github/credentials.js +11 -0
  68. package/dist/github/repo.d.ts +49 -0
  69. package/dist/github/repo.d.ts.map +1 -0
  70. package/dist/github/repo.js +123 -0
  71. package/dist/github/signing.d.ts +17 -0
  72. package/dist/github/signing.d.ts.map +1 -0
  73. package/dist/github/signing.js +79 -0
  74. package/dist/github/types.d.ts +35 -0
  75. package/dist/github/types.d.ts.map +1 -0
  76. package/dist/github/types.js +19 -0
  77. package/dist/index.d.ts +27 -8
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -10
  80. package/dist/{nav.d.ts → nav/site-config.d.ts} +16 -24
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/{nav.js → nav/site-config.js} +27 -13
  83. package/dist/render/glyph.d.ts +1 -1
  84. package/dist/render/glyph.d.ts.map +1 -1
  85. package/dist/render/index.d.ts +5 -5
  86. package/dist/render/index.d.ts.map +1 -1
  87. package/dist/render/index.js +6 -6
  88. package/dist/render/pipeline.d.ts +7 -6
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +5 -5
  91. package/dist/render/registry.d.ts +10 -6
  92. package/dist/render/registry.d.ts.map +1 -1
  93. package/dist/render/registry.js +8 -6
  94. package/dist/render/rehype-dispatch.d.ts +8 -7
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/rehype-dispatch.js +16 -14
  97. package/dist/render/remark-directives.d.ts +1 -1
  98. package/dist/render/remark-directives.d.ts.map +1 -1
  99. package/dist/render/sanitize.d.ts +8 -0
  100. package/dist/render/sanitize.d.ts.map +1 -0
  101. package/dist/render/sanitize.js +26 -0
  102. package/dist/sveltekit/auth-routes.d.ts +23 -0
  103. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  104. package/dist/sveltekit/auth-routes.js +85 -0
  105. package/dist/sveltekit/content-routes.d.ts +80 -0
  106. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  107. package/dist/sveltekit/content-routes.js +183 -0
  108. package/dist/sveltekit/editors-routes.d.ts +24 -0
  109. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  110. package/dist/sveltekit/editors-routes.js +73 -0
  111. package/dist/sveltekit/guard.d.ts +9 -0
  112. package/dist/sveltekit/guard.d.ts.map +1 -0
  113. package/dist/sveltekit/guard.js +43 -0
  114. package/dist/sveltekit/health.d.ts +19 -0
  115. package/dist/sveltekit/health.d.ts.map +1 -0
  116. package/dist/sveltekit/health.js +12 -0
  117. package/dist/sveltekit/index.d.ts +9 -173
  118. package/dist/sveltekit/index.d.ts.map +1 -1
  119. package/dist/sveltekit/index.js +8 -348
  120. package/dist/sveltekit/nav-routes.d.ts +30 -0
  121. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  122. package/dist/sveltekit/nav-routes.js +103 -0
  123. package/dist/sveltekit/types.d.ts +32 -0
  124. package/dist/sveltekit/types.d.ts.map +1 -0
  125. package/dist/sveltekit/types.js +1 -0
  126. package/package.json +33 -57
  127. package/src/lib/auth/crypto.ts +37 -0
  128. package/src/lib/auth/store.ts +158 -0
  129. package/src/lib/auth/types.ts +27 -0
  130. package/src/lib/components/AdminLayout.svelte +58 -164
  131. package/src/lib/components/ComponentPalette.svelte +36 -20
  132. package/src/lib/components/ConceptList.svelte +81 -0
  133. package/src/lib/components/ConfirmPage.svelte +23 -20
  134. package/src/lib/components/EditPage.svelte +155 -136
  135. package/src/lib/components/LoginPage.svelte +42 -52
  136. package/src/lib/components/ManageEditors.svelte +81 -0
  137. package/src/lib/components/MarkdownEditor.svelte +81 -0
  138. package/src/lib/components/NavTree.svelte +73 -63
  139. package/src/lib/components/cairn-admin.css +42 -0
  140. package/src/lib/components/index.ts +5 -4
  141. package/src/lib/content/compose.ts +39 -0
  142. package/src/lib/content/concepts.ts +57 -0
  143. package/src/lib/content/frontmatter.ts +71 -0
  144. package/src/lib/content/ids.ts +38 -0
  145. package/src/lib/content/types.ts +235 -0
  146. package/src/lib/content/validate.ts +51 -0
  147. package/src/lib/email.ts +52 -38
  148. package/src/lib/env.ts +32 -0
  149. package/src/lib/github/credentials.ts +27 -0
  150. package/src/lib/github/repo.ts +138 -0
  151. package/src/lib/github/signing.ts +97 -0
  152. package/src/lib/github/types.ts +46 -0
  153. package/src/lib/index.ts +86 -10
  154. package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
  155. package/src/lib/render/glyph.ts +6 -6
  156. package/src/lib/render/index.ts +6 -6
  157. package/src/lib/render/pipeline.ts +23 -22
  158. package/src/lib/render/registry.ts +35 -26
  159. package/src/lib/render/rehype-dispatch.ts +58 -56
  160. package/src/lib/render/remark-directives.ts +46 -46
  161. package/src/lib/render/sanitize.ts +27 -0
  162. package/src/lib/sveltekit/auth-routes.ts +107 -0
  163. package/src/lib/sveltekit/content-routes.ts +261 -0
  164. package/src/lib/sveltekit/editors-routes.ts +82 -0
  165. package/src/lib/sveltekit/guard.ts +47 -0
  166. package/src/lib/sveltekit/health.ts +24 -0
  167. package/src/lib/sveltekit/index.ts +19 -512
  168. package/src/lib/sveltekit/nav-routes.ts +139 -0
  169. package/src/lib/sveltekit/types.ts +33 -0
  170. package/dist/adapter.d.ts +0 -93
  171. package/dist/adapter.d.ts.map +0 -1
  172. package/dist/adapter.js +0 -30
  173. package/dist/auth/admins.d.ts +0 -33
  174. package/dist/auth/admins.d.ts.map +0 -1
  175. package/dist/auth/admins.js +0 -90
  176. package/dist/auth/capabilities.d.ts +0 -7
  177. package/dist/auth/capabilities.d.ts.map +0 -1
  178. package/dist/auth/capabilities.js +0 -26
  179. package/dist/auth/config.d.ts +0 -2097
  180. package/dist/auth/config.d.ts.map +0 -1
  181. package/dist/auth/config.js +0 -78
  182. package/dist/auth/guard.d.ts +0 -34
  183. package/dist/auth/guard.d.ts.map +0 -1
  184. package/dist/auth/guard.js +0 -47
  185. package/dist/auth/index.d.ts +0 -5
  186. package/dist/auth/index.d.ts.map +0 -1
  187. package/dist/auth/index.js +0 -7
  188. package/dist/auth/schema.d.ts +0 -750
  189. package/dist/auth/schema.d.ts.map +0 -1
  190. package/dist/auth/schema.js +0 -93
  191. package/dist/carta.d.ts +0 -39
  192. package/dist/carta.d.ts.map +0 -1
  193. package/dist/carta.js +0 -30
  194. package/dist/components/CollectionList.svelte +0 -96
  195. package/dist/components/CollectionList.svelte.d.ts +0 -8
  196. package/dist/components/CollectionList.svelte.d.ts.map +0 -1
  197. package/dist/components/ManageAdmins.svelte +0 -84
  198. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  199. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  200. package/dist/content.d.ts +0 -3
  201. package/dist/content.d.ts.map +0 -1
  202. package/dist/content.js +0 -10
  203. package/dist/editor.d.ts +0 -25
  204. package/dist/editor.d.ts.map +0 -1
  205. package/dist/editor.js +0 -20
  206. package/dist/frontmatter.d.ts +0 -3
  207. package/dist/frontmatter.d.ts.map +0 -1
  208. package/dist/frontmatter.js +0 -16
  209. package/dist/github.d.ts +0 -72
  210. package/dist/github.d.ts.map +0 -1
  211. package/dist/github.js +0 -171
  212. package/dist/nav.d.ts.map +0 -1
  213. package/dist/slug.d.ts +0 -7
  214. package/dist/slug.d.ts.map +0 -1
  215. package/dist/slug.js +0 -15
  216. package/dist/utils.d.ts +0 -3
  217. package/dist/utils.d.ts.map +0 -1
  218. package/dist/utils.js +0 -11
  219. package/src/lib/adapter.ts +0 -144
  220. package/src/lib/auth/admins.ts +0 -106
  221. package/src/lib/auth/capabilities.ts +0 -35
  222. package/src/lib/auth/config.ts +0 -108
  223. package/src/lib/auth/guard.ts +0 -60
  224. package/src/lib/auth/index.ts +0 -7
  225. package/src/lib/auth/schema.ts +0 -112
  226. package/src/lib/carta.ts +0 -59
  227. package/src/lib/components/CollectionList.svelte +0 -96
  228. package/src/lib/components/ManageAdmins.svelte +0 -84
  229. package/src/lib/content.ts +0 -11
  230. package/src/lib/editor.ts +0 -38
  231. package/src/lib/frontmatter.ts +0 -17
  232. package/src/lib/github.ts +0 -220
  233. package/src/lib/slug.ts +0 -16
  234. package/src/lib/utils.ts +0 -12
@@ -0,0 +1,235 @@
1
+ // cairn-cms: the adapter contract a site implements, and the engine-internal descriptors
2
+ // the contract normalizes into.
3
+ //
4
+ // The adapter is the single seam the engine consumes (spec §8). A site supplies a
5
+ // `CairnAdapter` at `src/lib/cairn.config.ts` declaring its backend repo, the content
6
+ // concepts it enables, its magic-link sender, and a design-accurate `renderPreview`. The
7
+ // engine never hard-codes a concept, directory, or field; it reads them here. Field
8
+ // descriptors are plain data so a `load` function can hand them across the server-to-client
9
+ // boundary to the editor form.
10
+ import type { ComponentRegistry } from '../render/registry.js';
11
+
12
+ /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
13
+ interface FieldBase {
14
+ /** Frontmatter key and form input name. */
15
+ name: string;
16
+ /** Form label. */
17
+ label: string;
18
+ /** A required field fails validation when empty (spec §7.4). */
19
+ required?: boolean;
20
+ }
21
+
22
+ /** A single-line text input. */
23
+ export interface TextField extends FieldBase {
24
+ type: 'text';
25
+ }
26
+ /** A multi-line text input. */
27
+ export interface TextareaField extends FieldBase {
28
+ type: 'textarea';
29
+ /** Visible rows; the editor picks a default when omitted. */
30
+ rows?: number;
31
+ }
32
+ /** A `YYYY-MM-DD` date input. */
33
+ export interface DateField extends FieldBase {
34
+ type: 'date';
35
+ }
36
+ /** A checkbox; absent means false. */
37
+ export interface BooleanField extends FieldBase {
38
+ type: 'boolean';
39
+ }
40
+ /** A closed-vocabulary tag set, rendered as checkboxes (ecnordic). */
41
+ export interface TagsField extends FieldBase {
42
+ type: 'tags';
43
+ /** The controlled vocabulary. */
44
+ options: readonly string[];
45
+ }
46
+ /** Free-form tags, edited as one comma-separated input (907). */
47
+ export interface FreeTagsField extends FieldBase {
48
+ type: 'freetags';
49
+ placeholder?: string;
50
+ }
51
+
52
+ /**
53
+ * The discriminated union the per-concept frontmatter form is generated from. Adding a
54
+ * field type is one variant here plus one decode arm in `frontmatterFromForm` and one in
55
+ * `validateFields`.
56
+ */
57
+ export type FrontmatterField =
58
+ | TextField
59
+ | TextareaField
60
+ | DateField
61
+ | BooleanField
62
+ | TagsField
63
+ | FreeTagsField;
64
+
65
+ /**
66
+ * A validator's verdict. On success it carries the normalized frontmatter to commit; on
67
+ * failure it carries field-keyed error messages (the empty key is a form-level error).
68
+ * Invalid input bounces to the form and never reaches git (spec §7.4).
69
+ */
70
+ export type ValidationResult =
71
+ | { ok: true; data: Record<string, unknown> }
72
+ | { ok: false; errors: Record<string, string> };
73
+
74
+ /**
75
+ * Per-site configuration for one content concept (spec §8). Concept-fixed behavior such as
76
+ * routability is not here; it lives in the engine's routing table (`CONCEPT_ROUTING`).
77
+ */
78
+ export interface ConceptConfig {
79
+ /** Repo-relative content directory, e.g. "src/content/posts". */
80
+ dir: string;
81
+ /** Sidebar label; defaults from the concept id when omitted. */
82
+ label?: string;
83
+ /** Drives the per-concept frontmatter form, in order. */
84
+ fields: FrontmatterField[];
85
+ /** Validate submitted frontmatter before any commit. */
86
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
87
+ }
88
+
89
+ /** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
90
+ export interface BackendConfig {
91
+ owner: string;
92
+ repo: string;
93
+ /** Commit target, e.g. "main". */
94
+ branch: string;
95
+ appId: string;
96
+ installationId: string;
97
+ }
98
+
99
+ /** Magic-link sender identity for Cloudflare Email Sending. */
100
+ export interface SenderConfig {
101
+ from: string;
102
+ replyTo?: string;
103
+ }
104
+
105
+ /** A git-committed YAML menu this site's nav editor manages (Plan 06). */
106
+ export interface NavMenuConfig {
107
+ /** Repo-relative path to the site-config YAML, e.g. "src/lib/site.config.yaml". */
108
+ configPath: string;
109
+ /** Key within the file's menus map, e.g. "primary". */
110
+ menuName: string;
111
+ /** Sidebar label for the menu. */
112
+ label: string;
113
+ /** Max nesting depth allowed in the editor; defaults to 2. */
114
+ maxDepth?: number;
115
+ }
116
+
117
+ /** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
118
+ export interface AssetConfig {
119
+ /** Repo-relative asset roots, e.g. ["static/images"]. */
120
+ roots: string[];
121
+ /** Public URL base, e.g. "/images". */
122
+ publicBase: string;
123
+ }
124
+
125
+ /** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
126
+ export interface CairnAdapter {
127
+ siteName: string;
128
+ /**
129
+ * Which content concepts this site enables. A future `fragments?` key attaches here with
130
+ * no reshape of the contract (seam 1). A site never has two of the same concept.
131
+ */
132
+ content: {
133
+ posts?: ConceptConfig;
134
+ pages?: ConceptConfig;
135
+ };
136
+ backend: BackendConfig;
137
+ sender: SenderConfig;
138
+ /** Design-accurate preview: the same render pipeline the site ships. */
139
+ renderPreview(md: string): string | Promise<string>;
140
+ /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
141
+ registry?: ComponentRegistry;
142
+ navMenu?: NavMenuConfig;
143
+ assets?: AssetConfig;
144
+ }
145
+
146
+ /**
147
+ * Concept-fixed routing for a normalized concept (spec §7.2). Posts are dated feed entries;
148
+ * pages are plain navigable structure. Not in adapter config.
149
+ */
150
+ export interface RoutingRule {
151
+ /** Routable as a standalone URL. A future Fragments concept is embedded, not routable. */
152
+ routable: boolean;
153
+ /** Carries a date (posts). */
154
+ dated: boolean;
155
+ /** Appears in feeds and the sitemap (posts). */
156
+ inFeeds: boolean;
157
+ }
158
+
159
+ /**
160
+ * The engine-internal, uniform view of one concept after normalization (seam 1). The admin
161
+ * nav, the list views, and the editor all read this, never the raw config.
162
+ */
163
+ export interface ConceptDescriptor {
164
+ /** Concept id, the key under `content`, e.g. "posts". */
165
+ id: string;
166
+ label: string;
167
+ dir: string;
168
+ routing: RoutingRule;
169
+ fields: FrontmatterField[];
170
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
171
+ }
172
+
173
+ /**
174
+ * A site-defined admin screen contributed by an extension (Mode 2). It gains a sidebar entry, the
175
+ * `/admin` guard, and the session, and may commit through the same GitHub pipeline. The dispatch
176
+ * route is built in Plan 09; the `load`/`actions`/`component` members are typed loosely here and
177
+ * tightened when the machinery lands.
178
+ */
179
+ export interface AdminPanel {
180
+ /** Routes under `/admin/<id>`; also the sidebar key. */
181
+ id: string;
182
+ /** Sidebar label. */
183
+ label: string;
184
+ /** Owner-gated, like editor management. */
185
+ owner?: boolean;
186
+ /** Server load, behind the guard. Typed in Plan 09. */
187
+ load?: (event: unknown) => unknown;
188
+ /** Named form actions, which may use the commit pipeline. Typed in Plan 09. */
189
+ actions?: Record<string, (event: unknown) => Promise<unknown>>;
190
+ /** The panel UI, rendered inside the admin shell. Typed as a component in Plan 09. */
191
+ component: unknown;
192
+ }
193
+
194
+ /**
195
+ * A custom frontmatter field type contributed by an extension (Mode 2): a renderer plus a validator
196
+ * dispatched alongside the built-in field union. The renderer and validator are typed in Plan 09
197
+ * when the form dispatch becomes a registry; the `type` key reserves the discriminator now.
198
+ */
199
+ export interface FieldTypeDef {
200
+ /** The field-type discriminator, e.g. "color". */
201
+ type: string;
202
+ }
203
+
204
+ /**
205
+ * A future build-time extension (seam 2). It folds in the same way the adapter does and
206
+ * contributes the same kinds of things. Reserved and unused in the rebuild; the shape is
207
+ * fixed now so the extension contract is additive later.
208
+ */
209
+ export interface CairnExtension {
210
+ /** Additional concepts, merged after the adapter's. */
211
+ content?: Record<string, ConceptConfig>;
212
+ /** Site-defined admin panels (Mode 2). Carried onto the runtime now; dispatched in Plan 09. */
213
+ adminPanels?: AdminPanel[];
214
+ /** Custom field types (Mode 2). Carried onto the runtime now; dispatched in Plan 09. */
215
+ fieldTypes?: FieldTypeDef[];
216
+ }
217
+
218
+ /**
219
+ * The composed runtime the engine serves from (seam 2 output). The single aggregation point
220
+ * (`composeRuntime`) folds the adapter and any extensions into this shape.
221
+ */
222
+ export interface CairnRuntime {
223
+ siteName: string;
224
+ concepts: ConceptDescriptor[];
225
+ backend: BackendConfig;
226
+ sender: SenderConfig;
227
+ renderPreview(md: string): string | Promise<string>;
228
+ registry?: ComponentRegistry;
229
+ navMenu?: NavMenuConfig;
230
+ assets?: AssetConfig;
231
+ /** Admin panels contributed by extensions (Mode 2). Empty until Plan 09 wires the dispatch route. */
232
+ adminPanels?: AdminPanel[];
233
+ /** Field types contributed by extensions (Mode 2). Empty until Plan 09 wires the form dispatch. */
234
+ fieldTypes?: FieldTypeDef[];
235
+ }
@@ -0,0 +1,51 @@
1
+ // cairn-cms: the field-driven baseline validator. A site's `validate` calls this for the
2
+ // required-and-coerce baseline, then layers any bespoke rules on top, so the per-site
3
+ // validator stays thin (engine-fat rule). Saving runs the concept's validator on the
4
+ // server before any commit; invalid input bounces to the form (spec §7.4).
5
+ import type { FrontmatterField, ValidationResult } from './types.js';
6
+ import { dateInputValue } from './frontmatter.js';
7
+
8
+ /**
9
+ * Validate raw frontmatter against a field list. Required text and date fields must be
10
+ * non-empty; required tag fields must be non-empty lists. Booleans coerce to `true`/`false`
11
+ * and tag fields to string arrays. Returns the normalized data, or field-keyed errors when
12
+ * any required field is empty.
13
+ *
14
+ * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
15
+ * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
16
+ * `Date` to `YYYY-MM-DD` so a valid parsed date is not mistaken for an empty one.
17
+ */
18
+ export function validateFields(
19
+ fields: FrontmatterField[],
20
+ frontmatter: Record<string, unknown>,
21
+ ): ValidationResult {
22
+ const data: Record<string, unknown> = {};
23
+ const errors: Record<string, string> = {};
24
+ for (const field of fields) {
25
+ const value = frontmatter[field.name];
26
+ switch (field.type) {
27
+ case 'boolean':
28
+ data[field.name] = value === true;
29
+ break;
30
+ case 'tags':
31
+ case 'freetags': {
32
+ const list = Array.isArray(value) ? value.map(String) : [];
33
+ if (field.required && list.length === 0) errors[field.name] = `${field.label} is required`;
34
+ data[field.name] = list;
35
+ break;
36
+ }
37
+ case 'date': {
38
+ const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
39
+ if (field.required && text === '') errors[field.name] = `${field.label} is required`;
40
+ data[field.name] = text;
41
+ break;
42
+ }
43
+ default: {
44
+ const text = typeof value === 'string' ? value.trim() : '';
45
+ if (field.required && text === '') errors[field.name] = `${field.label} is required`;
46
+ data[field.name] = text;
47
+ }
48
+ }
49
+ }
50
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, data };
51
+ }
package/src/lib/email.ts CHANGED
@@ -1,42 +1,56 @@
1
- // cairn-core: pluggable magic-link email sender.
2
- //
3
- // The default adapter targets Cloudflare Email Service (Email Sending, transactional,
4
- // arbitrary recipients), distinct from Email Routing's recipient-restricted `EmailMessage`
5
- // flow. Both share the same `send_email` binding (configured without a destination_address)
6
- // but use a different call shape: `binding.send({ to, from, ... })`.
7
- // Resend can slot in behind the same `sendMagicLink` signature if needed.
1
+ // The email boundary. The send is injected so tests capture links in a sink with no
2
+ // send_email binding; production passes `cloudflareSend`, which calls env.EMAIL.send
3
+ // (Cloudflare Email Sending, arbitrary recipients).
4
+ import type { AuthEnv } from './auth/types.js';
8
5
 
9
- /** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
10
- export interface EmailSender {
11
- send(message: {
12
- to: string;
13
- from: string;
14
- subject: string;
15
- text?: string;
16
- html?: string;
17
- }): Promise<{ messageId: string }>;
6
+ export type { AuthEnv };
7
+
8
+ /** The message a built magic-link email carries. */
9
+ export interface MagicLinkMessage {
10
+ to: string;
11
+ from: string;
12
+ subject: string;
13
+ html: string;
14
+ text: string;
15
+ }
16
+
17
+ /** Per-site identity for the magic-link email, sourced from the adapter. */
18
+ export interface AuthBranding {
19
+ siteName: string;
20
+ from: string;
21
+ replyTo?: string;
18
22
  }
19
23
 
20
- export async function sendMagicLink(
21
- sender: EmailSender,
22
- to: string,
23
- link: string,
24
- siteName: string,
25
- from: string,
26
- ): Promise<void> {
27
- const expiry = "This link expires in 10 minutes and works only once. If you didn't request it, ignore this email.";
28
- try {
29
- await sender.send({
30
- to,
31
- from,
32
- subject: `Your ${siteName} sign-in link`,
33
- text: `Sign in to ${siteName}:\n\n${link}\n\n${expiry}`,
34
- html: `<p>Sign in to ${siteName}:</p><p><a href="${link}">Confirm sign-in</a></p><p style="color:#666;font-size:0.9em">${expiry}</p>`,
35
- });
36
- } catch (err) {
37
- // H6: Email Sending is beta + the sole auth channel. Surface + audit; a Resend fallback
38
- // can slot in behind this same signature if Sending proves unreliable.
39
- console.error(`magic-link email send failed for ${to}:`, err);
40
- throw err;
41
- }
24
+ /** The injected send. Production uses `cloudflareSend`; tests pass a sink. */
25
+ export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
26
+
27
+ /** Escape the five HTML-significant characters. */
28
+ function escapeHtml(value: string): string {
29
+ return value
30
+ .replaceAll('&', '&amp;')
31
+ .replaceAll('<', '&lt;')
32
+ .replaceAll('>', '&gt;')
33
+ .replaceAll('"', '&quot;')
34
+ .replaceAll("'", '&#39;');
42
35
  }
36
+
37
+ /** Build the confirmation email. The link is the only action; the copy stays plain. */
38
+ export function buildMagicLinkMessage(input: {
39
+ to: string;
40
+ branding: AuthBranding;
41
+ link: string;
42
+ }): MagicLinkMessage {
43
+ const { to, branding, link } = input;
44
+ const subject = `Sign in to ${branding.siteName}`;
45
+ const text = `Open this link to sign in to ${branding.siteName}:\n\n${link}\n\nThe link expires in 10 minutes. If you did not request it, ignore this email.`;
46
+ // `link` is engine-built and url-safe; `siteName` is site config, so escape it for HTML.
47
+ const name = escapeHtml(branding.siteName);
48
+ const html = `<p>Open this link to sign in to ${name}:</p><p><a href="${link}">Sign in</a></p><p>The link expires in 10 minutes. If you did not request it, ignore this email.</p>`;
49
+ return { to, from: branding.from, subject, html, text };
50
+ }
51
+
52
+ /** The production send: Cloudflare Email Sending through the EMAIL binding. */
53
+ export const cloudflareSend: SendMagicLink = async (env, message) => {
54
+ if (!env.EMAIL) throw new Error('EMAIL binding is not configured');
55
+ await env.EMAIL.send(message);
56
+ };
package/src/lib/env.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { D1Database } from '@cloudflare/workers-types';
2
+
3
+ /**
4
+ * Returns the site's public origin from configuration.
5
+ *
6
+ * The origin is always config-derived, never read from a request header, so a
7
+ * forged Host header cannot redirect a magic link (spec 7.1, risk H3).
8
+ *
9
+ * @throws Error when `PUBLIC_ORIGIN` is unset or empty.
10
+ */
11
+ export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
12
+ const origin = env.PUBLIC_ORIGIN;
13
+ if (!origin) {
14
+ throw new Error('PUBLIC_ORIGIN is not configured');
15
+ }
16
+ return origin;
17
+ }
18
+
19
+ /**
20
+ * Returns the `AUTH_DB` binding, or throws a clear error when a site has not wired it.
21
+ *
22
+ * The handlers read D1 off `event.platform.env`; without this a misconfigured binding
23
+ * surfaces as a raw `TypeError` deep in a store call. This gives the failure a name.
24
+ *
25
+ * @throws Error when `AUTH_DB` is missing.
26
+ */
27
+ export function requireDb(env: { AUTH_DB?: D1Database }): D1Database {
28
+ if (!env.AUTH_DB) {
29
+ throw new Error('AUTH_DB binding is not configured');
30
+ }
31
+ return env.AUTH_DB;
32
+ }
@@ -0,0 +1,27 @@
1
+ // src/lib/github/credentials.ts
2
+ // cairn-cms: the bridge from the adapter's backend config and the Worker's secret to the
3
+ // App signer's input. One tested place owns the join and the missing-secret failure, so the
4
+ // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
5
+ // TypeError. Mirrors requireDb/requireOrigin in env.ts.
6
+ import type { BackendConfig } from '../content/types.js';
7
+ import type { AppCredentials } from './types.js';
8
+
9
+ /** The Worker secret holding the GitHub App private key: base64 of the PEM, single line. */
10
+ export interface GithubKeyEnv {
11
+ GITHUB_APP_PRIVATE_KEY_B64?: string;
12
+ }
13
+
14
+ /**
15
+ * Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
16
+ * installation) and the Worker's private-key secret. Throws when the secret is unset.
17
+ */
18
+ export function appCredentials(
19
+ backend: Pick<BackendConfig, 'appId' | 'installationId'>,
20
+ env: GithubKeyEnv,
21
+ ): AppCredentials {
22
+ const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
23
+ if (!privateKeyB64) {
24
+ throw new Error('GITHUB_APP_PRIVATE_KEY_B64 is not configured');
25
+ }
26
+ return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
27
+ }
@@ -0,0 +1,138 @@
1
+ // src/lib/github/repo.ts
2
+ // cairn-cms: repo reads and the commit, over the GitHub REST API. Listing a concept
3
+ // directory uses the Git Trees API (the contents API silently truncates at 1,000 entries,
4
+ // spec §7.3); a single-file read uses the contents API. An optional token lifts reads to
5
+ // the authenticated rate limit and unlocks private repos; ecnordic's repo is public, 907's
6
+ // is not.
7
+ import { idFromFilename } from '../content/ids.js';
8
+ import { CommitConflictError } from './types.js';
9
+ import type { CommitAuthor, RepoFile, RepoRef } from './types.js';
10
+
11
+ const API = 'https://api.github.com';
12
+
13
+ /** Standard GitHub API headers, with a bearer token when one is supplied. */
14
+ function ghHeaders(accept: string, token?: string): Record<string, string> {
15
+ const headers: Record<string, string> = {
16
+ Accept: accept,
17
+ 'User-Agent': 'cairn-cms',
18
+ 'X-GitHub-Api-Version': '2022-11-28',
19
+ };
20
+ if (token) headers.Authorization = `Bearer ${token}`;
21
+ return headers;
22
+ }
23
+
24
+ /** The recursive Git Trees API URL for the configured branch. */
25
+ export function treeUrl(repo: RepoRef): string {
26
+ return `${API}/repos/${repo.owner}/${repo.repo}/git/trees/${encodeURIComponent(repo.branch)}?recursive=1`;
27
+ }
28
+
29
+ /** A Git Trees API entry: a full repo path and whether it is a blob or a subtree. */
30
+ interface TreeEntry {
31
+ path: string;
32
+ type: string;
33
+ }
34
+
35
+ /** The basename of a repo path: the segment after the last slash. */
36
+ function basename(path: string): string {
37
+ return path.slice(path.lastIndexOf('/') + 1);
38
+ }
39
+
40
+ /**
41
+ * Markdown files directly in `dir`, newest id first. Tree entries carry full repo paths, so
42
+ * the directory prefix is stripped to a basename before deriving the id. Nested files, non
43
+ * markdown, and other directories are dropped.
44
+ */
45
+ export function markdownFilesIn(dir: string, tree: TreeEntry[]): RepoFile[] {
46
+ const clean = dir.replace(/^\/+|\/+$/g, '');
47
+ const prefix = `${clean}/`;
48
+ return tree
49
+ .filter((entry) => entry.type === 'blob' && entry.path.startsWith(prefix) && entry.path.endsWith('.md'))
50
+ .filter((entry) => !entry.path.slice(prefix.length).includes('/'))
51
+ .map((entry) => {
52
+ const name = basename(entry.path);
53
+ return { id: idFromFilename(name), name, path: entry.path };
54
+ })
55
+ .sort((a, b) => b.id.localeCompare(a.id));
56
+ }
57
+
58
+ /**
59
+ * List the markdown files in a concept directory through the Git Trees API. A truncated tree
60
+ * (GitHub caps the recursive listing near 100,000 entries) throws rather than returning a
61
+ * silent partial list; a concept directory sits far below that, and sharding is deferred
62
+ * until one approaches it (spec §7.3).
63
+ */
64
+ export async function listMarkdown(repo: RepoRef, dir: string, token?: string): Promise<RepoFile[]> {
65
+ const res = await fetch(treeUrl(repo), { headers: ghHeaders('application/vnd.github+json', token) });
66
+ if (!res.ok) throw new Error(`GitHub tree ${repo.branch} failed: ${res.status}`);
67
+ const body = (await res.json()) as { tree: TreeEntry[]; truncated: boolean };
68
+ if (body.truncated) throw new Error(`GitHub tree ${repo.branch} is truncated; ${dir} exceeds the listing cap`);
69
+ return markdownFilesIn(dir, body.tree);
70
+ }
71
+
72
+ /** The contents-API URL for a repo path, pinned to the configured branch. */
73
+ export function contentsUrl(repo: RepoRef, path: string): string {
74
+ const clean = path.replace(/^\/+|\/+$/g, '');
75
+ return `${API}/repos/${repo.owner}/${repo.repo}/contents/${clean}?ref=${encodeURIComponent(repo.branch)}`;
76
+ }
77
+
78
+ /**
79
+ * Fetch a file's raw markdown, or null if it does not exist. The contents API caps a raw
80
+ * read at 1 MB; a concept's files sit far below that, and sharding is deferred until one
81
+ * approaches it (spec §7.3).
82
+ */
83
+ export async function readRaw(repo: RepoRef, path: string, token?: string): Promise<string | null> {
84
+ const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github.raw', token) });
85
+ if (res.status === 404) return null;
86
+ if (!res.ok) throw new Error(`GitHub read ${path} failed: ${res.status}`);
87
+ return res.text();
88
+ }
89
+
90
+ /** Standard (padded) base64 of UTF-8 text, the form the contents API expects. */
91
+ function toBase64(text: string): string {
92
+ return btoa(Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b)).join(''));
93
+ }
94
+
95
+ /** The current blob sha for a path, or null if the file does not yet exist. */
96
+ export async function fileSha(repo: RepoRef, path: string, token: string): Promise<string | null> {
97
+ const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github+json', token) });
98
+ if (res.status === 404) return null;
99
+ if (!res.ok) throw new Error(`GitHub stat ${path} failed: ${res.status}`);
100
+ return ((await res.json()) as { sha: string }).sha;
101
+ }
102
+
103
+ /**
104
+ * Commit `content` to `path` on the configured branch through the contents API. The author is
105
+ * the editor; the committer is omitted, so GitHub attributes it to the App (`cairn-cms[bot]`).
106
+ * Updates the file in place when it exists (passing its sha), creates it otherwise. Returns the
107
+ * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`,
108
+ * so the save fails safe: re-fetch and ask the editor to reapply, never a merge.
109
+ *
110
+ * Caller preconditions this layer cannot enforce, and the save action (Plan 05) must:
111
+ * `path` is confined to the concept's configured directory (the App token can write anywhere
112
+ * in the repo, so an unvalidated path could overwrite CI config or source), and `author` is
113
+ * derived from the verified server-side session, never from request input.
114
+ */
115
+ export async function commitFile(
116
+ repo: RepoRef,
117
+ path: string,
118
+ content: string,
119
+ opts: { message: string; author: CommitAuthor },
120
+ token: string,
121
+ ): Promise<string> {
122
+ const sha = await fileSha(repo, path, token);
123
+ const url = `${API}/repos/${repo.owner}/${repo.repo}/contents/${path.replace(/^\/+|\/+$/g, '')}`;
124
+ const res = await fetch(url, {
125
+ method: 'PUT',
126
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ message: opts.message,
129
+ content: toBase64(content),
130
+ branch: repo.branch,
131
+ author: opts.author,
132
+ ...(sha ? { sha } : {}),
133
+ }),
134
+ });
135
+ if (res.status === 409) throw new CommitConflictError(path);
136
+ if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
137
+ return ((await res.json()) as { commit: { sha: string } }).commit.sha;
138
+ }
@@ -0,0 +1,97 @@
1
+ // src/lib/github/signing.ts
2
+ // cairn-cms: the GitHub App auth path. Mint an RS256 App JWT signed in-Worker with Web
3
+ // Crypto, exchange it for a short-lived installation access token, and self-test the
4
+ // brittle key conversion. GitHub issues PKCS#1 private keys and Web Crypto's importKey
5
+ // takes only PKCS#8, so the key is wrapped in-process. No octokit: it is heavy and pulls
6
+ // Node built-ins the Worker bundle should not carry.
7
+ import type { AppCredentials } from './types.js';
8
+
9
+ const API = 'https://api.github.com';
10
+ const encoder = new TextEncoder();
11
+
12
+ /** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT wire format. */
13
+ function bytesToB64url(bytes: Uint8Array): string {
14
+ const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
15
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
16
+ }
17
+
18
+ // TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies Web
19
+ // Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
20
+ function buf(bytes: Uint8Array): ArrayBuffer {
21
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
22
+ }
23
+
24
+ /** DER length octets for a value of `n` bytes (short form < 128, else long form). */
25
+ function derLength(n: number): number[] {
26
+ if (n < 0x80) return [n];
27
+ const out: number[] = [];
28
+ for (let v = n; v > 0; v >>= 8) out.unshift(v & 0xff);
29
+ return [0x80 | out.length, ...out];
30
+ }
31
+
32
+ // AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
33
+ const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
34
+
35
+ /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
36
+ function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array {
37
+ const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
38
+ const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
39
+ return Uint8Array.from([0x30, ...derLength(body.length), ...body]);
40
+ }
41
+
42
+ /** Decode a PEM private key to PKCS#8 DER, converting from PKCS#1 (GitHub's format) if needed. */
43
+ function pemToPkcs8(pem: string): Uint8Array {
44
+ const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
45
+ const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
46
+ return pem.includes('RSA PRIVATE KEY') ? pkcs1ToPkcs8(der) : der;
47
+ }
48
+
49
+ /** Mint a GitHub App JWT (RS256), valid ~9 min, with `iat` backdated for clock skew. */
50
+ export async function appJwt(appId: string, privateKeyPem: string): Promise<string> {
51
+ const now = Math.floor(Date.now() / 1000);
52
+ const header = bytesToB64url(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
53
+ const payload = bytesToB64url(encoder.encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })));
54
+ const signingInput = `${header}.${payload}`;
55
+ const key = await crypto.subtle.importKey(
56
+ 'pkcs8',
57
+ buf(pemToPkcs8(privateKeyPem)),
58
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
59
+ false,
60
+ ['sign'],
61
+ );
62
+ const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, buf(encoder.encode(signingInput)));
63
+ return `${signingInput}.${bytesToB64url(new Uint8Array(sig))}`;
64
+ }
65
+
66
+ /** Exchange the App JWT for a short-lived installation access token. */
67
+ export async function installationToken(creds: AppCredentials): Promise<string> {
68
+ const jwt = await appJwt(creds.appId, atob(creds.privateKeyB64));
69
+ const res = await fetch(`${API}/app/installations/${creds.installationId}/access_tokens`, {
70
+ method: 'POST',
71
+ headers: {
72
+ Accept: 'application/vnd.github+json',
73
+ Authorization: `Bearer ${jwt}`,
74
+ 'User-Agent': 'cairn-cms',
75
+ 'X-GitHub-Api-Version': '2022-11-28',
76
+ },
77
+ });
78
+ if (!res.ok) throw new Error(`GitHub installation token failed: ${res.status}`);
79
+ return ((await res.json()) as { token: string }).token;
80
+ }
81
+
82
+ /**
83
+ * Deploy-time self-test for the App signer: sign a dummy JWT with the configured key. It
84
+ * exercises the brittle PKCS#1-to-PKCS#8 conversion and the Web Crypto import and sign with
85
+ * no network call and no secret in the result, so `/admin/healthz` (Plan 05) catches a bad
86
+ * or rotated key before an editor's save fails. The `detail` is a fixed classifier, never the
87
+ * raw crypto error, so the surfaced health result cannot echo key bytes. Never throws.
88
+ */
89
+ export async function signingSelfTest(appId: string, privateKeyB64: string): Promise<{ ok: boolean; detail?: string }> {
90
+ try {
91
+ const jwt = await appJwt(appId, atob(privateKeyB64));
92
+ if (jwt.split('.').length !== 3) return { ok: false, detail: 'malformed JWT' };
93
+ return { ok: true };
94
+ } catch {
95
+ return { ok: false, detail: 'key import or sign failed' };
96
+ }
97
+ }