@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/dist/github.js DELETED
@@ -1,171 +0,0 @@
1
- // cairn-core: read and write repository content through the GitHub API.
2
- //
3
- // Reads (Pass B) list a collection directory and fetch a file's raw markdown; the token
4
- // is optional because ecnordic's repo is public. Writes (Pass C) mint a short-lived
5
- // GitHub App installation token (App JWT, RS256 signed with Web Crypto, no octokit
6
- // dependency) and commit through the contents API with author = editor, committer = the
7
- // App (cairn-cms[bot]). The same token also lifts reads to the authenticated rate limit
8
- // and unlocks private repos (e.g. 907-life).
9
- import { bytesToB64url } from './utils';
10
- const API = 'https://api.github.com';
11
- function ghHeaders(accept, token) {
12
- const headers = {
13
- Accept: accept,
14
- 'User-Agent': 'cairn-cms',
15
- 'X-GitHub-Api-Version': '2022-11-28',
16
- };
17
- if (token)
18
- headers.Authorization = `Bearer ${token}`;
19
- return headers;
20
- }
21
- /** Build the contents-API URL for a repo path, pinned to the configured branch. */
22
- export function contentsUrl(repo, path) {
23
- const clean = path.replace(/^\/+|\/+$/g, '');
24
- return `${API}/repos/${repo.owner}/${repo.repo}/contents/${clean}?ref=${encodeURIComponent(repo.branch)}`;
25
- }
26
- /** Keep only markdown files from a contents-API directory listing, newest id first. */
27
- export function markdownFiles(entries) {
28
- return entries
29
- .filter((entry) => entry.type === 'file' && entry.name.endsWith('.md'))
30
- .map((entry) => ({ id: entry.name.replace(/\.md$/, ''), name: entry.name, path: entry.path }))
31
- .sort((a, b) => b.id.localeCompare(a.id));
32
- }
33
- /** List the markdown files in a collection directory. */
34
- export async function listMarkdown(repo, dir, token) {
35
- const res = await fetch(contentsUrl(repo, dir), { headers: ghHeaders('application/vnd.github+json', token) });
36
- if (!res.ok)
37
- throw new Error(`GitHub list ${dir} failed: ${res.status}`);
38
- return markdownFiles((await res.json()));
39
- }
40
- /** Fetch a file's raw markdown, or null if it does not exist. */
41
- export async function readRaw(repo, path, token) {
42
- const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github.raw', token) });
43
- if (res.status === 404)
44
- return null;
45
- if (!res.ok)
46
- throw new Error(`GitHub read ${path} failed: ${res.status}`);
47
- return res.text();
48
- }
49
- // --- Write path: GitHub App auth + commit (Pass C) -------------------------------------
50
- const encoder = new TextEncoder();
51
- // TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
52
- // Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
53
- function buf(bytes) {
54
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
55
- }
56
- /** DER length octets for a value of `n` bytes (short form < 128, else long form). */
57
- function derLength(n) {
58
- if (n < 0x80)
59
- return [n];
60
- const out = [];
61
- for (let v = n; v > 0; v >>= 8)
62
- out.unshift(v & 0xff);
63
- return [0x80 | out.length, ...out];
64
- }
65
- // AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
66
- const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
67
- /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
68
- function pkcs1ToPkcs8(pkcs1) {
69
- const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
70
- const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
71
- return Uint8Array.from([0x30, ...derLength(body.length), ...body]);
72
- }
73
- /** Decode a PEM private key to PKCS#8 DER, converting from PKCS#1 (GitHub's format) if needed. */
74
- function pemToPkcs8(pem) {
75
- const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
76
- const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
77
- return pem.includes('RSA PRIVATE KEY') ? pkcs1ToPkcs8(der) : der;
78
- }
79
- /** Mint a GitHub App JWT (RS256), valid ~9 min, with `iat` backdated for clock skew. */
80
- export async function appJwt(appId, privateKeyPem) {
81
- const now = Math.floor(Date.now() / 1000);
82
- const header = bytesToB64url(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
83
- const payload = bytesToB64url(encoder.encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })));
84
- const signingInput = `${header}.${payload}`;
85
- const key = await crypto.subtle.importKey('pkcs8', buf(pemToPkcs8(privateKeyPem)), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
86
- const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, buf(encoder.encode(signingInput)));
87
- return `${signingInput}.${bytesToB64url(new Uint8Array(sig))}`;
88
- }
89
- /** Exchange the App JWT for a short-lived installation access token. */
90
- export async function installationToken(creds) {
91
- const jwt = await appJwt(creds.appId, atob(creds.privateKeyB64));
92
- const res = await fetch(`${API}/app/installations/${creds.installationId}/access_tokens`, {
93
- method: 'POST',
94
- headers: ghHeaders('application/vnd.github+json', jwt),
95
- });
96
- if (!res.ok)
97
- throw new Error(`GitHub installation token failed: ${res.status}`);
98
- return (await res.json()).token;
99
- }
100
- /** Standard (padded) base64 of UTF-8 text, as the contents API expects. */
101
- function toBase64(text) {
102
- return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
103
- }
104
- /** The current blob sha for a path, or null if the file does not yet exist. */
105
- export async function fileSha(repo, path, token) {
106
- const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github+json', token) });
107
- if (res.status === 404)
108
- return null;
109
- if (!res.ok)
110
- throw new Error(`GitHub stat ${path} failed: ${res.status}`);
111
- return (await res.json()).sha;
112
- }
113
- /**
114
- * A concurrent edit lost the SHA race (C3): the file changed between the read and the PUT,
115
- * from another editor or the site's own CI. Thrown so callers can fail safe (re-fetch and ask
116
- * the editor to reapply) instead of surfacing a raw 409. Defined and caught inside the package
117
- * so `instanceof` is reliable (no peer-boundary identity split, unlike kit's `redirect`/`error`).
118
- */
119
- export class CommitConflictError extends Error {
120
- path;
121
- constructor(path) {
122
- super(`Commit conflict on ${path}: it changed since it was opened`);
123
- this.path = path;
124
- this.name = 'CommitConflictError';
125
- }
126
- }
127
- /**
128
- * Commit `content` to `path` on the configured branch via the contents API. Author is the
129
- * editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
130
- * the file in place when it exists (passing its sha), creates it otherwise. Returns the
131
- * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`.
132
- */
133
- export async function commitFile(repo, path, content, opts, token) {
134
- const sha = await fileSha(repo, path, token);
135
- const url = `${API}/repos/${repo.owner}/${repo.repo}/contents/${path.replace(/^\/+|\/+$/g, '')}`;
136
- const res = await fetch(url, {
137
- method: 'PUT',
138
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
139
- body: JSON.stringify({
140
- message: opts.message,
141
- content: toBase64(content),
142
- branch: repo.branch,
143
- author: opts.author,
144
- ...(sha ? { sha } : {}),
145
- }),
146
- });
147
- // 409 = the blob sha we read is no longer current. Fail safe: the caller re-fetches and the
148
- // editor reapplies. (Full three-way merge stays out of scope; see ARCHITECTURE §5.)
149
- if (res.status === 409)
150
- throw new CommitConflictError(path);
151
- if (!res.ok)
152
- throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
153
- return (await res.json()).commit.sha;
154
- }
155
- /**
156
- * Deploy-time self-test for the GitHub App signer (M2): sign a dummy JWT with the configured
157
- * private key. Exercises the brittle PKCS#1→PKCS#8 conversion + Web Crypto import/sign without
158
- * any network call or secret in the result, so `/admin/healthz` catches a bad/rotated key
159
- * before an editor's save fails. Returns `{ ok: false, detail }` rather than throwing.
160
- */
161
- export async function signingSelfTest(appId, privateKeyB64) {
162
- try {
163
- const jwt = await appJwt(appId, atob(privateKeyB64));
164
- if (jwt.split('.').length !== 3)
165
- return { ok: false, detail: 'malformed JWT' };
166
- return { ok: true };
167
- }
168
- catch (err) {
169
- return { ok: false, detail: err instanceof Error ? err.message : 'sign failed' };
170
- }
171
- }
package/dist/nav.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"nav.d.ts","sourceRoot":"","sources":["../src/lib/nav.ts"],"names":[],"mappings":"AAOA,8GAA8G;AAC9G,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,+EAA+E;AAC/E,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAuB3E;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,MAAM,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,QAAQ,CAAC,EAAE;QACT,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,qBAAqB,CAAC,EAAE,MAAM,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED,uGAAuG;AACvG,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAUvD;AAED,uGAAuG;AACvG,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAIzF;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,MAAM,CAO1E"}
package/dist/slug.d.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Lowercase a title into a filename-safe slug stem.
3
- * Apostrophes are dropped so "Geoff's" becomes "geoffs" (no spurious hyphen).
4
- * All other non-alphanumeric runs become a single hyphen; leading/trailing hyphens are trimmed.
5
- */
6
- export declare function slugify(title: string): string;
7
- //# sourceMappingURL=slug.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"slug.d.ts","sourceRoot":"","sources":["../src/lib/slug.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C"}
package/dist/slug.js DELETED
@@ -1,15 +0,0 @@
1
- // cairn-core: derive a filename-safe slug stem from a human title, for the create-entry form.
2
- // The admin is filename-based (Pass E): this produces the editable stem an author can adjust,
3
- // matching the server-side SLUG_RE (lowercase alphanumerics and internal hyphens). Pure.
4
- /**
5
- * Lowercase a title into a filename-safe slug stem.
6
- * Apostrophes are dropped so "Geoff's" becomes "geoffs" (no spurious hyphen).
7
- * All other non-alphanumeric runs become a single hyphen; leading/trailing hyphens are trimmed.
8
- */
9
- export function slugify(title) {
10
- return title
11
- .toLowerCase()
12
- .replace(/'/g, '')
13
- .replace(/[^a-z0-9]+/g, '-')
14
- .replace(/^-+|-+$/g, '');
15
- }
package/dist/utils.d.ts DELETED
@@ -1,3 +0,0 @@
1
- /** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
2
- export declare function bytesToB64url(bytes: Uint8Array): string;
3
- //# sourceMappingURL=utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.ts"],"names":[],"mappings":"AAOA,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAGvD"}
package/dist/utils.js DELETED
@@ -1,11 +0,0 @@
1
- // cairn-core: internal encoding helpers shared across modules.
2
- //
3
- // Deliberately NOT re-exported from index.ts. These are implementation details of the
4
- // auth/github crypto, not part of the public API (auth.ts signs tokens, github.ts builds
5
- // the App JWT; both need base64url). Keeping them here stops bytesToB64url leaking through
6
- // the `export *` barrel.
7
- /** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
8
- export function bytesToB64url(bytes) {
9
- const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
10
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
11
- }
@@ -1,144 +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
- /**
57
- * Editing shape. `story` (the default when absent) is a dated feed entry; `page` is a
58
- * navigation-placed entry with a path-like slug and no date emphasis. Drives the create
59
- * form and the editor header. Never gates editing capability: the palette and toolbar are
60
- * available to both. (Pass K, R4.)
61
- */
62
- kind?: 'page' | 'story';
63
- /** Repo-relative folder holding the collection's markdown files. */
64
- dir: string;
65
- /** Editor form fields, rendered in order. */
66
- fields: CairnField[];
67
- /** Validate raw frontmatter (from the form) into the on-disk object, throwing on error. */
68
- validate(data: Record<string, unknown>, source: string): object;
69
- }
70
-
71
- /** A managed navigation menu, read from and committed to the site's YAML config file. */
72
- export interface NavMenuConfig {
73
- /** Repo-relative path to the site-config YAML, e.g. 'src/lib/site.config.yaml'. */
74
- configPath: string;
75
- /** Key within the file's `menus` map, e.g. 'primary'. */
76
- menuName: string;
77
- /** Sidebar/admin label for the menu. */
78
- label: string;
79
- /** Max nesting depth allowed in the editor (1 = flat). Defaults to 2. */
80
- maxDepth?: number;
81
- }
82
-
83
- export interface CairnAdapter {
84
- /** Branding + magic-link email copy. */
85
- siteName: string;
86
- /** From: address for magic-link email (must be a domain-authenticated sender). */
87
- sender: string;
88
- /** The repository the admin reads content from and commits to. */
89
- backend: RepoRef;
90
- /** Site plugin set for the Carta preview (parity with the live render). */
91
- preview: PreviewPlugins;
92
- collections: CairnCollection[];
93
- /**
94
- * The site's component registry: the single declaration of its directive
95
- * components (R10a). Rendering parity already flows through `preview`; this
96
- * exposes the same registry so the editor's insert-component palette can read
97
- * `registry.defs`. Optional: a site with no rich components (e.g. 907.life) may
98
- * omit it or supply an empty registry.
99
- */
100
- registry?: ComponentRegistry;
101
- /**
102
- * The navigation menu this site manages from `/admin/nav` (R3/Pass L2). The menu lives in the
103
- * site's git-committed YAML config (read at build time by the layout, committed back by the
104
- * editor). Omit to hide the nav surface, the same opt-in shape as `registry`.
105
- */
106
- navMenu?: NavMenuConfig;
107
- }
108
-
109
- /** Look up a collection by its route segment, or undefined if the segment is unknown. */
110
- export function findCollection(adapter: CairnAdapter, type: string): CairnCollection | undefined {
111
- return adapter.collections.find((collection) => collection.type === type);
112
- }
113
-
114
- /** Read raw frontmatter from a submitted form, decoding each value per its field type. */
115
- export function frontmatterFromForm(
116
- collection: CairnCollection,
117
- form: FormData,
118
- ): Record<string, unknown> {
119
- const data: Record<string, unknown> = {};
120
- for (const field of collection.fields) {
121
- switch (field.type) {
122
- case 'boolean':
123
- data[field.name] = form.get(field.name) === 'on';
124
- break;
125
- case 'tags':
126
- data[field.name] = form.getAll(field.name).map(String);
127
- break;
128
- case 'freetags':
129
- // One comma-separated input → trimmed, de-duplicated, non-empty tags.
130
- data[field.name] = [
131
- ...new Set(
132
- String(form.get(field.name) ?? '')
133
- .split(',')
134
- .map((tag) => tag.trim())
135
- .filter(Boolean),
136
- ),
137
- ];
138
- break;
139
- default:
140
- data[field.name] = form.get(field.name);
141
- }
142
- }
143
- return data;
144
- }
@@ -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,35 +0,0 @@
1
- // cairn-core: capability checks. Management surfaces gate on a capability, not on a role name,
2
- // so the two-tier owner/editor model can grow finer capabilities (and a future role) additively.
3
- // Creating a page and changing the nav are structural acts, so they sit with owner; editing a
4
- // page's content and running the story feed are everyday editor work.
5
- import { error } from '@sveltejs/kit';
6
- import type { CairnUser } from './guard';
7
-
8
- export type Capability =
9
- | 'story:create'
10
- | 'story:edit'
11
- | 'page:edit'
12
- | 'page:create'
13
- | 'nav:manage'
14
- | 'user:manage';
15
-
16
- // One source of truth. `'all'` means every capability; otherwise the explicit grant list. A future
17
- // `manager` role is one more row here, no call-site changes.
18
- const CAPS_BY_ROLE: Record<CairnUser['role'], readonly Capability[] | 'all'> = {
19
- owner: 'all',
20
- editor: ['story:create', 'story:edit', 'page:edit'],
21
- };
22
-
23
- /** Does this user hold the capability? A signed-out (null) user holds nothing. */
24
- export function can(user: CairnUser | null, cap: Capability): boolean {
25
- if (!user) return false;
26
- const grants = CAPS_BY_ROLE[user.role];
27
- return grants === 'all' || grants.includes(cap);
28
- }
29
-
30
- /** Assert the capability for a route load/action: 401 when signed out, 403 when under-privileged. */
31
- export function requireCapability(user: CairnUser | null, cap: Capability): CairnUser {
32
- if (!user) throw error(401, 'Not signed in');
33
- if (!can(user, cap)) throw error(403, 'You do not have permission to do that');
34
- return user;
35
- }
@@ -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>;