@glw907/cairn-cms 0.5.1 → 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 (233) 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 +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 +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 +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 -173
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -348
  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 +32 -57
  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 -164
  130. package/src/lib/components/ComponentPalette.svelte +36 -20
  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 +155 -136
  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 +73 -63
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +5 -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 -10
  153. package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
  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 -512
  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 -93
  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/capabilities.d.ts +0 -7
  176. package/dist/auth/capabilities.d.ts.map +0 -1
  177. package/dist/auth/capabilities.js +0 -26
  178. package/dist/auth/config.d.ts +0 -2097
  179. package/dist/auth/config.d.ts.map +0 -1
  180. package/dist/auth/config.js +0 -78
  181. package/dist/auth/guard.d.ts +0 -34
  182. package/dist/auth/guard.d.ts.map +0 -1
  183. package/dist/auth/guard.js +0 -47
  184. package/dist/auth/index.d.ts +0 -5
  185. package/dist/auth/index.d.ts.map +0 -1
  186. package/dist/auth/index.js +0 -7
  187. package/dist/auth/schema.d.ts +0 -750
  188. package/dist/auth/schema.d.ts.map +0 -1
  189. package/dist/auth/schema.js +0 -93
  190. package/dist/carta.d.ts +0 -39
  191. package/dist/carta.d.ts.map +0 -1
  192. package/dist/carta.js +0 -30
  193. package/dist/components/CollectionList.svelte +0 -96
  194. package/dist/components/CollectionList.svelte.d.ts +0 -8
  195. package/dist/components/CollectionList.svelte.d.ts.map +0 -1
  196. package/dist/components/ManageAdmins.svelte +0 -84
  197. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  198. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  199. package/dist/content.d.ts +0 -3
  200. package/dist/content.d.ts.map +0 -1
  201. package/dist/content.js +0 -10
  202. package/dist/editor.d.ts +0 -25
  203. package/dist/editor.d.ts.map +0 -1
  204. package/dist/editor.js +0 -20
  205. package/dist/frontmatter.d.ts +0 -3
  206. package/dist/frontmatter.d.ts.map +0 -1
  207. package/dist/frontmatter.js +0 -16
  208. package/dist/github.d.ts +0 -72
  209. package/dist/github.d.ts.map +0 -1
  210. package/dist/github.js +0 -171
  211. package/dist/nav.d.ts.map +0 -1
  212. package/dist/slug.d.ts +0 -7
  213. package/dist/slug.d.ts.map +0 -1
  214. package/dist/slug.js +0 -15
  215. package/dist/utils.d.ts +0 -3
  216. package/dist/utils.d.ts.map +0 -1
  217. package/dist/utils.js +0 -11
  218. package/src/lib/adapter.ts +0 -144
  219. package/src/lib/auth/admins.ts +0 -106
  220. package/src/lib/auth/capabilities.ts +0 -35
  221. package/src/lib/auth/config.ts +0 -108
  222. package/src/lib/auth/guard.ts +0 -60
  223. package/src/lib/auth/index.ts +0 -7
  224. package/src/lib/auth/schema.ts +0 -112
  225. package/src/lib/carta.ts +0 -59
  226. package/src/lib/components/CollectionList.svelte +0 -96
  227. package/src/lib/components/ManageAdmins.svelte +0 -84
  228. package/src/lib/content.ts +0 -11
  229. package/src/lib/editor.ts +0 -38
  230. package/src/lib/frontmatter.ts +0 -17
  231. package/src/lib/github.ts +0 -220
  232. package/src/lib/slug.ts +0 -16
  233. package/src/lib/utils.ts +0 -12
@@ -1,20 +1,20 @@
1
1
  import type { Paragraph, PhrasingContent, Root, Text } from 'mdast';
2
2
  import type { ContainerDirective, LeafDirective, TextDirective } from 'mdast-util-directive';
3
3
  import { visit } from 'unist-util-visit';
4
- import type { ComponentRegistry } from './registry';
4
+ import type { ComponentRegistry } from './registry.js';
5
5
 
6
6
  // Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
7
7
  // Accidental prose directives carry none, so this is almost always empty.
8
8
  function serializeAttributes(attributes?: Record<string, string | null | undefined> | null): string {
9
- if (!attributes) return '';
10
- const tokens: string[] = [];
11
- for (const [key, value] of Object.entries(attributes)) {
12
- if (value == null) tokens.push(key);
13
- else if (key === 'id') tokens.push(`#${value}`);
14
- else if (key === 'class') for (const c of value.split(/\s+/).filter(Boolean)) tokens.push(`.${c}`);
15
- else tokens.push(`${key}="${value}"`);
16
- }
17
- return tokens.length ? `{${tokens.join(' ')}}` : '';
9
+ if (!attributes) return '';
10
+ const tokens: string[] = [];
11
+ for (const [key, value] of Object.entries(attributes)) {
12
+ if (value == null) tokens.push(key);
13
+ else if (key === 'id') tokens.push(`#${value}`);
14
+ else if (key === 'class') for (const c of value.split(/\s+/).filter(Boolean)) tokens.push(`.${c}`);
15
+ else tokens.push(`${key}="${value}"`);
16
+ }
17
+ return tokens.length ? `{${tokens.join(' ')}}` : '';
18
18
  }
19
19
 
20
20
  // The vocabulary is container-only (`:::name`). A text directive (`:name`) or
@@ -22,14 +22,14 @@ function serializeAttributes(attributes?: Record<string, string | null | undefin
22
22
  // ("4:00", "9:30", "ratio 16:9") that micromark tokenized as a directive.
23
23
  // Restore it to its literal source text so prose renders verbatim.
24
24
  function restoreLiteral(node: TextDirective | LeafDirective): PhrasingContent[] {
25
- const marker = node.type === 'leafDirective' ? '::' : ':';
26
- const attrs = serializeAttributes(node.attributes);
27
- if (node.children.length === 0) {
28
- return [{ type: 'text', value: marker + node.name + attrs }];
29
- }
30
- const open: Text = { type: 'text', value: `${marker}${node.name}[` };
31
- const close: Text = { type: 'text', value: `]${attrs}` };
32
- return [open, ...(node.children as PhrasingContent[]), close];
25
+ const marker = node.type === 'leafDirective' ? '::' : ':';
26
+ const attrs = serializeAttributes(node.attributes);
27
+ if (node.children.length === 0) {
28
+ return [{ type: 'text', value: marker + node.name + attrs }];
29
+ }
30
+ const open: Text = { type: 'text', value: `${marker}${node.name}[` };
31
+ const close: Text = { type: 'text', value: `]${attrs}` };
32
+ return [open, ...(node.children as PhrasingContent[]), close];
33
33
  }
34
34
 
35
35
  // Stamp each registered container directive with data-* markers carrying its
@@ -37,35 +37,35 @@ function restoreLiteral(node: TextDirective | LeafDirective): PhrasingContent[]
37
37
  // dispatcher rewrites the marked elements once their children are hast.
38
38
  // Text and leaf directives are restored to literal text (accidental prose colons).
39
39
  export function remarkDirectiveStamp(registry: ComponentRegistry) {
40
- const known = new Set(registry.names);
41
- return (tree: Root) => {
42
- visit(tree, 'containerDirective', (node: ContainerDirective) => {
43
- if (!known.has(node.name)) return;
44
- const attrs = node.attributes ?? {};
45
- const role = attrs.role || undefined;
46
- let icon = attrs.icon || undefined;
47
- if (!icon && role) icon = registry.defaultIcon(node.name, role);
40
+ const known = new Set(registry.names);
41
+ return (tree: Root) => {
42
+ visit(tree, 'containerDirective', (node: ContainerDirective) => {
43
+ if (!known.has(node.name)) return;
44
+ const attrs = node.attributes ?? {};
45
+ const role = attrs.role || undefined;
46
+ let icon = attrs.icon || undefined;
47
+ if (!icon && role) icon = registry.defaultIcon(node.name, role);
48
48
 
49
- const properties: Record<string, string> = { dataPrimitive: node.name };
50
- if (icon) properties.dataIcon = icon;
51
- if (role) properties.dataRole = role;
49
+ const properties: Record<string, string> = { dataPrimitive: node.name };
50
+ if (icon) properties.dataIcon = icon;
51
+ if (role) properties.dataRole = role;
52
52
 
53
- const data = node.data ?? (node.data = {});
54
- data.hName = 'div';
55
- data.hProperties = properties;
56
- });
53
+ const data = node.data ?? (node.data = {});
54
+ data.hName = 'div';
55
+ data.hProperties = properties;
56
+ });
57
57
 
58
- visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
59
- if (!parent || index == null) return;
60
- const literal = restoreLiteral(node as TextDirective | LeafDirective);
61
- if (node.type === 'leafDirective') {
62
- // Leaf directives sit at block level; wrap the restored text in a paragraph.
63
- const paragraph: Paragraph = { type: 'paragraph', children: literal };
64
- parent.children.splice(index, 1, paragraph);
65
- } else {
66
- parent.children.splice(index, 1, ...literal);
67
- }
68
- return index;
69
- });
70
- };
58
+ visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
59
+ if (!parent || index == null) return;
60
+ const literal = restoreLiteral(node as TextDirective | LeafDirective);
61
+ if (node.type === 'leafDirective') {
62
+ // Leaf directives sit at block level; wrap the restored text in a paragraph.
63
+ const paragraph: Paragraph = { type: 'paragraph', children: literal };
64
+ parent.children.splice(index, 1, paragraph);
65
+ } else {
66
+ parent.children.splice(index, 1, ...literal);
67
+ }
68
+ return index;
69
+ });
70
+ };
71
71
  }
@@ -0,0 +1,27 @@
1
+ // The live preview's sanitize floor. Carta runs with `sanitizer: false` behind the MarkdownEditor
2
+ // seam, so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
3
+ // DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
4
+ // loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
5
+ // server import of this file pulls in nothing.
6
+ let purify: { sanitize(html: string, config?: Record<string, unknown>): string; addHook(event: string, cb: (node: Element) => void): void } | null = null;
7
+
8
+ /**
9
+ * Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
10
+ * handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
11
+ * Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
12
+ * reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
13
+ */
14
+ export async function sanitizePreviewHtml(html: string): Promise<string> {
15
+ if (!purify) {
16
+ const mod = await import('dompurify');
17
+ purify = mod.default;
18
+ purify.addHook('afterSanitizeAttributes', (node) => {
19
+ if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
20
+ node.setAttribute('rel', 'noopener noreferrer');
21
+ }
22
+ });
23
+ }
24
+ // ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
25
+ // can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
26
+ return purify.sanitize(html, { ADD_ATTR: ['target'] });
27
+ }
@@ -0,0 +1,107 @@
1
+ // The SvelteKit handlers for the magic-link flow, consumed by a site's thin route shims.
2
+ // The factory takes per-site branding and an injected send, so tests run the real handlers
3
+ // against a sink. The confirm-load, confirm, and logout handlers arrive in Task 6.
4
+ import { redirect } from '@sveltejs/kit';
5
+ import { requireOrigin, requireDb } from '../env.js';
6
+ import {
7
+ generateToken,
8
+ generateSessionId,
9
+ hashToken,
10
+ TOKEN_TTL_MS,
11
+ SESSION_TTL_MS,
12
+ COOKIE_NAME,
13
+ } from '../auth/crypto.js';
14
+ import { findEditor, issueToken, consumeToken, createSession, deleteSession } from '../auth/store.js';
15
+ import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
16
+ import type { RequestContext } from './types.js';
17
+
18
+ export interface AuthRoutesConfig {
19
+ branding: AuthBranding;
20
+ send?: SendMagicLink;
21
+ }
22
+
23
+ export function createAuthRoutes(config: AuthRoutesConfig) {
24
+ const send = config.send ?? cloudflareSend;
25
+
26
+ /**
27
+ * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
28
+ * and emails the confirmation link. The response is identical whether or not the email is
29
+ * allow-listed, so the endpoint never leaks membership.
30
+ */
31
+ async function requestAction(event: RequestContext): Promise<{ sent: true }> {
32
+ const env = event.platform?.env ?? {};
33
+ const origin = requireOrigin(env);
34
+ const db = requireDb(env);
35
+ const form = await event.request.formData();
36
+ const email = String(form.get('email') ?? '').trim().toLowerCase();
37
+
38
+ const editor = email ? await findEditor(db, email) : null;
39
+ if (editor) {
40
+ const token = generateToken();
41
+ const now = Date.now();
42
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
43
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
44
+ await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
45
+ }
46
+ return { sent: true };
47
+ }
48
+
49
+ /** GET /admin/login. Public. Carries the site name and an optional `?error` for the form. */
50
+ function loginLoad(event: RequestContext): { siteName: string; error: string | null } {
51
+ return { siteName: config.branding.siteName, error: event.url.searchParams.get('error') };
52
+ }
53
+
54
+ /**
55
+ * GET /admin/auth/confirm. Renders the confirm page and consumes nothing; only the POST
56
+ * verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer.
57
+ */
58
+ function confirmLoad(
59
+ event: RequestContext,
60
+ ): { token: string; siteName: string; error: string | null } {
61
+ event.setHeaders({ 'Referrer-Policy': 'no-referrer' });
62
+ return {
63
+ token: event.url.searchParams.get('token') ?? '',
64
+ siteName: config.branding.siteName,
65
+ error: event.url.searchParams.get('error'),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * POST /admin/auth/confirm. Hashes the submitted token and consumes it atomically. A valid
71
+ * token yields the email; the handler creates a session, sets the cookie, and redirects to
72
+ * /admin. An invalid, replayed, or expired token redirects to the login page.
73
+ */
74
+ async function confirmAction(event: RequestContext): Promise<never> {
75
+ const db = requireDb(event.platform?.env ?? {});
76
+ const form = await event.request.formData();
77
+ const token = String(form.get('token') ?? '');
78
+ if (!token) throw redirect(303, '/admin/login?error=expired');
79
+
80
+ const now = Date.now();
81
+ const email = await consumeToken(db, await hashToken(token), now);
82
+ if (!email) throw redirect(303, '/admin/login?error=expired');
83
+
84
+ const id = generateSessionId();
85
+ await createSession(db, id, email, now + SESSION_TTL_MS, now);
86
+ event.cookies.set(COOKIE_NAME, id, {
87
+ path: '/',
88
+ httpOnly: true,
89
+ // Secure on HTTPS (every real deploy); off on local http dev so the cookie sticks.
90
+ secure: event.url.protocol === 'https:',
91
+ sameSite: 'lax',
92
+ maxAge: Math.floor(SESSION_TTL_MS / 1000),
93
+ });
94
+ throw redirect(303, '/admin');
95
+ }
96
+
97
+ /** POST /admin/auth/logout. Deletes the session row and clears the cookie. */
98
+ async function logoutAction(event: RequestContext): Promise<never> {
99
+ const db = requireDb(event.platform?.env ?? {});
100
+ const id = event.cookies.get(COOKIE_NAME);
101
+ if (id) await deleteSession(db, id);
102
+ event.cookies.delete(COOKIE_NAME, { path: '/' });
103
+ throw redirect(303, '/admin/login');
104
+ }
105
+
106
+ return { loginLoad, requestAction, confirmLoad, confirmAction, logoutAction };
107
+ }
@@ -0,0 +1,261 @@
1
+ // The admin content routes: the load and action functions a site's /admin/** shims call.
2
+ // A factory closes over the composed runtime and the GitHub token mint, so the read and
3
+ // commit paths are unit-testable against a fetch double with an injected token, mirroring the
4
+ // email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
5
+ import { redirect, error } from '@sveltejs/kit';
6
+ import { findConcept } from '../content/concepts.js';
7
+ import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
+ import { isValidId, slugify, filenameFromId } from '../content/ids.js';
9
+ import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
10
+ import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
11
+ import { installationToken } from '../github/signing.js';
12
+ import { CommitConflictError } from '../github/types.js';
13
+ import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
14
+ import type { Editor, Role } from '../auth/types.js';
15
+
16
+ /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
17
+ export interface NavConcept {
18
+ id: string;
19
+ label: string;
20
+ }
21
+
22
+ /** The admin layout's data: site identity, the signed-in user, the nav, and the active path. */
23
+ export interface LayoutData {
24
+ siteName: string;
25
+ user: { displayName: string; role: Role };
26
+ concepts: NavConcept[];
27
+ pathname: string;
28
+ canManageEditors: boolean;
29
+ /** The nav menu's label when the site configures one; gates the Navigation nav entry. Null otherwise. */
30
+ navLabel: string | null;
31
+ }
32
+
33
+ /** One row in a concept's list view. */
34
+ export interface EntrySummary {
35
+ id: string;
36
+ title: string;
37
+ date: string | null;
38
+ draft: boolean;
39
+ }
40
+
41
+ /** The concept list view's data. */
42
+ export interface ListData {
43
+ conceptId: string;
44
+ label: string;
45
+ /** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
46
+ dated: boolean;
47
+ entries: EntrySummary[];
48
+ /** A listing failure degrades to an inline message rather than a thrown 500. */
49
+ error: string | null;
50
+ /** A create-form bounce error read from `?error`. */
51
+ formError: string | null;
52
+ }
53
+
54
+ /** The editor's data. `frontmatter` holds form-ready values (dates already `YYYY-MM-DD`). */
55
+ export interface EditData {
56
+ conceptId: string;
57
+ id: string;
58
+ label: string;
59
+ fields: FrontmatterField[];
60
+ frontmatter: Record<string, unknown>;
61
+ body: string;
62
+ title: string;
63
+ isNew: boolean;
64
+ saved: boolean;
65
+ error: string | null;
66
+ }
67
+
68
+ /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
69
+ export interface ContentEvent {
70
+ url: URL;
71
+ params: Record<string, string>;
72
+ request: Request;
73
+ locals: { editor?: Editor | null };
74
+ platform?: { env?: GithubKeyEnv };
75
+ }
76
+
77
+ /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
78
+ export interface ContentRoutesDeps {
79
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer. */
80
+ mintToken?: (env: GithubKeyEnv) => Promise<string>;
81
+ }
82
+
83
+ /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
84
+ function sessionOf(event: ContentEvent): Editor {
85
+ const editor = event.locals.editor;
86
+ if (!editor) throw redirect(303, '/admin/login');
87
+ return editor;
88
+ }
89
+
90
+ /** Look up the concept named by the `[concept]` route param, or a 404. */
91
+ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): ConceptDescriptor {
92
+ const concept = findConcept(runtime.concepts, params.concept ?? '');
93
+ if (!concept) throw error(404, `Unknown content type: ${params.concept ?? ''}`);
94
+ return concept;
95
+ }
96
+
97
+ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
98
+ const mintToken =
99
+ deps.mintToken ?? ((env: GithubKeyEnv) => installationToken(appCredentials(runtime.backend, env)));
100
+
101
+ /** Layout load for every admin page: the nav, the user, and the active path. */
102
+ function layoutLoad(event: ContentEvent): LayoutData {
103
+ const editor = sessionOf(event);
104
+ return {
105
+ siteName: runtime.siteName,
106
+ user: { displayName: editor.displayName, role: editor.role },
107
+ concepts: runtime.concepts.map((c) => ({ id: c.id, label: c.label })),
108
+ pathname: event.url.pathname,
109
+ canManageEditors: editor.role === 'owner',
110
+ navLabel: runtime.navMenu?.label ?? null,
111
+ };
112
+ }
113
+
114
+ /** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
115
+ function indexRedirect(): never {
116
+ const first = runtime.concepts[0];
117
+ if (!first) throw error(404, 'No content types configured');
118
+ throw redirect(307, `/admin/${first.id}`);
119
+ }
120
+
121
+ /** Read a file's frontmatter for its list row, degrading to the id on any read failure. */
122
+ async function summarize(file: { id: string; path: string }, token: string): Promise<EntrySummary> {
123
+ try {
124
+ const raw = await readRaw(runtime.backend, file.path, token);
125
+ if (raw === null) return { id: file.id, title: file.id, date: null, draft: false };
126
+ const { frontmatter } = parseMarkdown(raw);
127
+ const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
128
+ const date = dateInputValue(frontmatter.date) || null;
129
+ return { id: file.id, title, date, draft: frontmatter.draft === true };
130
+ } catch {
131
+ return { id: file.id, title: file.id, date: null, draft: false };
132
+ }
133
+ }
134
+
135
+ /** List a concept's entries. A listing failure degrades to an inline error, not a thrown 500. */
136
+ async function listLoad(event: ContentEvent): Promise<ListData> {
137
+ sessionOf(event);
138
+ const concept = conceptOf(runtime, event.params);
139
+ const formError = event.url.searchParams.get('error');
140
+ const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError };
141
+ let token: string;
142
+ try {
143
+ token = await mintToken(event.platform?.env ?? {});
144
+ } catch {
145
+ return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
146
+ }
147
+ try {
148
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
149
+ const entries = await Promise.all(files.map((f) => summarize(f, token)));
150
+ return { ...base, entries, error: null };
151
+ } catch {
152
+ return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
153
+ }
154
+ }
155
+
156
+ /** Create a new entry: validate the slug, refuse to clobber, and redirect to the editor. */
157
+ async function createAction(event: ContentEvent): Promise<never> {
158
+ sessionOf(event);
159
+ const concept = conceptOf(runtime, event.params);
160
+ const form = await event.request.formData();
161
+ const raw = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
162
+ const bounce = (msg: string): never => {
163
+ throw redirect(303, `/admin/${concept.id}?error=${encodeURIComponent(msg)}`);
164
+ };
165
+ if (!isValidId(raw)) bounce('Enter a valid slug: lowercase letters, numbers, and hyphens.');
166
+
167
+ const token = await mintToken(event.platform?.env ?? {});
168
+ const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(raw)}`, token);
169
+ if (existing !== null) bounce('An entry with that slug already exists.');
170
+
171
+ throw redirect(303, `/admin/${concept.id}/${raw}?new=1`);
172
+ }
173
+
174
+ /** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
175
+ function formValues(fields: FrontmatterField[], frontmatter: Record<string, unknown>): Record<string, unknown> {
176
+ const out: Record<string, unknown> = {};
177
+ for (const field of fields) {
178
+ const value = frontmatter[field.name];
179
+ if (field.type === 'date') out[field.name] = dateInputValue(value);
180
+ else if (field.type === 'boolean') out[field.name] = value === true;
181
+ else if (field.type === 'tags' || field.type === 'freetags') out[field.name] = Array.isArray(value) ? value.map(String) : [];
182
+ else out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
188
+ async function editLoad(event: ContentEvent): Promise<EditData> {
189
+ sessionOf(event);
190
+ const concept = conceptOf(runtime, event.params);
191
+ const id = event.params.id ?? '';
192
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
193
+ const isNew = event.url.searchParams.get('new') === '1';
194
+ const token = await mintToken(event.platform?.env ?? {});
195
+ const raw = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
196
+ if (raw === null && !isNew) throw error(404, 'Entry not found');
197
+
198
+ const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
199
+ const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
200
+ return {
201
+ conceptId: concept.id,
202
+ id,
203
+ label: concept.label,
204
+ fields: concept.fields,
205
+ frontmatter: formValues(concept.fields, parsed.frontmatter),
206
+ body: parsed.body,
207
+ title,
208
+ isNew,
209
+ saved: event.url.searchParams.get('saved') === '1',
210
+ error: event.url.searchParams.get('error'),
211
+ };
212
+ }
213
+
214
+ /** Match a commit conflict by class and by name (bundling can alias the class identity). */
215
+ function isConflict(err: unknown): boolean {
216
+ return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
217
+ }
218
+
219
+ /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
220
+ async function saveAction(event: ContentEvent): Promise<never> {
221
+ const editor = sessionOf(event);
222
+ const concept = conceptOf(runtime, event.params);
223
+ const id = event.params.id ?? '';
224
+ // Confine the commit path to the concept dir, built from a validated id (the App token can
225
+ // write anywhere in the repo). Reject before touching GitHub.
226
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
227
+ const path = `${concept.dir}/${filenameFromId(id)}`;
228
+
229
+ const form = await event.request.formData();
230
+ const body = String(form.get('body') ?? '');
231
+ const isNew = form.get('new') === '1';
232
+ const suffix = isNew ? '&new=1' : '';
233
+
234
+ const result = concept.validate(frontmatterFromForm(concept.fields, form), body);
235
+ if (!result.ok) {
236
+ const message = Object.values(result.errors)[0] ?? 'Invalid frontmatter';
237
+ throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
238
+ }
239
+
240
+ const markdown = serializeMarkdown(result.data, body);
241
+ const token = await mintToken(event.platform?.env ?? {});
242
+ try {
243
+ await commitFile(
244
+ runtime.backend,
245
+ path,
246
+ markdown,
247
+ { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
248
+ token,
249
+ );
250
+ } catch (err) {
251
+ if (isConflict(err)) {
252
+ const message = 'This file changed since you opened it. Reload and reapply your edits.';
253
+ throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
254
+ }
255
+ throw err;
256
+ }
257
+ throw redirect(303, `/admin/${concept.id}/${id}?saved=1`);
258
+ }
259
+
260
+ return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
261
+ }
@@ -0,0 +1,82 @@
1
+ // Owner-gated editor management. The editor table is the allowlist, so add and remove are
2
+ // insert and delete. The anti-lockout rule is the last remaining owner: the system refuses to
3
+ // drop below one owner (spec 7.1), enforced in the store by an atomic guarded write rather
4
+ // than a separate count, so concurrent removals cannot strand the allowlist at zero owners.
5
+ import { fail } from '@sveltejs/kit';
6
+ import { requireOwner } from './guard.js';
7
+ import { requireDb } from '../env.js';
8
+ import {
9
+ listEditors,
10
+ findEditor,
11
+ insertEditor,
12
+ deleteEditor,
13
+ setEditorRole,
14
+ removeOwnerIfNotLast,
15
+ demoteOwnerIfNotLast,
16
+ } from '../auth/store.js';
17
+ import type { Editor, Role } from '../auth/types.js';
18
+ import type { RequestContext } from './types.js';
19
+
20
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
21
+
22
+ function parseRole(value: FormDataEntryValue | null): Role {
23
+ return value === 'owner' ? 'owner' : 'editor';
24
+ }
25
+
26
+ export function createEditorRoutes() {
27
+ /** GET /admin/editors. Owner-only. Returns the allowlist and the acting owner's email. */
28
+ async function editorsLoad(event: RequestContext): Promise<{ editors: Editor[]; self: string }> {
29
+ const owner = requireOwner(event);
30
+ const editors = await listEditors(requireDb(event.platform?.env ?? {}));
31
+ return { editors, self: owner.email };
32
+ }
33
+
34
+ /** POST add an editor. Owner-only. */
35
+ async function addEditorAction(event: RequestContext) {
36
+ requireOwner(event);
37
+ const db = requireDb(event.platform?.env ?? {});
38
+ const form = await event.request.formData();
39
+ const email = String(form.get('email') ?? '').trim().toLowerCase();
40
+ const name = String(form.get('name') ?? '').trim();
41
+ const role = parseRole(form.get('role'));
42
+ if (!EMAIL_RE.test(email) || !name) return fail(400, { error: 'Enter a valid email and name' });
43
+ if (await findEditor(db, email)) return fail(400, { error: 'That editor already exists' });
44
+ await insertEditor(db, email, name, role, Date.now());
45
+ return { ok: true as const };
46
+ }
47
+
48
+ /** POST remove an editor. Owner-only. Refuses the last owner, atomically. */
49
+ async function removeEditorAction(event: RequestContext) {
50
+ requireOwner(event);
51
+ const db = requireDb(event.platform?.env ?? {});
52
+ const form = await event.request.formData();
53
+ const email = String(form.get('email') ?? '').trim().toLowerCase();
54
+ const target = await findEditor(db, email);
55
+ if (!target) return fail(400, { error: 'No such editor' });
56
+ if (target.role === 'owner') {
57
+ if (!(await removeOwnerIfNotLast(db, email))) return fail(400, { error: 'You cannot remove the last owner' });
58
+ } else {
59
+ await deleteEditor(db, email);
60
+ }
61
+ return { ok: true as const };
62
+ }
63
+
64
+ /** POST change an editor's role. Owner-only. Refuses demoting the last owner, atomically. */
65
+ async function setRoleAction(event: RequestContext) {
66
+ requireOwner(event);
67
+ const db = requireDb(event.platform?.env ?? {});
68
+ const form = await event.request.formData();
69
+ const email = String(form.get('email') ?? '').trim().toLowerCase();
70
+ const role = parseRole(form.get('role'));
71
+ const target = await findEditor(db, email);
72
+ if (!target) return fail(400, { error: 'No such editor' });
73
+ if (role === 'editor' && target.role === 'owner') {
74
+ if (!(await demoteOwnerIfNotLast(db, email))) return fail(400, { error: 'You cannot demote the last owner' });
75
+ } else {
76
+ await setEditorRole(db, email, role);
77
+ }
78
+ return { ok: true as const };
79
+ }
80
+
81
+ return { editorsLoad, addEditorAction, removeEditorAction, setRoleAction };
82
+ }
@@ -0,0 +1,47 @@
1
+ // The /admin guard, plus the per-load owner/session gates. A site's hooks.server.ts sets
2
+ // `export const handle = createAuthGuard()`. Events are typed structurally, so the engine
3
+ // stays free of a site's App.* ambient types.
4
+ import { redirect, error } from '@sveltejs/kit';
5
+ import { resolveSession } from '../auth/store.js';
6
+ import { COOKIE_NAME } from '../auth/crypto.js';
7
+ import type { Editor } from '../auth/types.js';
8
+ import type { HandleInput, RequestContext } from './types.js';
9
+
10
+ /** The login page and the auth endpoints are public; everything else under /admin is gated. */
11
+ function isPublicAdminPath(pathname: string): boolean {
12
+ return pathname === '/admin/login' || pathname.startsWith('/admin/auth/');
13
+ }
14
+
15
+ function isAdminPath(pathname: string): boolean {
16
+ return pathname === '/admin' || pathname.startsWith('/admin/');
17
+ }
18
+
19
+ /** The SvelteKit `Handle` that guards `/admin/**`. */
20
+ export function createAuthGuard() {
21
+ return async function handle({ event, resolve }: HandleInput): Promise<Response> {
22
+ const { pathname } = event.url;
23
+ if (!isAdminPath(pathname) || isPublicAdminPath(pathname)) {
24
+ return resolve(event);
25
+ }
26
+ const env = event.platform?.env ?? {};
27
+ const id = event.cookies.get(COOKIE_NAME);
28
+ const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
29
+ if (!editor) throw redirect(303, '/admin/login');
30
+ event.locals.editor = editor;
31
+ return resolve(event);
32
+ };
33
+ }
34
+
35
+ /** For a protected load/action: the session the guard already resolved, or a login redirect. */
36
+ export function requireSession(event: RequestContext): Editor {
37
+ const editor = event.locals.editor;
38
+ if (!editor) throw redirect(303, '/admin/login');
39
+ return editor;
40
+ }
41
+
42
+ /** For the management surface: a signed-in owner, or 403 for an editor. */
43
+ export function requireOwner(event: RequestContext): Editor {
44
+ const editor = requireSession(event);
45
+ if (editor.role !== 'owner') throw error(403, 'Owner access required');
46
+ return editor;
47
+ }
@@ -0,0 +1,24 @@
1
+ // GET /admin/healthz. Signs a dummy JWT through the real App-signing path so a broken
2
+ // PKCS#1-to-PKCS#8 conversion is caught early (spec §7.8). The payload is pass/fail and a
3
+ // coarse detail only; it never carries the key or a token.
4
+ import { signingSelfTest } from '../github/signing.js';
5
+ import type { CairnRuntime } from '../content/types.js';
6
+ import type { GithubKeyEnv } from '../github/credentials.js';
7
+
8
+ /** The `/admin/healthz` payload. */
9
+ export interface HealthData {
10
+ ok: boolean;
11
+ checks: { githubAppSigning: { ok: boolean; detail?: string } };
12
+ }
13
+
14
+ /** Run the signing self-test against the configured App id and the Worker's key secret. */
15
+ export async function healthLoad(
16
+ event: { platform?: { env?: GithubKeyEnv } },
17
+ runtime: CairnRuntime,
18
+ ): Promise<HealthData> {
19
+ const key = event.platform?.env?.GITHUB_APP_PRIVATE_KEY_B64;
20
+ const githubAppSigning = key
21
+ ? await signingSelfTest(runtime.backend.appId, key)
22
+ : { ok: false, detail: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured' };
23
+ return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
24
+ }