@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,119 +0,0 @@
1
- // cairn-core: the adapter contract each site implements.
2
- //
3
- // This is the single seam that lets one admin surface serve different designs. A site
4
- // supplies a `CairnAdapter` (see `src/lib/cairn.config.ts`) describing its backend repo,
5
- // its editable collections (folder + form fields + frontmatter validator), and its preview
6
- // plugin set. cairn-core never hard-codes a collection, tag, or directive; it reads them
7
- // from the adapter. Field descriptors are plain data so a load function can hand them to
8
- // the editor form across the server-to-client boundary.
9
- import type { PreviewPlugins } from './carta';
10
- import type { RepoRef } from './github';
11
- import type { ComponentRegistry } from './render';
12
-
13
- interface FieldBase {
14
- /** Frontmatter key and form input name. */
15
- name: string;
16
- label: string;
17
- required?: boolean;
18
- }
19
-
20
- export interface TextField extends FieldBase {
21
- type: 'text';
22
- }
23
- export interface DateField extends FieldBase {
24
- type: 'date';
25
- }
26
- export interface TextareaField extends FieldBase {
27
- type: 'textarea';
28
- rows?: number;
29
- }
30
- export interface BooleanField extends FieldBase {
31
- type: 'boolean';
32
- }
33
- export interface TagsField extends FieldBase {
34
- type: 'tags';
35
- /** Controlled vocabulary rendered as checkboxes. */
36
- options: readonly string[];
37
- }
38
- export interface FreeTagsField extends FieldBase {
39
- type: 'freetags';
40
- /** Free-form tags, edited as one comma-separated text input (no controlled vocabulary). */
41
- placeholder?: string;
42
- }
43
-
44
- export type CairnField =
45
- | TextField
46
- | DateField
47
- | TextareaField
48
- | BooleanField
49
- | TagsField
50
- | FreeTagsField;
51
-
52
- export interface CairnCollection {
53
- /** Route `[type]` segment and list key, e.g. `posts`. */
54
- type: string;
55
- label: string;
56
- /** Repo-relative folder holding the collection's markdown files. */
57
- dir: string;
58
- /** Editor form fields, rendered in order. */
59
- fields: CairnField[];
60
- /** Validate raw frontmatter (from the form) into the on-disk object, throwing on error. */
61
- validate(data: Record<string, unknown>, source: string): object;
62
- }
63
-
64
- export interface CairnAdapter {
65
- /** Branding + magic-link email copy. */
66
- siteName: string;
67
- /** From: address for magic-link email (must be a domain-authenticated sender). */
68
- sender: string;
69
- /** The repository the admin reads content from and commits to. */
70
- backend: RepoRef;
71
- /** Site plugin set for the Carta preview (parity with the live render). */
72
- preview: PreviewPlugins;
73
- collections: CairnCollection[];
74
- /**
75
- * The site's component registry: the single declaration of its directive
76
- * components (R10a). Rendering parity already flows through `preview`; this
77
- * exposes the same registry so the editor's insert-component palette can read
78
- * `registry.defs`. Optional: a site with no rich components (e.g. 907.life) may
79
- * omit it or supply an empty registry.
80
- */
81
- registry?: ComponentRegistry;
82
- }
83
-
84
- /** Look up a collection by its route segment, or undefined if the segment is unknown. */
85
- export function findCollection(adapter: CairnAdapter, type: string): CairnCollection | undefined {
86
- return adapter.collections.find((collection) => collection.type === type);
87
- }
88
-
89
- /** Read raw frontmatter from a submitted form, decoding each value per its field type. */
90
- export function frontmatterFromForm(
91
- collection: CairnCollection,
92
- form: FormData,
93
- ): Record<string, unknown> {
94
- const data: Record<string, unknown> = {};
95
- for (const field of collection.fields) {
96
- switch (field.type) {
97
- case 'boolean':
98
- data[field.name] = form.get(field.name) === 'on';
99
- break;
100
- case 'tags':
101
- data[field.name] = form.getAll(field.name).map(String);
102
- break;
103
- case 'freetags':
104
- // One comma-separated input → trimmed, de-duplicated, non-empty tags.
105
- data[field.name] = [
106
- ...new Set(
107
- String(form.get(field.name) ?? '')
108
- .split(',')
109
- .map((tag) => tag.trim())
110
- .filter(Boolean),
111
- ),
112
- ];
113
- break;
114
- default:
115
- data[field.name] = form.get(field.name);
116
- }
117
- }
118
- return data;
119
- }
@@ -1,106 +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
- import type { Auth } from './config';
7
- import type { CairnUser } from './guard';
8
-
9
- export interface AdminsData {
10
- admins: CairnUser[];
11
- /** Acting owner's email, so the UI can disable self-targeted remove/demote. */
12
- self: string;
13
- saved: boolean;
14
- error: string | null;
15
- }
16
-
17
- const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
18
-
19
- /**
20
- * The privilege-escalation gate. better-auth's admin API also enforces this server-side (only
21
- * `owner` holds the admin statements), but checking `locals.user` here gives clean redirect/403
22
- * UX and lets the mutations guard self-lockout before calling the API. Returns the acting owner.
23
- */
24
- export function requireOwner(user: CairnUser | null): CairnUser {
25
- if (!user) throw error(401, 'Not signed in');
26
- if (user.role !== 'owner') throw error(403, 'Owner access required');
27
- return user;
28
- }
29
-
30
- type Ev = { locals: { auth: Auth; user: CairnUser | null }; request: Request; url: URL };
31
-
32
- function asCairnUser(u: { id: string; email: string; name: string; role?: string | null }): CairnUser {
33
- return { id: u.id, email: u.email, name: u.name, role: u.role === 'owner' ? 'owner' : 'editor' };
34
- }
35
-
36
- /** Find an editor by exact (lowercased) email, or undefined. */
37
- async function findByEmail(event: Ev, email: string): Promise<CairnUser | undefined> {
38
- const res = await event.locals.auth.api.listUsers({
39
- query: { searchValue: email, searchField: 'email', limit: 100 },
40
- headers: event.request.headers,
41
- });
42
- const match = (res.users ?? []).find((u) => u.email.toLowerCase() === email);
43
- return match ? asCairnUser(match) : undefined;
44
- }
45
-
46
- /** List the allowlist for the manage-editors page. Owner-only. */
47
- export async function adminsLoad(event: Ev): Promise<AdminsData> {
48
- const owner = requireOwner(event.locals.user);
49
- const res = await event.locals.auth.api.listUsers({
50
- query: { limit: 200 },
51
- headers: event.request.headers,
52
- });
53
- const admins = (res.users ?? []).map(asCairnUser).sort((a, b) => a.email.localeCompare(b.email));
54
- return {
55
- admins,
56
- self: owner.email,
57
- saved: event.url.searchParams.get('saved') === '1',
58
- error: event.url.searchParams.get('error'),
59
- };
60
- }
61
-
62
- /** Add an editor (create the user). Owner-only. */
63
- export async function addAdmin(event: Ev): Promise<never> {
64
- requireOwner(event.locals.user);
65
- const form = await event.request.formData();
66
- const email = String(form.get('email') ?? '').trim().toLowerCase();
67
- const name = String(form.get('name') ?? '').trim();
68
- const role = form.get('role') === 'owner' ? 'owner' : 'editor';
69
- if (!EMAIL_RE.test(email) || !name) {
70
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
71
- }
72
- // No password: a magic-link-only user (no credential account), per better-auth's createUser.
73
- await event.locals.auth.api.createUser({ body: { email, name, role }, headers: event.request.headers });
74
- throw redirect(303, '/admin/admins?saved=1');
75
- }
76
-
77
- /** Remove an editor (delete the user). Owner-only; owners can't remove themselves (anti-lockout). */
78
- export async function removeAdmin(event: Ev): Promise<never> {
79
- const owner = requireOwner(event.locals.user);
80
- const form = await event.request.formData();
81
- const email = String(form.get('email') ?? '').trim().toLowerCase();
82
- if (email === owner.email) {
83
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
84
- }
85
- const target = await findByEmail(event, email);
86
- if (!target) throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
87
- await event.locals.auth.api.removeUser({ body: { userId: target.id }, headers: event.request.headers });
88
- throw redirect(303, '/admin/admins?saved=1');
89
- }
90
-
91
- /** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
92
- export async function setAdminRole(event: Ev): Promise<never> {
93
- const owner = requireOwner(event.locals.user);
94
- const form = await event.request.formData();
95
- const email = String(form.get('email') ?? '').trim().toLowerCase();
96
- const role = form.get('role') === 'owner' ? 'owner' : 'editor';
97
- if (email === owner.email && role !== 'owner') {
98
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
99
- }
100
- const target = await findByEmail(event, email);
101
- if (!target) throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
102
- await event.locals.auth.api.setRole({ body: { userId: target.id, role }, headers: event.request.headers });
103
- // M3: revoke a demoted editor's live sessions so the privilege drop takes effect immediately.
104
- await event.locals.auth.api.revokeUserSessions({ body: { userId: target.id }, headers: event.request.headers });
105
- throw redirect(303, '/admin/admins?saved=1');
106
- }
@@ -1,108 +0,0 @@
1
- // cairn-core: the better-auth instance. Auth is engine code (engine-fat rule), so the whole
2
- // config lives here: Drizzle/D1 adapter, magic-link (POST-confirm-shaped send), admin roles.
3
- // Instantiated PER REQUEST in hooks.server.ts (the D1 binding is request-scoped); the factory
4
- // is cheap (no I/O at construction).
5
- import { betterAuth } from 'better-auth';
6
- import { drizzleAdapter } from 'better-auth/adapters/drizzle';
7
- import { drizzle } from 'drizzle-orm/d1';
8
- import { magicLink, admin } from 'better-auth/plugins';
9
- import { createAccessControl } from 'better-auth/plugins/access';
10
- import { defaultStatements } from 'better-auth/plugins/admin/access';
11
- import type { D1Database } from '@cloudflare/workers-types';
12
- import { sendMagicLink, type EmailSender } from '../email';
13
- import * as schema from './schema';
14
-
15
- // Two-tier roles on the admin plugin's access-control system: `owner` holds every admin
16
- // statement (manage editors, revoke sessions); `editor` holds none (content-only). `adminRoles`
17
- // must name a role defined here, so owner (not the plugin's built-in `admin`) is the gate.
18
- const ac = createAccessControl(defaultStatements);
19
- const owner = ac.newRole(defaultStatements);
20
- const editor = ac.newRole({});
21
-
22
- /** Worker bindings + vars the auth layer reads (a structural subset of `Platform.env`). */
23
- export interface AuthEnv {
24
- AUTH_DB?: D1Database;
25
- AUTH_SECRET?: string;
26
- /** Canonical origin; `BETTER_AUTH_URL` is accepted as a legacy alias. */
27
- PUBLIC_ORIGIN?: string;
28
- /** Legacy alias for `PUBLIC_ORIGIN`; `PUBLIC_ORIGIN` takes precedence when both are set. */
29
- BETTER_AUTH_URL?: string;
30
- EMAIL?: EmailSender;
31
- }
32
-
33
- /** Branding the magic-link email needs; threaded from the site adapter via hooks. */
34
- export interface AuthBranding {
35
- siteName: string;
36
- /** The `From:` address used when sending magic-link emails. */
37
- sender: string;
38
- }
39
-
40
- /** The drizzle adapter result `betterAuth` consumes (same provider/schema everywhere). */
41
- type DrizzleDb = Parameters<typeof drizzleAdapter>[0];
42
-
43
- /**
44
- * The shared better-auth config. Kept separate from `createAuth` so the test harness can run
45
- * the EXACT plugin set (allowlist semantics, expiry, POST-confirm send) over an in-memory
46
- * SQLite instead of D1. `disableSignUp:true` makes the `user` table the editor allowlist:
47
- * magic-link never auto-creates, so the only way in is the owner-gated admin `createUser`
48
- * (see auth/admins.ts). `adminRoles:['owner']` lets owners (not the default `admin` role)
49
- * drive the admin API. Tokens are stored hashed and consumed atomically on first verify
50
- * (better-auth GHSA-hc7v-rggr-4hvx), single-use by construction (C1).
51
- */
52
- export function buildAuth(opts: {
53
- database: DrizzleDb;
54
- baseURL: string;
55
- secret: string | undefined;
56
- branding: AuthBranding;
57
- sendLink: (email: string, token: string) => Promise<void>;
58
- }) {
59
- return betterAuth({
60
- appName: opts.branding.siteName,
61
- secret: opts.secret,
62
- baseURL: opts.baseURL,
63
- trustedOrigins: [opts.baseURL],
64
- database: opts.database,
65
- plugins: [
66
- magicLink({
67
- disableSignUp: true,
68
- expiresIn: 600,
69
- storeToken: 'hashed',
70
- sendMagicLink: async ({ email, token }, ctx) => {
71
- // Allowlist gate: better-auth always fires this callback (even for unknown emails, to
72
- // avoid enumeration) and only blocks user creation at verify. So gate the actual send
73
- // here. Never email a non-editor. The login UI shows neutral copy either way, so this
74
- // leaks nothing; it just stops strangers receiving a dead link.
75
- const existing = await ctx?.context.internalAdapter.findUserByEmail(email);
76
- if (!existing?.user) return;
77
- await opts.sendLink(email, token);
78
- },
79
- }),
80
- admin({ ac, roles: { owner, editor }, defaultRole: 'editor', adminRoles: ['owner'] }),
81
- ],
82
- });
83
- }
84
-
85
- /**
86
- * Build the per-request better-auth instance over the site's D1 binding. The magic-link email
87
- * points at OUR confirm page carrying only the token; consumption happens when the user clicks
88
- * "Confirm sign-in" there (a POST), never on a scanner GET (C2 / POST-confirm). The origin is
89
- * config-derived (`PUBLIC_ORIGIN`/`BETTER_AUTH_URL`), never request-derived (H3).
90
- */
91
- export function createAuth(env: AuthEnv, branding: AuthBranding) {
92
- if (!env.AUTH_DB) throw new Error('AUTH_DB (D1) binding is required');
93
- const origin = env.PUBLIC_ORIGIN || env.BETTER_AUTH_URL || 'http://localhost';
94
- const db = drizzle(env.AUTH_DB, { schema });
95
- return buildAuth({
96
- database: drizzleAdapter(db, { provider: 'sqlite', schema }),
97
- baseURL: origin,
98
- secret: env.AUTH_SECRET,
99
- branding,
100
- sendLink: async (email, token) => {
101
- if (!env.EMAIL) throw new Error('EMAIL binding is required to send magic links');
102
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
103
- await sendMagicLink(env.EMAIL, email, link, branding.siteName, branding.sender);
104
- },
105
- });
106
- }
107
-
108
- export type Auth = ReturnType<typeof createAuth>;
@@ -1,60 +0,0 @@
1
- // cairn-core: server-side auth helpers the site route shims delegate to. Each takes the
2
- // SvelteKit event, typed structurally so the package never depends on a site's generated
3
- // `App.*` ambient types, plus the per-request `Auth` from `locals`.
4
- import { redirect } from '@sveltejs/kit';
5
- import type { Auth } from './config';
6
-
7
- /** The session shape the whole admin reads: layout, guards, content fns, manage-editors. */
8
- export interface CairnUser {
9
- id: string;
10
- email: string;
11
- name: string;
12
- role: 'owner' | 'editor';
13
- }
14
-
15
- /** Read the better-auth session into a cairn user (or null). */
16
- export async function loadSession(auth: Auth, request: Request): Promise<CairnUser | null> {
17
- const session = await auth.api.getSession({ headers: request.headers });
18
- if (!session?.user) return null;
19
- const u = session.user as { id: string; email: string; name: string; role?: string | null };
20
- return { id: u.id, email: u.email, name: u.name, role: u.role === 'owner' ? 'owner' : 'editor' };
21
- }
22
-
23
- export function requireSession(user: CairnUser | null): CairnUser {
24
- if (!user) throw redirect(303, '/admin/login');
25
- return user;
26
- }
27
-
28
- type ConfirmEvent = { request: Request; locals: { auth: Auth }; url: URL };
29
-
30
- /**
31
- * POST-confirm verification (C2). Invoked from the confirm page's POST action: proxies the
32
- * token to better-auth's GET verify endpoint via the per-request handler, then forwards the
33
- * resulting Set-Cookie(s) onto a 303 to /admin. Scanners GET the confirm *page* (nothing is
34
- * consumed); only this explicit POST consumes the token.
35
- */
36
- export async function confirmSignIn(event: ConfirmEvent): Promise<Response> {
37
- const form = await event.request.formData();
38
- const token = String(form.get('token') ?? '');
39
- if (!token) throw redirect(303, '/admin/login?error=expired');
40
-
41
- const verifyUrl = `${event.url.origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}&callbackURL=/admin`;
42
- const res = await event.locals.auth.handler(new Request(verifyUrl, { headers: event.request.headers }));
43
- const cookies = res.headers.getSetCookie();
44
- if (cookies.length === 0) throw redirect(303, '/admin/login?error=expired');
45
-
46
- const headers = new Headers({ location: '/admin' });
47
- for (const cookie of cookies) headers.append('set-cookie', cookie);
48
- return new Response(null, { status: 303, headers });
49
- }
50
-
51
- /** Sign out via better-auth, forwarding the session-clearing cookies, then 303 to login. */
52
- export async function signOut(event: { request: Request; locals: { auth: Auth } }): Promise<Response> {
53
- const origin = new URL(event.request.url).origin;
54
- const res = await event.locals.auth.handler(
55
- new Request(`${origin}/api/auth/sign-out`, { method: 'POST', headers: event.request.headers }),
56
- );
57
- const headers = new Headers({ location: '/admin/login' });
58
- for (const cookie of res.headers.getSetCookie()) headers.append('set-cookie', cookie);
59
- return new Response(null, { status: 303, headers });
60
- }
@@ -1,6 +0,0 @@
1
- // Public surface of `@glw907/cairn-cms/auth`: the per-request factory + server-side helpers
2
- // the site route shims and hooks delegate to. The browser client is intentionally NOT here
3
- // (it lives component-local in LoginPage to keep better-auth's deep client types out of dist).
4
- export { createAuth, type Auth, type AuthEnv, type AuthBranding } from './config';
5
- export { loadSession, requireSession, confirmSignIn, signOut, type CairnUser } from './guard';
6
- export { adminsLoad, addAdmin, removeAdmin, setAdminRole, requireOwner, type AdminsData } from './admins';
@@ -1,112 +0,0 @@
1
- import { relations, sql } from "drizzle-orm";
2
- import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
3
-
4
- export const user = sqliteTable("user", {
5
- id: text("id").primaryKey(),
6
- name: text("name").notNull(),
7
- email: text("email").notNull().unique(),
8
- emailVerified: integer("email_verified", { mode: "boolean" })
9
- .default(false)
10
- .notNull(),
11
- image: text("image"),
12
- createdAt: integer("created_at", { mode: "timestamp_ms" })
13
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
14
- .notNull(),
15
- updatedAt: integer("updated_at", { mode: "timestamp_ms" })
16
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
17
- .$onUpdate(() => /* @__PURE__ */ new Date())
18
- .notNull(),
19
- role: text("role"),
20
- banned: integer("banned", { mode: "boolean" }).default(false),
21
- banReason: text("ban_reason"),
22
- banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
23
- });
24
-
25
- export const session = sqliteTable(
26
- "session",
27
- {
28
- id: text("id").primaryKey(),
29
- expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
30
- token: text("token").notNull().unique(),
31
- createdAt: integer("created_at", { mode: "timestamp_ms" })
32
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
33
- .notNull(),
34
- updatedAt: integer("updated_at", { mode: "timestamp_ms" })
35
- .$onUpdate(() => /* @__PURE__ */ new Date())
36
- .notNull(),
37
- ipAddress: text("ip_address"),
38
- userAgent: text("user_agent"),
39
- userId: text("user_id")
40
- .notNull()
41
- .references(() => user.id, { onDelete: "cascade" }),
42
- impersonatedBy: text("impersonated_by"),
43
- },
44
- (table) => [index("session_userId_idx").on(table.userId)],
45
- );
46
-
47
- export const account = sqliteTable(
48
- "account",
49
- {
50
- id: text("id").primaryKey(),
51
- accountId: text("account_id").notNull(),
52
- providerId: text("provider_id").notNull(),
53
- userId: text("user_id")
54
- .notNull()
55
- .references(() => user.id, { onDelete: "cascade" }),
56
- accessToken: text("access_token"),
57
- refreshToken: text("refresh_token"),
58
- idToken: text("id_token"),
59
- accessTokenExpiresAt: integer("access_token_expires_at", {
60
- mode: "timestamp_ms",
61
- }),
62
- refreshTokenExpiresAt: integer("refresh_token_expires_at", {
63
- mode: "timestamp_ms",
64
- }),
65
- scope: text("scope"),
66
- password: text("password"),
67
- createdAt: integer("created_at", { mode: "timestamp_ms" })
68
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
69
- .notNull(),
70
- updatedAt: integer("updated_at", { mode: "timestamp_ms" })
71
- .$onUpdate(() => /* @__PURE__ */ new Date())
72
- .notNull(),
73
- },
74
- (table) => [index("account_userId_idx").on(table.userId)],
75
- );
76
-
77
- export const verification = sqliteTable(
78
- "verification",
79
- {
80
- id: text("id").primaryKey(),
81
- identifier: text("identifier").notNull(),
82
- value: text("value").notNull(),
83
- expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
84
- createdAt: integer("created_at", { mode: "timestamp_ms" })
85
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
86
- .notNull(),
87
- updatedAt: integer("updated_at", { mode: "timestamp_ms" })
88
- .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
89
- .$onUpdate(() => /* @__PURE__ */ new Date())
90
- .notNull(),
91
- },
92
- (table) => [index("verification_identifier_idx").on(table.identifier)],
93
- );
94
-
95
- export const userRelations = relations(user, ({ many }) => ({
96
- sessions: many(session),
97
- accounts: many(account),
98
- }));
99
-
100
- export const sessionRelations = relations(session, ({ one }) => ({
101
- user: one(user, {
102
- fields: [session.userId],
103
- references: [user.id],
104
- }),
105
- }));
106
-
107
- export const accountRelations = relations(account, ({ one }) => ({
108
- user: one(user, {
109
- fields: [account.userId],
110
- references: [user.id],
111
- }),
112
- }));
package/src/lib/carta.ts DELETED
@@ -1,59 +0,0 @@
1
- // cairn-core: pure Carta options/transformer wiring for render-only preview.
2
- //
3
- // Plugins are passed in rather than imported; that seam is what the Pass D adapter formalises.
4
- // No `carta-md` import: its index re-exports Svelte components that the node test env
5
- // can't load. The Svelte component calls `new Carta(previewCartaOptions(...))` directly.
6
- import type { Pluggable, Processor } from 'unified';
7
-
8
- export interface PreviewPlugins {
9
- /** remark plugins, injected after gfm and before remark-rehype. */
10
- remarkPlugins: Pluggable[];
11
- /** rehype plugins, injected after remark-rehype. */
12
- rehypePlugins: Pluggable[];
13
- }
14
-
15
- interface PreviewTransformer {
16
- execution: 'sync';
17
- type: 'remark' | 'rehype';
18
- transform: (ctx: { processor: Processor }) => void;
19
- }
20
-
21
- function phase(plugins: Pluggable[], type: PreviewTransformer['type']): PreviewTransformer[] {
22
- return plugins.map((plugin) => ({
23
- execution: 'sync',
24
- type,
25
- transform: ({ processor }) => {
26
- processor.use([plugin]);
27
- },
28
- }));
29
- }
30
-
31
- /**
32
- * Map the site's plugin set to Carta sync transformers, remark phase before rehype.
33
- * Carta's processor is remarkParse → gfm → [remark] → remark-rehype → [rehype] → stringify,
34
- * so this ordering reproduces render.ts exactly. Pure (no Carta) so it is unit-testable.
35
- */
36
- export function previewTransformers({ remarkPlugins, rehypePlugins }: PreviewPlugins): PreviewTransformer[] {
37
- return [...phase(remarkPlugins, 'remark'), ...phase(rehypePlugins, 'rehype')];
38
- }
39
-
40
- /** Minimal Options subset we populate (avoids importing carta-md, which re-exports Svelte components). */
41
- interface PreviewCartaOptions {
42
- sanitizer: false;
43
- rehypeOptions: { allowDangerousHtml: boolean };
44
- extensions: Array<{ transformers: PreviewTransformer[] }>;
45
- }
46
-
47
- /**
48
- * Carta options for a render-only preview: site plugins wired in, raw HTML allowed, no
49
- * sanitizer. Authors are trusted and the directive pipeline emits intentional raw HTML
50
- * (render.ts uses allowDangerousHtml + rehype-raw); sanitizing here would strip EC
51
- * primitives. The Svelte component passes this to `new Carta(...)`.
52
- */
53
- export function previewCartaOptions(plugins: PreviewPlugins): PreviewCartaOptions {
54
- return {
55
- sanitizer: false,
56
- rehypeOptions: { allowDangerousHtml: true },
57
- extensions: [{ transformers: previewTransformers(plugins) }],
58
- };
59
- }
@@ -1,33 +0,0 @@
1
- <script lang="ts">
2
- // The /admin content list: every collection's files, linking into the editor. Data comes
3
- // from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
4
- // (AdminLayout) owns the chrome (site title, signed-in identity, nav, sign out), so this
5
- // page renders only the content body.
6
- import type { AdminCollectionList } from '../sveltekit';
7
-
8
- interface Props {
9
- data: { collections: AdminCollectionList[] };
10
- }
11
- let { data }: Props = $props();
12
- </script>
13
-
14
- <h1 class="text-2xl font-bold">Content</h1>
15
-
16
- {#each data.collections as collection (collection.type)}
17
- <section class="mt-8">
18
- <h2 class="mb-3 text-lg font-semibold">{collection.label}</h2>
19
- {#if collection.error}
20
- <div class="alert alert-warning">Couldn't load {collection.label.toLowerCase()}: {collection.error}</div>
21
- {:else if collection.files.length === 0}
22
- <p class="opacity-60">No content yet.</p>
23
- {:else}
24
- <ul class="menu rounded-box border border-base-300 bg-base-100 p-2">
25
- {#each collection.files as file (file.path)}
26
- <li>
27
- <a href="/admin/edit/{collection.type}/{file.id}">{file.id}</a>
28
- </li>
29
- {/each}
30
- </ul>
31
- {/if}
32
- </section>
33
- {/each}