@glw907/cairn-cms 0.5.0 → 0.6.0-rc.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 (216) 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 -108
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -9
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +50 -0
  14. package/dist/components/ComponentPalette.svelte.d.ts +16 -0
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
  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 +160 -103
  23. package/dist/components/EditPage.svelte.d.ts +17 -7
  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 +24 -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 +138 -0
  35. package/dist/components/NavTree.svelte.d.ts +17 -0
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -0
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +5 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +7 -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 -6
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -8
  80. package/dist/nav/site-config.d.ts +50 -0
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/nav/site-config.js +100 -0
  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 +3 -3
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +4 -4
  91. package/dist/render/registry.d.ts +6 -4
  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 +1 -1
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/remark-directives.d.ts +1 -1
  97. package/dist/render/remark-directives.d.ts.map +1 -1
  98. package/dist/render/sanitize.d.ts +8 -0
  99. package/dist/render/sanitize.d.ts.map +1 -0
  100. package/dist/render/sanitize.js +26 -0
  101. package/dist/sveltekit/auth-routes.d.ts +23 -0
  102. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  103. package/dist/sveltekit/auth-routes.js +85 -0
  104. package/dist/sveltekit/content-routes.d.ts +80 -0
  105. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  106. package/dist/sveltekit/content-routes.js +183 -0
  107. package/dist/sveltekit/editors-routes.d.ts +24 -0
  108. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  109. package/dist/sveltekit/editors-routes.js +73 -0
  110. package/dist/sveltekit/guard.d.ts +9 -0
  111. package/dist/sveltekit/guard.d.ts.map +1 -0
  112. package/dist/sveltekit/guard.js +43 -0
  113. package/dist/sveltekit/health.d.ts +19 -0
  114. package/dist/sveltekit/health.d.ts.map +1 -0
  115. package/dist/sveltekit/health.js +12 -0
  116. package/dist/sveltekit/index.d.ts +9 -83
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -149
  119. package/dist/sveltekit/nav-routes.d.ts +30 -0
  120. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  121. package/dist/sveltekit/nav-routes.js +103 -0
  122. package/dist/sveltekit/types.d.ts +32 -0
  123. package/dist/sveltekit/types.d.ts.map +1 -0
  124. package/dist/sveltekit/types.js +1 -0
  125. package/package.json +38 -58
  126. package/src/lib/auth/crypto.ts +37 -0
  127. package/src/lib/auth/store.ts +158 -0
  128. package/src/lib/auth/types.ts +27 -0
  129. package/src/lib/components/AdminLayout.svelte +58 -108
  130. package/src/lib/components/ComponentPalette.svelte +50 -0
  131. package/src/lib/components/ConceptList.svelte +81 -0
  132. package/src/lib/components/ConfirmPage.svelte +23 -20
  133. package/src/lib/components/EditPage.svelte +160 -103
  134. package/src/lib/components/LoginPage.svelte +42 -52
  135. package/src/lib/components/ManageEditors.svelte +81 -0
  136. package/src/lib/components/MarkdownEditor.svelte +81 -0
  137. package/src/lib/components/NavTree.svelte +138 -0
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +7 -4
  140. package/src/lib/content/compose.ts +39 -0
  141. package/src/lib/content/concepts.ts +57 -0
  142. package/src/lib/content/frontmatter.ts +71 -0
  143. package/src/lib/content/ids.ts +38 -0
  144. package/src/lib/content/types.ts +235 -0
  145. package/src/lib/content/validate.ts +51 -0
  146. package/src/lib/email.ts +52 -38
  147. package/src/lib/env.ts +32 -0
  148. package/src/lib/github/credentials.ts +27 -0
  149. package/src/lib/github/repo.ts +138 -0
  150. package/src/lib/github/signing.ts +97 -0
  151. package/src/lib/github/types.ts +46 -0
  152. package/src/lib/index.ts +86 -8
  153. package/src/lib/nav/site-config.ts +124 -0
  154. package/src/lib/render/glyph.ts +6 -6
  155. package/src/lib/render/index.ts +6 -6
  156. package/src/lib/render/pipeline.ts +22 -22
  157. package/src/lib/render/registry.ts +33 -26
  158. package/src/lib/render/rehype-dispatch.ts +47 -47
  159. package/src/lib/render/remark-directives.ts +46 -46
  160. package/src/lib/render/sanitize.ts +27 -0
  161. package/src/lib/sveltekit/auth-routes.ts +107 -0
  162. package/src/lib/sveltekit/content-routes.ts +261 -0
  163. package/src/lib/sveltekit/editors-routes.ts +82 -0
  164. package/src/lib/sveltekit/guard.ts +47 -0
  165. package/src/lib/sveltekit/health.ts +24 -0
  166. package/src/lib/sveltekit/index.ts +19 -235
  167. package/src/lib/sveltekit/nav-routes.ts +139 -0
  168. package/src/lib/sveltekit/types.ts +33 -0
  169. package/dist/adapter.d.ts +0 -69
  170. package/dist/adapter.d.ts.map +0 -1
  171. package/dist/adapter.js +0 -30
  172. package/dist/auth/admins.d.ts +0 -33
  173. package/dist/auth/admins.d.ts.map +0 -1
  174. package/dist/auth/admins.js +0 -90
  175. package/dist/auth/config.d.ts +0 -2097
  176. package/dist/auth/config.d.ts.map +0 -1
  177. package/dist/auth/config.js +0 -78
  178. package/dist/auth/guard.d.ts +0 -34
  179. package/dist/auth/guard.d.ts.map +0 -1
  180. package/dist/auth/guard.js +0 -47
  181. package/dist/auth/index.d.ts +0 -4
  182. package/dist/auth/index.d.ts.map +0 -1
  183. package/dist/auth/index.js +0 -6
  184. package/dist/auth/schema.d.ts +0 -750
  185. package/dist/auth/schema.d.ts.map +0 -1
  186. package/dist/auth/schema.js +0 -93
  187. package/dist/carta.d.ts +0 -39
  188. package/dist/carta.d.ts.map +0 -1
  189. package/dist/carta.js +0 -30
  190. package/dist/components/AdminList.svelte +0 -33
  191. package/dist/components/AdminList.svelte.d.ts +0 -10
  192. package/dist/components/AdminList.svelte.d.ts.map +0 -1
  193. package/dist/components/ManageAdmins.svelte +0 -84
  194. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  195. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  196. package/dist/content.d.ts +0 -3
  197. package/dist/content.d.ts.map +0 -1
  198. package/dist/content.js +0 -10
  199. package/dist/github.d.ts +0 -72
  200. package/dist/github.d.ts.map +0 -1
  201. package/dist/github.js +0 -171
  202. package/dist/utils.d.ts +0 -3
  203. package/dist/utils.d.ts.map +0 -1
  204. package/dist/utils.js +0 -11
  205. package/src/lib/adapter.ts +0 -119
  206. package/src/lib/auth/admins.ts +0 -106
  207. package/src/lib/auth/config.ts +0 -108
  208. package/src/lib/auth/guard.ts +0 -60
  209. package/src/lib/auth/index.ts +0 -6
  210. package/src/lib/auth/schema.ts +0 -112
  211. package/src/lib/carta.ts +0 -59
  212. package/src/lib/components/AdminList.svelte +0 -33
  213. package/src/lib/components/ManageAdmins.svelte +0 -84
  214. package/src/lib/content.ts +0 -11
  215. package/src/lib/github.ts +0 -220
  216. package/src/lib/utils.ts +0 -12
@@ -1,235 +1,19 @@
1
- // cairn-core: the SvelteKit content-route server logic, extracted so each site's `admin/**`
2
- // route files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
3
- //
4
- // SvelteKit's filesystem routing requires the route *files* to live in each site's
5
- // `src/routes/`, but their bodies are identical across sites. Only the adapter differs.
6
- // These functions take the SvelteKit event (typed structurally, to avoid depending on the
7
- // site-generated `App.*` ambient types) plus the site `CairnAdapter`, and throw
8
- // `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
9
- // class identity with the host's runtime; otherwise the redirect 500s). Auth/session/manage-editors
10
- // logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
11
- import { redirect, error } from '@sveltejs/kit';
12
- import matter from 'gray-matter';
13
- import type { CairnUser } from '../auth/guard';
14
- import {
15
- listMarkdown,
16
- readRaw,
17
- commitFile,
18
- installationToken,
19
- signingSelfTest,
20
- CommitConflictError,
21
- type RepoFile,
22
- } from '../github';
23
- import { serializeMarkdown } from '../content';
24
- import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
25
-
26
- /** The `platform.env` bindings the content routes read. All optional; the handlers guard. */
27
- export interface AdminEnv {
28
- GITHUB_APP_ID?: string;
29
- GITHUB_APP_INSTALLATION_ID?: string;
30
- GITHUB_APP_PRIVATE_KEY_B64?: string;
31
- }
32
-
33
- interface PlatformEvent {
34
- platform?: { env?: AdminEnv };
35
- }
36
-
37
- /**
38
- * Mint a GitHub App installation token for *reads* when the App is configured, else undefined
39
- * (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
40
- * reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
41
- * A mint failure degrades gracefully to anonymous rather than 500ing. Unlike the commit path,
42
- * where a missing App is fatal, a read can still succeed unauthenticated.
43
- */
44
- async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
45
- if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
46
- return undefined;
47
- }
48
- try {
49
- return await installationToken({
50
- appId: env.GITHUB_APP_ID,
51
- installationId: env.GITHUB_APP_INSTALLATION_ID,
52
- privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
53
- });
54
- } catch (err) {
55
- console.error('read token mint failed; falling back to anonymous read:', err);
56
- return undefined;
57
- }
58
- }
59
-
60
- // ── /admin layout ──────────────────────────────────────────────────────────
61
-
62
- export interface AdminLayoutData {
63
- user: CairnUser | null;
64
- siteName: string;
65
- pathname: string;
66
- }
67
-
68
- /**
69
- * Branding + session for every admin page. `siteName` flows from the adapter without pulling
70
- * its plugin graph into client bundles; the import stays server-side in the layout load.
71
- * `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
72
- * (those kit virtual modules have no types outside a kit app, so they can't live in the
73
- * package); reading `event.url` here also opts the layout load into rerunning on navigation.
74
- */
75
- export function adminLayoutLoad(
76
- event: { locals: { user: CairnUser | null }; url: URL },
77
- adapter: CairnAdapter,
78
- ): AdminLayoutData {
79
- return { user: event.locals.user, siteName: adapter.siteName, pathname: event.url.pathname };
80
- }
81
-
82
- // ── /admin (content list) ────────────────────────────────────────────────────
83
-
84
- export interface AdminCollectionList {
85
- type: string;
86
- label: string;
87
- files: RepoFile[];
88
- error?: string;
89
- }
90
-
91
- /** List every collection's markdown files. A failed listing degrades to an inline error. */
92
- export async function adminListLoad(
93
- event: PlatformEvent,
94
- adapter: CairnAdapter,
95
- ): Promise<{ collections: AdminCollectionList[] }> {
96
- const token = await readToken(event.platform?.env);
97
- const collections = await Promise.all(
98
- adapter.collections.map(async ({ type, label, dir }): Promise<AdminCollectionList> => {
99
- try {
100
- return { type, label, files: await listMarkdown(adapter.backend, dir, token) };
101
- } catch (err) {
102
- // A failed listing (rate limit, network) shouldn't 500 the whole admin.
103
- return { type, label, files: [], error: err instanceof Error ? err.message : 'Failed to load' };
104
- }
105
- }),
106
- );
107
- return { collections };
108
- }
109
-
110
- // ── /admin/edit/[type]/[id] ─────────────────────────────────────────────────
111
-
112
- export interface EditData {
113
- type: string;
114
- id: string;
115
- label: string;
116
- fields: CairnField[];
117
- path: string;
118
- body: string;
119
- frontmatter: Record<string, unknown>;
120
- title: string;
121
- saved: boolean;
122
- error: string | null;
123
- }
124
-
125
- export async function editLoad(
126
- event: PlatformEvent & { params: { type: string; id: string }; url: URL },
127
- adapter: CairnAdapter,
128
- ): Promise<EditData> {
129
- const collection = findCollection(adapter, event.params.type);
130
- if (!collection) throw error(404, 'Unknown collection');
131
-
132
- const token = await readToken(event.platform?.env);
133
- const path = `${collection.dir}/${event.params.id}.md`;
134
- const raw = await readRaw(adapter.backend, path, token);
135
- if (raw === null) throw error(404, 'Content not found');
136
-
137
- // Split frontmatter from body server-side; the editor form binds to the frontmatter and
138
- // the Carta editor binds to the body, and /admin/save reassembles them on commit.
139
- const { data: frontmatter, content: body } = matter(raw);
140
-
141
- return {
142
- type: event.params.type,
143
- id: event.params.id,
144
- label: collection.label,
145
- fields: collection.fields,
146
- path,
147
- body,
148
- frontmatter,
149
- title: typeof frontmatter.title === 'string' ? frontmatter.title : event.params.id,
150
- saved: event.url.searchParams.get('saved') === '1',
151
- error: event.url.searchParams.get('error'),
152
- };
153
- }
154
-
155
- // ── /admin/save (POST) ──────────────────────────────────────────────────────
156
-
157
- export async function saveCommit(
158
- event: PlatformEvent & { request: Request; locals: { user: CairnUser | null } },
159
- adapter: CairnAdapter,
160
- ): Promise<never> {
161
- const user = event.locals.user;
162
- if (!user) throw error(401, 'Not signed in');
163
-
164
- const env = event.platform?.env;
165
- if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
166
- throw error(500, 'GitHub App is not configured');
167
- }
168
-
169
- const form = await event.request.formData();
170
- const type = String(form.get('type') ?? '');
171
- const id = String(form.get('id') ?? '');
172
- const body = String(form.get('body') ?? '');
173
- const collection = findCollection(adapter, type);
174
- if (!collection || !id) throw error(400, 'Bad request');
175
-
176
- // Build frontmatter from the posted fields and validate against the collection's schema; a
177
- // bad field bounces back to the editor with the validator's message rather than 500ing.
178
- let frontmatter: object;
179
- try {
180
- frontmatter = collection.validate(frontmatterFromForm(collection, form), `${id}.md`);
181
- } catch (err) {
182
- const message = err instanceof Error ? err.message : 'Invalid frontmatter';
183
- throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
184
- }
185
-
186
- const markdown = serializeMarkdown(frontmatter, body);
187
- const token = await installationToken({
188
- appId: env.GITHUB_APP_ID,
189
- installationId: env.GITHUB_APP_INSTALLATION_ID,
190
- privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
191
- });
192
-
193
- try {
194
- await commitFile(
195
- adapter.backend,
196
- `${collection.dir}/${id}.md`,
197
- markdown,
198
- { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
199
- token,
200
- );
201
- } catch (err) {
202
- // Concurrent-edit 409 (C3): fail safe. Bounce back with a reload prompt; the editor reloads
203
- // the current version and reapplies. Any other error is unexpected, so rethrow.
204
- if (err instanceof CommitConflictError) {
205
- const message = 'This file changed since you opened it. Reload and reapply your edits.';
206
- throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
207
- }
208
- throw err;
209
- }
210
-
211
- throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
212
- }
213
-
214
- // ── /admin/healthz (GET) ──────────────────────────────────────────────────────
215
-
216
- export interface HealthData {
217
- ok: boolean;
218
- checks: { githubAppSigning: { ok: boolean; detail?: string } };
219
- }
220
-
221
- /**
222
- * Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
223
- * the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
224
- * `/admin` guard (signed-in editors only); returns ok/fail with no secret in the body.
225
- */
226
- export async function healthLoad(event: PlatformEvent): Promise<HealthData> {
227
- const env = event.platform?.env;
228
- let githubAppSigning: { ok: boolean; detail?: string };
229
- if (env?.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY_B64) {
230
- githubAppSigning = await signingSelfTest(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY_B64);
231
- } else {
232
- githubAppSigning = { ok: false, detail: 'GitHub App not configured' };
233
- }
234
- return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
235
- }
1
+ // SvelteKit server logic consumed by site route shims: the guard plus the auth, editor,
2
+ // content, and health route factories and functions.
3
+ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
4
+ export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
5
+ export { createEditorRoutes } from './editors-routes.js';
6
+ export { createContentRoutes } from './content-routes.js';
7
+ export type {
8
+ NavConcept,
9
+ LayoutData,
10
+ EntrySummary,
11
+ ListData,
12
+ EditData,
13
+ ContentEvent,
14
+ ContentRoutesDeps,
15
+ } from './content-routes.js';
16
+ export { createNavRoutes } from './nav-routes.js';
17
+ export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
18
+ export { healthLoad, type HealthData } from './health.js';
19
+ export type { RequestContext, CookieJar, HandleInput } from './types.js';
@@ -0,0 +1,139 @@
1
+ // The admin nav-editing routes: the load and save a site's /admin/nav shim calls. A factory closes
2
+ // over the composed runtime and the GitHub token mint, mirroring createContentRoutes, so the read
3
+ // and commit paths are unit-testable against a fetch double with an injected token.
4
+ import { redirect, error } from '@sveltejs/kit';
5
+ import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
6
+ import { installationToken } from '../github/signing.js';
7
+ import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
+ import { CommitConflictError } from '../github/types.js';
9
+ import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
10
+ import type { CairnRuntime } from '../content/types.js';
11
+ import type { ContentEvent } from './content-routes.js';
12
+ import type { Editor } from '../auth/types.js';
13
+
14
+ /** One page option for the URL picker datalist. */
15
+ export interface NavPageOption {
16
+ label: string;
17
+ url: string;
18
+ }
19
+
20
+ /** The nav editor's load data: the menu meta, the current tree, page options, and flags. */
21
+ export interface NavLoadData {
22
+ menu: { name: string; label: string; maxDepth: number };
23
+ tree: NavNode[];
24
+ pages: NavPageOption[];
25
+ saved: boolean;
26
+ error: string | null;
27
+ }
28
+
29
+ /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
30
+ export interface NavRoutesDeps {
31
+ mintToken?: (env: GithubKeyEnv) => Promise<string>;
32
+ }
33
+
34
+ /** The signed-in editor the guard resolved, or a login redirect. */
35
+ function sessionOf(event: ContentEvent): Editor {
36
+ const editor = event.locals.editor;
37
+ if (!editor) throw redirect(303, '/admin/login');
38
+ return editor;
39
+ }
40
+
41
+ /** Match a commit conflict by class and by name (bundling can alias the class identity). */
42
+ function isConflict(err: unknown): boolean {
43
+ return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
44
+ }
45
+
46
+ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
47
+ const mintToken =
48
+ deps.mintToken ?? ((env: GithubKeyEnv) => installationToken(appCredentials(runtime.backend, env)));
49
+
50
+ /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
51
+ async function pageOptions(token: string): Promise<NavPageOption[]> {
52
+ const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
53
+ const lists = await Promise.all(
54
+ pageConcepts.map(async (c) => {
55
+ try {
56
+ const files = await listMarkdown(runtime.backend, c.dir, token);
57
+ return files.map((f): NavPageOption => ({ label: f.id, url: `/${f.id}` }));
58
+ } catch {
59
+ return [];
60
+ }
61
+ }),
62
+ );
63
+ return lists.flat();
64
+ }
65
+
66
+ /** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
67
+ async function navLoad(event: ContentEvent): Promise<NavLoadData> {
68
+ sessionOf(event);
69
+ const config = runtime.navMenu;
70
+ if (!config) throw error(404, 'No navigation menu configured');
71
+ const maxDepth = config.maxDepth ?? 2;
72
+ const menu = { name: config.menuName, label: config.label, maxDepth };
73
+
74
+ let token: string;
75
+ try {
76
+ token = await mintToken(event.platform?.env ?? {});
77
+ } catch {
78
+ return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
79
+ }
80
+
81
+ let tree: NavNode[] = [];
82
+ try {
83
+ const raw = await readRaw(runtime.backend, config.configPath, token);
84
+ if (raw !== null) tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
85
+ } catch {
86
+ // A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
87
+ tree = [];
88
+ }
89
+
90
+ return {
91
+ menu,
92
+ tree,
93
+ pages: await pageOptions(token),
94
+ saved: event.url.searchParams.get('saved') === '1',
95
+ error: event.url.searchParams.get('error'),
96
+ };
97
+ }
98
+
99
+ /** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
100
+ async function navSave(event: ContentEvent): Promise<never> {
101
+ const editor = sessionOf(event);
102
+ const config = runtime.navMenu;
103
+ if (!config) throw error(404, 'No navigation menu configured');
104
+ const maxDepth = config.maxDepth ?? 2;
105
+
106
+ const form = await event.request.formData();
107
+ let tree: NavNode[];
108
+ try {
109
+ tree = validateNavTree(JSON.parse(String(form.get('tree') ?? '[]')), maxDepth);
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : 'Invalid navigation';
112
+ throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
113
+ }
114
+
115
+ const token = await mintToken(event.platform?.env ?? {});
116
+ const raw = await readRaw(runtime.backend, config.configPath, token);
117
+ if (raw === null) throw error(404, 'Site config not found');
118
+
119
+ try {
120
+ await commitFile(
121
+ runtime.backend,
122
+ config.configPath,
123
+ setMenu(raw, config.menuName, tree),
124
+ { message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } },
125
+ token,
126
+ );
127
+ } catch (err) {
128
+ if (isConflict(err)) {
129
+ const message = 'The site config changed since you opened it. Reload and reapply your edits.';
130
+ throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
131
+ }
132
+ throw err;
133
+ }
134
+
135
+ throw redirect(303, '/admin/nav?saved=1');
136
+ }
137
+
138
+ return { navLoad, navSave };
139
+ }
@@ -0,0 +1,33 @@
1
+ // Structural subsets of SvelteKit's RequestEvent. A site passes its real event, which has
2
+ // these and more, so the engine never imports a site's generated App.* ambient types.
3
+ import type { AuthEnv, Editor } from '../auth/types.js';
4
+
5
+ export interface CookieSetOptions {
6
+ path: string;
7
+ httpOnly?: boolean;
8
+ secure?: boolean;
9
+ sameSite?: 'lax' | 'strict' | 'none';
10
+ maxAge?: number;
11
+ }
12
+
13
+ export interface CookieJar {
14
+ get(name: string): string | undefined;
15
+ set(name: string, value: string, opts: CookieSetOptions): void;
16
+ delete(name: string, opts: { path: string }): void;
17
+ }
18
+
19
+ export interface RequestContext {
20
+ url: URL;
21
+ request: Request;
22
+ cookies: CookieJar;
23
+ locals: { editor?: Editor | null };
24
+ platform?: { env?: AuthEnv };
25
+ // Required so a site cannot silently drop the confirm page's Referrer-Policy header
26
+ // (spec 7.1). A real SvelteKit RequestEvent always supplies it.
27
+ setHeaders(headers: Record<string, string>): void;
28
+ }
29
+
30
+ export interface HandleInput {
31
+ event: RequestContext;
32
+ resolve(event: RequestContext): Promise<Response> | Response;
33
+ }
package/dist/adapter.d.ts DELETED
@@ -1,69 +0,0 @@
1
- import type { PreviewPlugins } from './carta';
2
- import type { RepoRef } from './github';
3
- import type { ComponentRegistry } from './render';
4
- interface FieldBase {
5
- /** Frontmatter key and form input name. */
6
- name: string;
7
- label: string;
8
- required?: boolean;
9
- }
10
- export interface TextField extends FieldBase {
11
- type: 'text';
12
- }
13
- export interface DateField extends FieldBase {
14
- type: 'date';
15
- }
16
- export interface TextareaField extends FieldBase {
17
- type: 'textarea';
18
- rows?: number;
19
- }
20
- export interface BooleanField extends FieldBase {
21
- type: 'boolean';
22
- }
23
- export interface TagsField extends FieldBase {
24
- type: 'tags';
25
- /** Controlled vocabulary rendered as checkboxes. */
26
- options: readonly string[];
27
- }
28
- export interface FreeTagsField extends FieldBase {
29
- type: 'freetags';
30
- /** Free-form tags, edited as one comma-separated text input (no controlled vocabulary). */
31
- placeholder?: string;
32
- }
33
- export type CairnField = TextField | DateField | TextareaField | BooleanField | TagsField | FreeTagsField;
34
- export interface CairnCollection {
35
- /** Route `[type]` segment and list key, e.g. `posts`. */
36
- type: string;
37
- label: string;
38
- /** Repo-relative folder holding the collection's markdown files. */
39
- dir: string;
40
- /** Editor form fields, rendered in order. */
41
- fields: CairnField[];
42
- /** Validate raw frontmatter (from the form) into the on-disk object, throwing on error. */
43
- validate(data: Record<string, unknown>, source: string): object;
44
- }
45
- export interface CairnAdapter {
46
- /** Branding + magic-link email copy. */
47
- siteName: string;
48
- /** From: address for magic-link email (must be a domain-authenticated sender). */
49
- sender: string;
50
- /** The repository the admin reads content from and commits to. */
51
- backend: RepoRef;
52
- /** Site plugin set for the Carta preview (parity with the live render). */
53
- preview: PreviewPlugins;
54
- collections: CairnCollection[];
55
- /**
56
- * The site's component registry: the single declaration of its directive
57
- * components (R10a). Rendering parity already flows through `preview`; this
58
- * exposes the same registry so the editor's insert-component palette can read
59
- * `registry.defs`. Optional: a site with no rich components (e.g. 907.life) may
60
- * omit it or supply an empty registry.
61
- */
62
- registry?: ComponentRegistry;
63
- }
64
- /** Look up a collection by its route segment, or undefined if the segment is unknown. */
65
- export declare function findCollection(adapter: CairnAdapter, type: string): CairnCollection | undefined;
66
- /** Read raw frontmatter from a submitted form, decoding each value per its field type. */
67
- export declare function frontmatterFromForm(collection: CairnCollection, form: FormData): Record<string, unknown>;
68
- export {};
69
- //# sourceMappingURL=adapter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/lib/adapter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,UAAU,SAAS;IACjB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AACD,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AACD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AACD,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,IAAI,EAAE,SAAS,CAAC;CACjB;AACD,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AACD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,SAAS,GACT,aAAa,GACb,YAAY,GACZ,SAAS,GACT,aAAa,CAAC;AAElB,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAC7C,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;CACjE;AAED,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAE/F;AAED,0FAA0F;AAC1F,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,eAAe,EAC3B,IAAI,EAAE,QAAQ,GACb,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA0BzB"}
package/dist/adapter.js DELETED
@@ -1,30 +0,0 @@
1
- /** Look up a collection by its route segment, or undefined if the segment is unknown. */
2
- export function findCollection(adapter, type) {
3
- return adapter.collections.find((collection) => collection.type === type);
4
- }
5
- /** Read raw frontmatter from a submitted form, decoding each value per its field type. */
6
- export function frontmatterFromForm(collection, form) {
7
- const data = {};
8
- for (const field of collection.fields) {
9
- switch (field.type) {
10
- case 'boolean':
11
- data[field.name] = form.get(field.name) === 'on';
12
- break;
13
- case 'tags':
14
- data[field.name] = form.getAll(field.name).map(String);
15
- break;
16
- case 'freetags':
17
- // One comma-separated input → trimmed, de-duplicated, non-empty tags.
18
- data[field.name] = [
19
- ...new Set(String(form.get(field.name) ?? '')
20
- .split(',')
21
- .map((tag) => tag.trim())
22
- .filter(Boolean)),
23
- ];
24
- break;
25
- default:
26
- data[field.name] = form.get(field.name);
27
- }
28
- }
29
- return data;
30
- }
@@ -1,33 +0,0 @@
1
- import type { Auth } from './config';
2
- import type { CairnUser } from './guard';
3
- export interface AdminsData {
4
- admins: CairnUser[];
5
- /** Acting owner's email, so the UI can disable self-targeted remove/demote. */
6
- self: string;
7
- saved: boolean;
8
- error: string | null;
9
- }
10
- /**
11
- * The privilege-escalation gate. better-auth's admin API also enforces this server-side (only
12
- * `owner` holds the admin statements), but checking `locals.user` here gives clean redirect/403
13
- * UX and lets the mutations guard self-lockout before calling the API. Returns the acting owner.
14
- */
15
- export declare function requireOwner(user: CairnUser | null): CairnUser;
16
- type Ev = {
17
- locals: {
18
- auth: Auth;
19
- user: CairnUser | null;
20
- };
21
- request: Request;
22
- url: URL;
23
- };
24
- /** List the allowlist for the manage-editors page. Owner-only. */
25
- export declare function adminsLoad(event: Ev): Promise<AdminsData>;
26
- /** Add an editor (create the user). Owner-only. */
27
- export declare function addAdmin(event: Ev): Promise<never>;
28
- /** Remove an editor (delete the user). Owner-only; owners can't remove themselves (anti-lockout). */
29
- export declare function removeAdmin(event: Ev): Promise<never>;
30
- /** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
31
- export declare function setAdminRole(event: Ev): Promise<never>;
32
- export {};
33
- //# sourceMappingURL=admins.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"admins.d.ts","sourceRoot":"","sources":["../../src/lib/auth/admins.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,GAAG,SAAS,CAI9D;AAED,KAAK,EAAE,GAAG;IAAE,MAAM,EAAE;QAAE,IAAI,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;KAAE,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,CAAC;AAgBzF,kEAAkE;AAClE,wBAAsB,UAAU,CAAC,KAAK,EAAE,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAa/D;AAED,mDAAmD;AACnD,wBAAsB,QAAQ,CAAC,KAAK,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAYxD;AAED,qGAAqG;AACrG,wBAAsB,WAAW,CAAC,KAAK,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAW3D;AAED,0FAA0F;AAC1F,wBAAsB,YAAY,CAAC,KAAK,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAc5D"}
@@ -1,90 +0,0 @@
1
- // cairn-core: owner-gated editor management, on better-auth's admin API. The `user` table IS
2
- // the allowlist (disableSignUp ⇒ only listed emails can sign in), so add/remove editor = create/
3
- // remove user; role flips go through the admin plugin's access-control roles (owner/editor).
4
- // These run as SvelteKit form actions; each verifies the acting user is an owner first.
5
- import { redirect, error } from '@sveltejs/kit';
6
- const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
7
- /**
8
- * The privilege-escalation gate. better-auth's admin API also enforces this server-side (only
9
- * `owner` holds the admin statements), but checking `locals.user` here gives clean redirect/403
10
- * UX and lets the mutations guard self-lockout before calling the API. Returns the acting owner.
11
- */
12
- export function requireOwner(user) {
13
- if (!user)
14
- throw error(401, 'Not signed in');
15
- if (user.role !== 'owner')
16
- throw error(403, 'Owner access required');
17
- return user;
18
- }
19
- function asCairnUser(u) {
20
- return { id: u.id, email: u.email, name: u.name, role: u.role === 'owner' ? 'owner' : 'editor' };
21
- }
22
- /** Find an editor by exact (lowercased) email, or undefined. */
23
- async function findByEmail(event, email) {
24
- const res = await event.locals.auth.api.listUsers({
25
- query: { searchValue: email, searchField: 'email', limit: 100 },
26
- headers: event.request.headers,
27
- });
28
- const match = (res.users ?? []).find((u) => u.email.toLowerCase() === email);
29
- return match ? asCairnUser(match) : undefined;
30
- }
31
- /** List the allowlist for the manage-editors page. Owner-only. */
32
- export async function adminsLoad(event) {
33
- const owner = requireOwner(event.locals.user);
34
- const res = await event.locals.auth.api.listUsers({
35
- query: { limit: 200 },
36
- headers: event.request.headers,
37
- });
38
- const admins = (res.users ?? []).map(asCairnUser).sort((a, b) => a.email.localeCompare(b.email));
39
- return {
40
- admins,
41
- self: owner.email,
42
- saved: event.url.searchParams.get('saved') === '1',
43
- error: event.url.searchParams.get('error'),
44
- };
45
- }
46
- /** Add an editor (create the user). Owner-only. */
47
- export async function addAdmin(event) {
48
- requireOwner(event.locals.user);
49
- const form = await event.request.formData();
50
- const email = String(form.get('email') ?? '').trim().toLowerCase();
51
- const name = String(form.get('name') ?? '').trim();
52
- const role = form.get('role') === 'owner' ? 'owner' : 'editor';
53
- if (!EMAIL_RE.test(email) || !name) {
54
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
55
- }
56
- // No password: a magic-link-only user (no credential account), per better-auth's createUser.
57
- await event.locals.auth.api.createUser({ body: { email, name, role }, headers: event.request.headers });
58
- throw redirect(303, '/admin/admins?saved=1');
59
- }
60
- /** Remove an editor (delete the user). Owner-only; owners can't remove themselves (anti-lockout). */
61
- export async function removeAdmin(event) {
62
- const owner = requireOwner(event.locals.user);
63
- const form = await event.request.formData();
64
- const email = String(form.get('email') ?? '').trim().toLowerCase();
65
- if (email === owner.email) {
66
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
67
- }
68
- const target = await findByEmail(event, email);
69
- if (!target)
70
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
71
- await event.locals.auth.api.removeUser({ body: { userId: target.id }, headers: event.request.headers });
72
- throw redirect(303, '/admin/admins?saved=1');
73
- }
74
- /** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
75
- export async function setAdminRole(event) {
76
- const owner = requireOwner(event.locals.user);
77
- const form = await event.request.formData();
78
- const email = String(form.get('email') ?? '').trim().toLowerCase();
79
- const role = form.get('role') === 'owner' ? 'owner' : 'editor';
80
- if (email === owner.email && role !== 'owner') {
81
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
82
- }
83
- const target = await findByEmail(event, email);
84
- if (!target)
85
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
86
- await event.locals.auth.api.setRole({ body: { userId: target.id, role }, headers: event.request.headers });
87
- // M3: revoke a demoted editor's live sessions so the privilege drop takes effect immediately.
88
- await event.locals.auth.api.revokeUserSessions({ body: { userId: target.id }, headers: event.request.headers });
89
- throw redirect(303, '/admin/admins?saved=1');
90
- }