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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/auth/crypto.d.ts +13 -0
  2. package/dist/auth/crypto.d.ts.map +1 -0
  3. package/dist/auth/crypto.js +31 -0
  4. package/dist/auth/store.d.ts +41 -0
  5. package/dist/auth/store.d.ts.map +1 -0
  6. package/dist/auth/store.js +115 -0
  7. package/dist/auth/types.d.ts +25 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/components/AdminLayout.svelte +58 -164
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -18
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +36 -20
  14. package/dist/components/ComponentPalette.svelte.d.ts +11 -4
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -1
  16. package/dist/components/ConceptList.svelte +81 -0
  17. package/dist/components/ConceptList.svelte.d.ts +13 -0
  18. package/dist/components/ConceptList.svelte.d.ts.map +1 -0
  19. package/dist/components/ConfirmPage.svelte +23 -20
  20. package/dist/components/ConfirmPage.svelte.d.ts +6 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
  22. package/dist/components/EditPage.svelte +155 -136
  23. package/dist/components/EditPage.svelte.d.ts +16 -8
  24. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  25. package/dist/components/LoginPage.svelte +42 -52
  26. package/dist/components/LoginPage.svelte.d.ts +12 -0
  27. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  28. package/dist/components/ManageEditors.svelte +81 -0
  29. package/dist/components/ManageEditors.svelte.d.ts +23 -0
  30. package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
  31. package/dist/components/MarkdownEditor.svelte +81 -0
  32. package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
  33. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
  34. package/dist/components/NavTree.svelte +73 -63
  35. package/dist/components/NavTree.svelte.d.ts +13 -4
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -1
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +3 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +5 -4
  41. package/dist/content/compose.d.ts +7 -0
  42. package/dist/content/compose.d.ts.map +1 -0
  43. package/dist/content/compose.js +32 -0
  44. package/dist/content/concepts.d.ts +17 -0
  45. package/dist/content/concepts.d.ts.map +1 -0
  46. package/dist/content/concepts.js +41 -0
  47. package/dist/content/frontmatter.d.ts +18 -0
  48. package/dist/content/frontmatter.d.ts.map +1 -0
  49. package/dist/content/frontmatter.js +58 -0
  50. package/dist/content/ids.d.ts +17 -0
  51. package/dist/content/ids.d.ts.map +1 -0
  52. package/dist/content/ids.js +33 -0
  53. package/dist/content/types.d.ts +210 -0
  54. package/dist/content/types.d.ts.map +1 -0
  55. package/dist/content/types.js +1 -0
  56. package/dist/content/validate.d.ts +13 -0
  57. package/dist/content/validate.d.ts.map +1 -0
  58. package/dist/content/validate.js +45 -0
  59. package/dist/email.d.ts +25 -12
  60. package/dist/email.d.ts.map +1 -1
  61. package/dist/email.js +24 -24
  62. package/dist/env.d.ts +24 -0
  63. package/dist/env.d.ts.map +1 -0
  64. package/dist/env.js +29 -0
  65. package/dist/github/credentials.d.ts +12 -0
  66. package/dist/github/credentials.d.ts.map +1 -0
  67. package/dist/github/credentials.js +11 -0
  68. package/dist/github/repo.d.ts +49 -0
  69. package/dist/github/repo.d.ts.map +1 -0
  70. package/dist/github/repo.js +123 -0
  71. package/dist/github/signing.d.ts +17 -0
  72. package/dist/github/signing.d.ts.map +1 -0
  73. package/dist/github/signing.js +79 -0
  74. package/dist/github/types.d.ts +35 -0
  75. package/dist/github/types.d.ts.map +1 -0
  76. package/dist/github/types.js +19 -0
  77. package/dist/index.d.ts +27 -8
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -10
  80. package/dist/{nav.d.ts → nav/site-config.d.ts} +16 -24
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/{nav.js → nav/site-config.js} +27 -13
  83. package/dist/render/glyph.d.ts +1 -1
  84. package/dist/render/glyph.d.ts.map +1 -1
  85. package/dist/render/index.d.ts +5 -5
  86. package/dist/render/index.d.ts.map +1 -1
  87. package/dist/render/index.js +6 -6
  88. package/dist/render/pipeline.d.ts +7 -6
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +5 -5
  91. package/dist/render/registry.d.ts +10 -6
  92. package/dist/render/registry.d.ts.map +1 -1
  93. package/dist/render/registry.js +8 -6
  94. package/dist/render/rehype-dispatch.d.ts +8 -7
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/rehype-dispatch.js +16 -14
  97. package/dist/render/remark-directives.d.ts +1 -1
  98. package/dist/render/remark-directives.d.ts.map +1 -1
  99. package/dist/render/sanitize.d.ts +8 -0
  100. package/dist/render/sanitize.d.ts.map +1 -0
  101. package/dist/render/sanitize.js +26 -0
  102. package/dist/sveltekit/auth-routes.d.ts +23 -0
  103. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  104. package/dist/sveltekit/auth-routes.js +85 -0
  105. package/dist/sveltekit/content-routes.d.ts +80 -0
  106. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  107. package/dist/sveltekit/content-routes.js +183 -0
  108. package/dist/sveltekit/editors-routes.d.ts +24 -0
  109. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  110. package/dist/sveltekit/editors-routes.js +73 -0
  111. package/dist/sveltekit/guard.d.ts +9 -0
  112. package/dist/sveltekit/guard.d.ts.map +1 -0
  113. package/dist/sveltekit/guard.js +43 -0
  114. package/dist/sveltekit/health.d.ts +19 -0
  115. package/dist/sveltekit/health.d.ts.map +1 -0
  116. package/dist/sveltekit/health.js +12 -0
  117. package/dist/sveltekit/index.d.ts +9 -173
  118. package/dist/sveltekit/index.d.ts.map +1 -1
  119. package/dist/sveltekit/index.js +8 -348
  120. package/dist/sveltekit/nav-routes.d.ts +30 -0
  121. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  122. package/dist/sveltekit/nav-routes.js +103 -0
  123. package/dist/sveltekit/types.d.ts +32 -0
  124. package/dist/sveltekit/types.d.ts.map +1 -0
  125. package/dist/sveltekit/types.js +1 -0
  126. package/package.json +33 -57
  127. package/src/lib/auth/crypto.ts +37 -0
  128. package/src/lib/auth/store.ts +158 -0
  129. package/src/lib/auth/types.ts +27 -0
  130. package/src/lib/components/AdminLayout.svelte +58 -164
  131. package/src/lib/components/ComponentPalette.svelte +36 -20
  132. package/src/lib/components/ConceptList.svelte +81 -0
  133. package/src/lib/components/ConfirmPage.svelte +23 -20
  134. package/src/lib/components/EditPage.svelte +155 -136
  135. package/src/lib/components/LoginPage.svelte +42 -52
  136. package/src/lib/components/ManageEditors.svelte +81 -0
  137. package/src/lib/components/MarkdownEditor.svelte +81 -0
  138. package/src/lib/components/NavTree.svelte +73 -63
  139. package/src/lib/components/cairn-admin.css +42 -0
  140. package/src/lib/components/index.ts +5 -4
  141. package/src/lib/content/compose.ts +39 -0
  142. package/src/lib/content/concepts.ts +57 -0
  143. package/src/lib/content/frontmatter.ts +71 -0
  144. package/src/lib/content/ids.ts +38 -0
  145. package/src/lib/content/types.ts +235 -0
  146. package/src/lib/content/validate.ts +51 -0
  147. package/src/lib/email.ts +52 -38
  148. package/src/lib/env.ts +32 -0
  149. package/src/lib/github/credentials.ts +27 -0
  150. package/src/lib/github/repo.ts +138 -0
  151. package/src/lib/github/signing.ts +97 -0
  152. package/src/lib/github/types.ts +46 -0
  153. package/src/lib/index.ts +86 -10
  154. package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
  155. package/src/lib/render/glyph.ts +6 -6
  156. package/src/lib/render/index.ts +6 -6
  157. package/src/lib/render/pipeline.ts +23 -22
  158. package/src/lib/render/registry.ts +35 -26
  159. package/src/lib/render/rehype-dispatch.ts +58 -56
  160. package/src/lib/render/remark-directives.ts +46 -46
  161. package/src/lib/render/sanitize.ts +27 -0
  162. package/src/lib/sveltekit/auth-routes.ts +107 -0
  163. package/src/lib/sveltekit/content-routes.ts +261 -0
  164. package/src/lib/sveltekit/editors-routes.ts +82 -0
  165. package/src/lib/sveltekit/guard.ts +47 -0
  166. package/src/lib/sveltekit/health.ts +24 -0
  167. package/src/lib/sveltekit/index.ts +19 -512
  168. package/src/lib/sveltekit/nav-routes.ts +139 -0
  169. package/src/lib/sveltekit/types.ts +33 -0
  170. package/dist/adapter.d.ts +0 -93
  171. package/dist/adapter.d.ts.map +0 -1
  172. package/dist/adapter.js +0 -30
  173. package/dist/auth/admins.d.ts +0 -33
  174. package/dist/auth/admins.d.ts.map +0 -1
  175. package/dist/auth/admins.js +0 -90
  176. package/dist/auth/capabilities.d.ts +0 -7
  177. package/dist/auth/capabilities.d.ts.map +0 -1
  178. package/dist/auth/capabilities.js +0 -26
  179. package/dist/auth/config.d.ts +0 -2097
  180. package/dist/auth/config.d.ts.map +0 -1
  181. package/dist/auth/config.js +0 -78
  182. package/dist/auth/guard.d.ts +0 -34
  183. package/dist/auth/guard.d.ts.map +0 -1
  184. package/dist/auth/guard.js +0 -47
  185. package/dist/auth/index.d.ts +0 -5
  186. package/dist/auth/index.d.ts.map +0 -1
  187. package/dist/auth/index.js +0 -7
  188. package/dist/auth/schema.d.ts +0 -750
  189. package/dist/auth/schema.d.ts.map +0 -1
  190. package/dist/auth/schema.js +0 -93
  191. package/dist/carta.d.ts +0 -39
  192. package/dist/carta.d.ts.map +0 -1
  193. package/dist/carta.js +0 -30
  194. package/dist/components/CollectionList.svelte +0 -96
  195. package/dist/components/CollectionList.svelte.d.ts +0 -8
  196. package/dist/components/CollectionList.svelte.d.ts.map +0 -1
  197. package/dist/components/ManageAdmins.svelte +0 -84
  198. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  199. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  200. package/dist/content.d.ts +0 -3
  201. package/dist/content.d.ts.map +0 -1
  202. package/dist/content.js +0 -10
  203. package/dist/editor.d.ts +0 -25
  204. package/dist/editor.d.ts.map +0 -1
  205. package/dist/editor.js +0 -20
  206. package/dist/frontmatter.d.ts +0 -3
  207. package/dist/frontmatter.d.ts.map +0 -1
  208. package/dist/frontmatter.js +0 -16
  209. package/dist/github.d.ts +0 -72
  210. package/dist/github.d.ts.map +0 -1
  211. package/dist/github.js +0 -171
  212. package/dist/nav.d.ts.map +0 -1
  213. package/dist/slug.d.ts +0 -7
  214. package/dist/slug.d.ts.map +0 -1
  215. package/dist/slug.js +0 -15
  216. package/dist/utils.d.ts +0 -3
  217. package/dist/utils.d.ts.map +0 -1
  218. package/dist/utils.js +0 -11
  219. package/src/lib/adapter.ts +0 -144
  220. package/src/lib/auth/admins.ts +0 -106
  221. package/src/lib/auth/capabilities.ts +0 -35
  222. package/src/lib/auth/config.ts +0 -108
  223. package/src/lib/auth/guard.ts +0 -60
  224. package/src/lib/auth/index.ts +0 -7
  225. package/src/lib/auth/schema.ts +0 -112
  226. package/src/lib/carta.ts +0 -59
  227. package/src/lib/components/CollectionList.svelte +0 -96
  228. package/src/lib/components/ManageAdmins.svelte +0 -84
  229. package/src/lib/content.ts +0 -11
  230. package/src/lib/editor.ts +0 -38
  231. package/src/lib/frontmatter.ts +0 -17
  232. package/src/lib/github.ts +0 -220
  233. package/src/lib/slug.ts +0 -16
  234. package/src/lib/utils.ts +0 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.5.1",
3
+ "version": "0.6.0-rc.1",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -23,75 +23,47 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "package": "svelte-package",
26
- "package:watch": "svelte-package --watch",
27
- "prepublishOnly": "svelte-package",
26
+ "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
27
+ "prepare": "svelte-package",
28
+ "check": "svelte-check --tsconfig ./tsconfig.json",
28
29
  "test": "vitest run",
29
30
  "test:watch": "vitest",
30
- "auth:schema": "better-auth generate --config auth.cli.ts --output src/lib/auth/schema.ts -y",
31
- "auth:sql": "drizzle-kit generate"
31
+ "test:unit": "vitest run --project unit",
32
+ "test:integration": "vitest run --project integration",
33
+ "test:component": "vitest run --project component"
32
34
  },
33
35
  "exports": {
34
36
  ".": {
35
- "types": "./src/lib/index.ts",
36
- "svelte": "./src/lib/index.ts",
37
- "default": "./src/lib/index.ts"
37
+ "types": "./dist/index.d.ts",
38
+ "svelte": "./dist/index.js",
39
+ "default": "./dist/index.js"
38
40
  },
39
41
  "./sveltekit": {
40
- "types": "./src/lib/sveltekit/index.ts",
41
- "svelte": "./src/lib/sveltekit/index.ts",
42
- "default": "./src/lib/sveltekit/index.ts"
42
+ "types": "./dist/sveltekit/index.d.ts",
43
+ "svelte": "./dist/sveltekit/index.js",
44
+ "default": "./dist/sveltekit/index.js"
43
45
  },
44
46
  "./components": {
45
- "types": "./src/lib/components/index.ts",
46
- "svelte": "./src/lib/components/index.ts",
47
- "default": "./src/lib/components/index.ts"
48
- },
49
- "./auth": {
50
- "types": "./src/lib/auth/index.ts",
51
- "svelte": "./src/lib/auth/index.ts",
52
- "default": "./src/lib/auth/index.ts"
47
+ "types": "./dist/components/index.d.ts",
48
+ "svelte": "./dist/components/index.js",
49
+ "default": "./dist/components/index.js"
53
50
  },
54
51
  "./package.json": "./package.json"
55
52
  },
56
- "publishConfig": {
57
- "exports": {
58
- ".": {
59
- "types": "./dist/index.d.ts",
60
- "svelte": "./dist/index.js",
61
- "default": "./dist/index.js"
62
- },
63
- "./sveltekit": {
64
- "types": "./dist/sveltekit/index.d.ts",
65
- "svelte": "./dist/sveltekit/index.js",
66
- "default": "./dist/sveltekit/index.js"
67
- },
68
- "./components": {
69
- "types": "./dist/components/index.d.ts",
70
- "svelte": "./dist/components/index.js",
71
- "default": "./dist/components/index.js"
72
- },
73
- "./auth": {
74
- "types": "./dist/auth/index.d.ts",
75
- "svelte": "./dist/auth/index.js",
76
- "default": "./dist/auth/index.js"
77
- },
78
- "./package.json": "./package.json"
79
- }
80
- },
81
53
  "files": [
82
54
  "dist",
83
55
  "src/lib"
84
56
  ],
85
57
  "peerDependencies": {
86
58
  "@sveltejs/kit": "^2",
87
- "better-auth": "^1.6",
88
59
  "carta-md": "^4.11",
89
- "drizzle-orm": ">=0.40 <1",
90
60
  "svelte": "^5.0.0"
91
61
  },
92
62
  "dependencies": {
63
+ "@rodrigodagostino/svelte-sortable-list": "^2.1.17",
93
64
  "@types/hast": "^3.0.4",
94
65
  "@types/mdast": "^4.0.4",
66
+ "dompurify": "^3.4.7",
95
67
  "gray-matter": "^4",
96
68
  "hastscript": "^9.0.1",
97
69
  "mdast-util-directive": "^3.1.0",
@@ -107,20 +79,24 @@
107
79
  "yaml": "^2"
108
80
  },
109
81
  "devDependencies": {
110
- "@better-auth/cli": "^1.4.21",
111
- "@cloudflare/workers-types": "^4.20260405.1",
112
- "@sveltejs/kit": "^2",
82
+ "@arethetypeswrong/cli": "^0.18.3",
83
+ "@cloudflare/vitest-pool-workers": "^0.16",
84
+ "@cloudflare/workers-types": "^4.20260501.0",
85
+ "@sveltejs/kit": "^2.61",
113
86
  "@sveltejs/package": "^2",
114
- "@sveltejs/vite-plugin-svelte": "^7",
115
- "@types/better-sqlite3": "^7.6.13",
116
- "better-auth": "^1.6.11",
117
- "better-sqlite3": "^12.10.0",
87
+ "@sveltejs/vite-plugin-svelte": "^7.1",
88
+ "@types/node": "^22.19.19",
89
+ "@vitest/browser": "^4.1.7",
90
+ "@vitest/browser-playwright": "^4.1.7",
118
91
  "carta-md": "^4.11",
119
- "drizzle-kit": "^0.31.10",
120
- "drizzle-orm": "^0.45.2",
121
- "svelte": "^5",
92
+ "playwright": "^1.60.0",
93
+ "publint": "^0.3.21",
94
+ "svelte": "^5.55",
122
95
  "svelte-check": "^4",
123
96
  "typescript": "^6.0.3",
124
- "vitest": "^4.1.6"
97
+ "vite": "^8.0",
98
+ "vitest": "^4.1",
99
+ "vitest-browser-svelte": "^2.1.1",
100
+ "wrangler": "^4"
125
101
  }
126
102
  }
@@ -0,0 +1,37 @@
1
+ // Token and session-id generation plus SHA-256 token hashing, on Web Crypto so the
2
+ // code runs unchanged in workerd. The store keeps only the hash of a token, never the
3
+ // token itself (spec 7.1).
4
+
5
+ /** The session cookie name. */
6
+ export const COOKIE_NAME = 'cairn_session';
7
+
8
+ /** Magic-link tokens live 10 minutes. */
9
+ export const TOKEN_TTL_MS = 10 * 60 * 1000;
10
+
11
+ /** Sessions live 30 days. */
12
+ export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
+
14
+ function randomBase64Url(byteLength = 32): string {
15
+ const bytes = new Uint8Array(byteLength);
16
+ crypto.getRandomValues(bytes);
17
+ let binary = '';
18
+ for (const b of bytes) binary += String.fromCharCode(b);
19
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
20
+ }
21
+
22
+ /** A fresh 256-bit magic-link token, url-safe. */
23
+ export function generateToken(): string {
24
+ return randomBase64Url(32);
25
+ }
26
+
27
+ /** A fresh 256-bit session id, url-safe. */
28
+ export function generateSessionId(): string {
29
+ return randomBase64Url(32);
30
+ }
31
+
32
+ /** The lowercase hex SHA-256 of a token, for storage and lookup. */
33
+ export async function hashToken(token: string): Promise<string> {
34
+ const data = new TextEncoder().encode(token);
35
+ const digest = await crypto.subtle.digest('SHA-256', data);
36
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
37
+ }
@@ -0,0 +1,158 @@
1
+ // D1 access for auth, through prepared statements only. No ORM. Each function takes the
2
+ // `AUTH_DB` binding plus primitives, so it is testable against a real local D1 and free of
3
+ // SvelteKit. Callers pass `now`/`expiresAt` in epoch milliseconds.
4
+ import type { D1Database } from '@cloudflare/workers-types';
5
+ import type { Editor, Role } from './types.js';
6
+
7
+ type EditorCols = { email: string; display_name: string; role: Role };
8
+
9
+ function toEditor(row: EditorCols): Editor {
10
+ return { email: row.email, displayName: row.display_name, role: row.role };
11
+ }
12
+
13
+ /** Look an email up in the allowlist. */
14
+ export async function findEditor(db: D1Database, email: string): Promise<Editor | null> {
15
+ const row = await db
16
+ .prepare('SELECT email, display_name, role FROM editor WHERE email = ?')
17
+ .bind(email)
18
+ .first<EditorCols>();
19
+ return row ? toEditor(row) : null;
20
+ }
21
+
22
+ /** Replace any prior token for this email with a fresh one, atomically. */
23
+ export async function issueToken(
24
+ db: D1Database,
25
+ email: string,
26
+ tokenHash: string,
27
+ expiresAt: number,
28
+ now: number,
29
+ ): Promise<void> {
30
+ await db.batch([
31
+ db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
32
+ db
33
+ .prepare('INSERT INTO magic_token (token_hash, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
34
+ .bind(tokenHash, email, expiresAt, now),
35
+ ]);
36
+ }
37
+
38
+ /**
39
+ * Consume a token in one atomic statement. A returned email means the token was present and
40
+ * unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
41
+ */
42
+ export async function consumeToken(db: D1Database, tokenHash: string, now: number): Promise<string | null> {
43
+ const row = await db
44
+ .prepare('DELETE FROM magic_token WHERE token_hash = ? AND expires_at > ? RETURNING email')
45
+ .bind(tokenHash, now)
46
+ .first<{ email: string }>();
47
+ return row?.email ?? null;
48
+ }
49
+
50
+ /** Create a session row. */
51
+ export async function createSession(
52
+ db: D1Database,
53
+ id: string,
54
+ email: string,
55
+ expiresAt: number,
56
+ now: number,
57
+ ): Promise<void> {
58
+ await db
59
+ .prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
60
+ .bind(id, email, expiresAt, now)
61
+ .run();
62
+ }
63
+
64
+ /**
65
+ * Resolve a session to its editor, joining `editor` so the role is read live. An expired
66
+ * session or a removed editor resolves to null, which revokes access on the next request.
67
+ */
68
+ export async function resolveSession(db: D1Database, id: string, now: number): Promise<Editor | null> {
69
+ const row = await db
70
+ .prepare(
71
+ `SELECT e.email AS email, e.display_name AS display_name, e.role AS role
72
+ FROM session s JOIN editor e ON e.email = s.email
73
+ WHERE s.id = ? AND s.expires_at > ?`,
74
+ )
75
+ .bind(id, now)
76
+ .first<EditorCols>();
77
+ return row ? toEditor(row) : null;
78
+ }
79
+
80
+ /** Delete a session (logout). */
81
+ export async function deleteSession(db: D1Database, id: string): Promise<void> {
82
+ await db.prepare('DELETE FROM session WHERE id = ?').bind(id).run();
83
+ }
84
+
85
+ /** The full allowlist, sorted by email. */
86
+ export async function listEditors(db: D1Database): Promise<Editor[]> {
87
+ const { results } = await db
88
+ .prepare('SELECT email, display_name, role FROM editor ORDER BY email')
89
+ .all<EditorCols>();
90
+ return results.map(toEditor);
91
+ }
92
+
93
+ /** Add an editor to the allowlist. */
94
+ export async function insertEditor(
95
+ db: D1Database,
96
+ email: string,
97
+ displayName: string,
98
+ role: Role,
99
+ now: number,
100
+ ): Promise<void> {
101
+ await db
102
+ .prepare('INSERT INTO editor (email, display_name, role, created_at) VALUES (?, ?, ?, ?)')
103
+ .bind(email, displayName, role, now)
104
+ .run();
105
+ }
106
+
107
+ /** Remove an editor and cut their live access (sessions and any pending token go too). */
108
+ export async function deleteEditor(db: D1Database, email: string): Promise<void> {
109
+ await db.batch([
110
+ db.prepare('DELETE FROM session WHERE email = ?').bind(email),
111
+ db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
112
+ db.prepare('DELETE FROM editor WHERE email = ?').bind(email),
113
+ ]);
114
+ }
115
+
116
+ /**
117
+ * Remove an owner only if another owner remains. The count is part of the DELETE, so two
118
+ * concurrent removals cannot both pass a separate check and strand the allowlist at zero
119
+ * owners. Returns false (and writes nothing) when this is the last owner. On success the
120
+ * editor's sessions and pending token go too.
121
+ */
122
+ export async function removeOwnerIfNotLast(db: D1Database, email: string): Promise<boolean> {
123
+ const res = await db
124
+ .prepare(
125
+ `DELETE FROM editor
126
+ WHERE email = ? AND role = 'owner'
127
+ AND (SELECT COUNT(*) FROM editor WHERE role = 'owner') > 1`,
128
+ )
129
+ .bind(email)
130
+ .run();
131
+ if (res.meta.changes !== 1) return false;
132
+ await db.batch([
133
+ db.prepare('DELETE FROM session WHERE email = ?').bind(email),
134
+ db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
135
+ ]);
136
+ return true;
137
+ }
138
+
139
+ /** Change an editor's role. The guard reads the new role on the next request. */
140
+ export async function setEditorRole(db: D1Database, email: string, role: Role): Promise<void> {
141
+ await db.prepare('UPDATE editor SET role = ? WHERE email = ?').bind(role, email).run();
142
+ }
143
+
144
+ /**
145
+ * Demote an owner to editor only if another owner remains, in one atomic statement (see
146
+ * `removeOwnerIfNotLast`). Returns false (and writes nothing) when this is the last owner.
147
+ */
148
+ export async function demoteOwnerIfNotLast(db: D1Database, email: string): Promise<boolean> {
149
+ const res = await db
150
+ .prepare(
151
+ `UPDATE editor SET role = 'editor'
152
+ WHERE email = ? AND role = 'owner'
153
+ AND (SELECT COUNT(*) FROM editor WHERE role = 'owner') > 1`,
154
+ )
155
+ .bind(email)
156
+ .run();
157
+ return res.meta.changes === 1;
158
+ }
@@ -0,0 +1,27 @@
1
+ import type { D1Database } from '@cloudflare/workers-types';
2
+
3
+ export type Role = 'owner' | 'editor';
4
+
5
+ /** The session shape the whole admin reads: guard, loads, content fns, manage-editors. */
6
+ export interface Editor {
7
+ email: string;
8
+ displayName: string;
9
+ role: Role;
10
+ }
11
+
12
+ /** Worker bindings and vars the auth layer reads; a structural subset of `Platform.env`. */
13
+ export interface AuthEnv {
14
+ AUTH_DB?: D1Database;
15
+ /** Canonical origin for confirmation links, never read from a request header (spec 7.1, risk H3). */
16
+ PUBLIC_ORIGIN?: string;
17
+ /** Cloudflare Email Sending binding. */
18
+ EMAIL?: {
19
+ send(message: {
20
+ to: string;
21
+ from: string;
22
+ subject: string;
23
+ html: string;
24
+ text: string;
25
+ }): Promise<void>;
26
+ };
27
+ }
@@ -1,186 +1,80 @@
1
+ <!--
2
+ @component
3
+ The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
4
+ data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
5
+ root sets `data-theme="cairn-admin"` and imports the self-contained Warm Stone theme, so the
6
+ admin looks identical on every host regardless of the site's own theme.
7
+ -->
1
8
  <script lang="ts">
2
- // Neutral admin chrome shared across sites. Signed in: DaisyUI drawer+navbar shell (sidebar
3
- // pinned on desktop, slide-over on mobile). Signed out: minimal centered shell. The
4
- // `cairn-admin` class on both roots scopes the "Warm Stone" theme; see the style block.
5
9
  import type { Snippet } from 'svelte';
6
- import type { CairnUser } from '../auth';
10
+ import type { LayoutData } from '../sveltekit/content-routes.js';
11
+ import './cairn-admin.css';
7
12
 
8
- let {
9
- data,
10
- children,
11
- }: {
12
- data: {
13
- siteName: string;
14
- user: CairnUser | null;
15
- pathname: string;
16
- collections: { type: string; label: string }[];
17
- navMenus: { name: string; label: string }[];
18
- canManageNav: boolean;
19
- };
13
+ interface Props {
14
+ /** The layout load's data: site name, user, nav concepts, active path, owner capability. */
15
+ data: LayoutData;
16
+ /** The page body. */
20
17
  children: Snippet;
21
- } = $props();
18
+ }
19
+
20
+ let { data, children }: Props = $props();
22
21
 
23
22
  interface NavItem {
24
23
  href: string;
25
24
  label: string;
26
- icon: Snippet;
27
- active: boolean;
28
- /** Owner-only surface; hidden from regular editors. */
29
25
  owner?: boolean;
30
26
  }
31
27
 
32
- const nav = $derived<NavItem[]>([
33
- ...data.collections.map((collection) => ({
34
- href: `/admin/${collection.type}`,
35
- label: collection.label,
36
- icon: contentIcon,
37
- active:
38
- data.pathname === `/admin/${collection.type}` ||
39
- data.pathname.startsWith(`/admin/edit/${collection.type}/`),
40
- })),
41
- ...(data.canManageNav && data.navMenus.length
42
- ? [{ href: '/admin/nav', label: 'Navigation', icon: navIcon, active: data.pathname.startsWith('/admin/nav') }]
43
- : []),
44
- {
45
- href: '/admin/admins',
46
- label: 'Editors',
47
- icon: editorsIcon,
48
- owner: true,
49
- active: data.pathname.startsWith('/admin/admins'),
50
- },
28
+ const navItems: NavItem[] = $derived([
29
+ ...data.concepts.map((c) => ({ href: `/admin/${c.id}`, label: c.label })),
30
+ ...(data.navLabel ? [{ href: '/admin/nav', label: data.navLabel }] : []),
31
+ { href: '/admin/editors', label: 'Editors', owner: true },
51
32
  ]);
52
- const visibleNav = $derived(nav.filter((item) => !item.owner || data.user?.role === 'owner'));
53
33
 
54
- // Close the slide-over after a nav tap on mobile.
55
- function closeDrawer(): void {
56
- const toggle = document.getElementById('admin-drawer');
57
- if (toggle instanceof HTMLInputElement) toggle.checked = false;
34
+ const visibleNav = $derived(navItems.filter((item) => !item.owner || data.canManageEditors));
35
+
36
+ function isActive(href: string): boolean {
37
+ return data.pathname === href || data.pathname.startsWith(`${href}/`);
58
38
  }
59
39
  </script>
60
40
 
61
- {#snippet contentIcon()}
62
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
63
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
64
- d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
65
- </svg>
66
- {/snippet}
67
-
68
- {#snippet editorsIcon()}
69
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
70
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
71
- d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
72
- </svg>
73
- {/snippet}
74
-
75
- {#snippet navIcon()}
76
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
77
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
78
- d="M4 6h16M4 12h16M4 18h16" />
79
- </svg>
80
- {/snippet}
81
-
82
- <svelte:head>
83
- <meta name="robots" content="noindex, nofollow" />
84
- </svelte:head>
85
-
86
- {#if data.user}
87
- <div class="cairn-admin drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
88
- <input id="admin-drawer" type="checkbox" class="drawer-toggle" />
89
-
90
- <div class="drawer-content">
91
- <!-- Mobile top bar; the desktop sidebar replaces this at lg. -->
92
- <div class="navbar bg-base-100 lg:hidden">
93
- <div class="flex-1">
94
- <span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
95
- </div>
96
- <div class="flex-none">
97
- <label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
98
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
99
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
100
- </svg>
101
- </label>
102
- </div>
41
+ <div data-theme="cairn-admin" class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
42
+ <input id="cairn-drawer" type="checkbox" class="drawer-toggle" />
43
+
44
+ <div class="drawer-content flex flex-col">
45
+ <div class="navbar bg-base-100 border-b border-base-300">
46
+ <div class="flex-none lg:hidden">
47
+ <label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
48
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
49
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
50
+ </svg>
51
+ </label>
103
52
  </div>
104
-
105
- <main class="container px-4 py-6 lg:px-8">
106
- {@render children()}
107
- </main>
53
+ <div class="flex-1 px-2 font-semibold">{data.siteName}</div>
54
+ <div class="flex-none px-2 text-sm text-[var(--color-muted)]">{data.user.displayName}</div>
108
55
  </div>
109
56
 
110
- <div class="drawer-side z-10">
111
- <label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
112
- <div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
113
- <ul class="menu menu-lg grow p-4">
114
- <li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
115
- <span class="grow">{data.siteName} CMS</span>
116
- <label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
117
- </li>
118
- {#each visibleNav as item (item.href)}
119
- <li>
120
- <a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
121
- {@render item.icon()}
122
- {item.label}
123
- </a>
124
- </li>
125
- {/each}
126
- </ul>
127
-
128
- <div class="border-t border-base-300 p-4">
129
- <p class="text-sm font-medium">{data.user.name}</p>
130
- <p class="text-xs opacity-60">{data.user.email}</p>
131
- <form method="POST" action="/admin/auth/logout" class="mt-3">
132
- <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
133
- </form>
134
- </div>
135
- </div>
136
- </div>
137
- </div>
138
- {:else}
139
- <!-- Signed out (login page): no nav, just a centered surface. -->
140
- <div class="cairn-admin min-h-screen bg-base-200" data-pagefind-ignore>
141
- <div class="mx-auto max-w-3xl px-4 py-8">
57
+ <main class="flex-1 p-4 lg:p-8">
142
58
  {@render children()}
143
- </div>
59
+ </main>
144
60
  </div>
145
- {/if}
146
-
147
- <style>
148
- /* Warm Stone: a neutral, fully self-contained admin theme (R6), light-only. Overriding the
149
- DaisyUI v5 tokens + font on this root re-skins the whole admin subtree by inheritance, so
150
- the tool looks identical on every host regardless of the site's own theme. Values are OKLCH
151
- (no hex/rgb, per the design-system rule). Warm-gray neutrals (hue ~75), violet accent. */
152
- .cairn-admin {
153
- color-scheme: light;
154
- font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
155
-
156
- --color-base-100: oklch(98.5% 0.004 75);
157
- --color-base-200: oklch(96% 0.005 75);
158
- --color-base-300: oklch(92% 0.008 75);
159
- --color-base-content: oklch(28% 0.012 75);
160
61
 
161
- --color-primary: oklch(52% 0.20 293);
162
- --color-primary-content: oklch(98% 0.012 293);
163
- --color-secondary: oklch(45% 0.02 75);
164
- --color-secondary-content: oklch(98% 0.004 75);
165
- --color-accent: oklch(58% 0.16 300);
166
- --color-accent-content: oklch(98% 0.012 300);
167
- --color-neutral: oklch(32% 0.012 75);
168
- --color-neutral-content: oklch(96% 0.004 75);
169
-
170
- --color-info: oklch(60% 0.12 240);
171
- --color-info-content: oklch(98% 0.01 240);
172
- --color-success: oklch(58% 0.12 150);
173
- --color-success-content: oklch(98% 0.01 150);
174
- --color-warning: oklch(75% 0.15 70);
175
- --color-warning-content: oklch(25% 0.02 70);
176
- --color-error: oklch(58% 0.20 25);
177
- --color-error-content: oklch(98% 0.01 25);
178
-
179
- --radius-selector: 0.5rem;
180
- --radius-field: 0.5rem;
181
- --radius-box: 0.75rem;
182
- --size-selector: 0.25rem;
183
- --size-field: 0.25rem;
184
- --border: 1px;
185
- }
186
- </style>
62
+ <div class="drawer-side">
63
+ <label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
64
+ <nav class="bg-base-100 min-h-full w-64 border-r border-base-300 p-4" aria-label="Site content">
65
+ <div class="menu-title mb-2 px-2 text-xs uppercase tracking-wide text-[var(--color-muted)]">Content</div>
66
+ <ul class="menu menu-lg w-full">
67
+ {#each visibleNav as item (item.href)}
68
+ <li>
69
+ <a href={item.href} class:menu-active={isActive(item.href)} aria-current={isActive(item.href) ? 'page' : undefined}>
70
+ {item.label}
71
+ </a>
72
+ </li>
73
+ {/each}
74
+ </ul>
75
+ <form method="POST" action="/admin/auth/logout" class="mt-6 px-2">
76
+ <button type="submit" class="btn btn-ghost btn-sm btn-block">Sign out</button>
77
+ </form>
78
+ </nav>
79
+ </div>
80
+ </div>