@glw907/cairn-cms 0.3.1 → 0.5.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.
- package/README.md +17 -9
- package/dist/adapter.d.ts +10 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/auth/admins.d.ts +33 -0
- package/dist/auth/admins.d.ts.map +1 -0
- package/dist/auth/admins.js +90 -0
- package/dist/auth/config.d.ts +2097 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/config.js +78 -0
- package/dist/auth/guard.d.ts +34 -0
- package/dist/auth/guard.d.ts.map +1 -0
- package/dist/auth/guard.js +47 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/schema.d.ts +750 -0
- package/dist/auth/schema.d.ts.map +1 -0
- package/dist/auth/schema.js +93 -0
- package/dist/carta.d.ts +1 -1
- package/dist/carta.d.ts.map +1 -1
- package/dist/components/AdminLayout.svelte +9 -9
- package/dist/components/AdminLayout.svelte.d.ts +2 -2
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/AdminList.svelte +1 -1
- package/dist/components/ConfirmPage.svelte +31 -0
- package/dist/components/ConfirmPage.svelte.d.ts +11 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +5 -5
- package/dist/components/LoginPage.svelte +35 -18
- package/dist/components/LoginPage.svelte.d.ts +0 -2
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageAdmins.svelte +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +19 -11
- package/dist/github.d.ts +22 -2
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +40 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/render/glyph.d.ts +6 -0
- package/dist/render/glyph.d.ts.map +1 -0
- package/dist/render/glyph.js +5 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/pipeline.d.ts +16 -0
- package/dist/render/pipeline.d.ts.map +1 -0
- package/dist/render/pipeline.js +29 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +11 -0
- package/dist/render/rehype-dispatch.d.ts +24 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -0
- package/dist/render/rehype-dispatch.js +86 -0
- package/dist/render/remark-directives.d.ts +4 -0
- package/dist/render/remark-directives.d.ts.map +1 -0
- package/dist/render/remark-directives.js +74 -0
- package/dist/sveltekit/index.d.ts +20 -58
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +35 -152
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -2
- package/package.json +48 -6
- package/src/lib/adapter.ts +12 -3
- package/src/lib/auth/admins.ts +106 -0
- package/src/lib/auth/config.ts +108 -0
- package/src/lib/auth/guard.ts +60 -0
- package/src/lib/auth/index.ts +6 -0
- package/src/lib/auth/schema.ts +112 -0
- package/src/lib/carta.ts +2 -2
- package/src/lib/components/AdminLayout.svelte +9 -9
- package/src/lib/components/AdminList.svelte +1 -1
- package/src/lib/components/ConfirmPage.svelte +31 -0
- package/src/lib/components/EditPage.svelte +5 -5
- package/src/lib/components/LoginPage.svelte +35 -18
- package/src/lib/components/ManageAdmins.svelte +1 -1
- package/src/lib/components/index.ts +1 -0
- package/src/lib/email.ts +18 -11
- package/src/lib/github.ts +38 -6
- package/src/lib/index.ts +3 -2
- package/src/lib/render/glyph.ts +14 -0
- package/src/lib/render/index.ts +8 -0
- package/src/lib/render/pipeline.ts +37 -0
- package/src/lib/render/registry.ts +36 -0
- package/src/lib/render/rehype-dispatch.ts +97 -0
- package/src/lib/render/remark-directives.ts +71 -0
- package/src/lib/sveltekit/index.ts +59 -227
- package/src/lib/utils.ts +2 -2
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js +0 -132
- package/src/lib/auth.ts +0 -185
package/dist/sveltekit/index.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
// cairn-core: the SvelteKit route server logic, extracted so each site's `admin/**`
|
|
2
|
-
// files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
|
|
1
|
+
// cairn-core: the SvelteKit content-route server logic, extracted so each site's `admin/**`
|
|
2
|
+
// route files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
|
|
3
3
|
//
|
|
4
4
|
// SvelteKit's filesystem routing requires the route *files* to live in each site's
|
|
5
|
-
// `src/routes/`, but their bodies are identical across sites
|
|
5
|
+
// `src/routes/`, but their bodies are identical across sites. Only the adapter differs.
|
|
6
6
|
// These functions take the SvelteKit event (typed structurally, to avoid depending on the
|
|
7
7
|
// site-generated `App.*` ambient types) plus the site `CairnAdapter`, and throw
|
|
8
|
-
// `redirect`/`error` from `@sveltejs/kit
|
|
9
|
-
//
|
|
8
|
+
// `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
|
|
9
|
+
// class identity with the host's runtime; otherwise the redirect 500s). Auth/session/manage-editors
|
|
10
|
+
// logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
|
|
10
11
|
import { redirect, error } from '@sveltejs/kit';
|
|
11
12
|
import matter from 'gray-matter';
|
|
12
|
-
import {
|
|
13
|
-
import { sendMagicLink } from '../email';
|
|
14
|
-
import { listMarkdown, readRaw, commitFile, installationToken } from '../github';
|
|
13
|
+
import { listMarkdown, readRaw, commitFile, installationToken, signingSelfTest, CommitConflictError, } from '../github';
|
|
15
14
|
import { serializeMarkdown } from '../content';
|
|
16
15
|
import { findCollection, frontmatterFromForm } from '../adapter';
|
|
17
|
-
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
18
16
|
/**
|
|
19
17
|
* Mint a GitHub App installation token for *reads* when the App is configured, else undefined
|
|
20
18
|
* (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
|
|
21
19
|
* reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
|
|
22
|
-
* A mint failure degrades gracefully to anonymous rather than 500ing
|
|
20
|
+
* A mint failure degrades gracefully to anonymous rather than 500ing. Unlike the commit path,
|
|
23
21
|
* where a missing App is fatal, a read can still succeed unauthenticated.
|
|
24
22
|
*/
|
|
25
23
|
async function readToken(env) {
|
|
@@ -40,13 +38,13 @@ async function readToken(env) {
|
|
|
40
38
|
}
|
|
41
39
|
/**
|
|
42
40
|
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
43
|
-
* its plugin graph into client bundles
|
|
41
|
+
* its plugin graph into client bundles; the import stays server-side in the layout load.
|
|
44
42
|
* `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
|
|
45
43
|
* (those kit virtual modules have no types outside a kit app, so they can't live in the
|
|
46
44
|
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
47
45
|
*/
|
|
48
46
|
export function adminLayoutLoad(event, adapter) {
|
|
49
|
-
return {
|
|
47
|
+
return { user: event.locals.user, siteName: adapter.siteName, pathname: event.url.pathname };
|
|
50
48
|
}
|
|
51
49
|
/** List every collection's markdown files. A failed listing degrades to an inline error. */
|
|
52
50
|
export async function adminListLoad(event, adapter) {
|
|
@@ -62,12 +60,6 @@ export async function adminListLoad(event, adapter) {
|
|
|
62
60
|
}));
|
|
63
61
|
return { collections };
|
|
64
62
|
}
|
|
65
|
-
export function loginLoad(event) {
|
|
66
|
-
return {
|
|
67
|
-
sent: event.url.searchParams.get('sent') === '1',
|
|
68
|
-
error: event.url.searchParams.get('error'),
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
63
|
export async function editLoad(event, adapter) {
|
|
72
64
|
const collection = findCollection(adapter, event.params.type);
|
|
73
65
|
if (!collection)
|
|
@@ -93,70 +85,10 @@ export async function editLoad(event, adapter) {
|
|
|
93
85
|
error: event.url.searchParams.get('error'),
|
|
94
86
|
};
|
|
95
87
|
}
|
|
96
|
-
// ── /admin/auth/request (POST) ──────────────────────────────────────────────
|
|
97
|
-
export async function authRequest(event, adapter) {
|
|
98
|
-
const env = event.platform?.env;
|
|
99
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.EMAIL) {
|
|
100
|
-
throw redirect(303, '/admin/login?error=config');
|
|
101
|
-
}
|
|
102
|
-
const form = await event.request.formData();
|
|
103
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
104
|
-
if (!EMAIL_RE.test(email)) {
|
|
105
|
-
throw redirect(303, '/admin/login?error=invalid');
|
|
106
|
-
}
|
|
107
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
108
|
-
if (!editor) {
|
|
109
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
110
|
-
}
|
|
111
|
-
const token = await createMagicLink(email, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
112
|
-
// PUBLIC_ORIGIN overrides url.origin for local dev (where wrangler's custom-domain
|
|
113
|
-
// route makes url.origin the production host); unset in prod → url.origin is correct.
|
|
114
|
-
const origin = env.PUBLIC_ORIGIN || event.url.origin;
|
|
115
|
-
const link = `${origin}/admin/auth/callback?token=${encodeURIComponent(token)}`;
|
|
116
|
-
try {
|
|
117
|
-
await sendMagicLink(env.EMAIL, email, link, adapter.siteName, adapter.sender);
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
console.error('magic-link send failed:', err);
|
|
121
|
-
throw redirect(303, '/admin/login?error=config');
|
|
122
|
-
}
|
|
123
|
-
throw redirect(303, '/admin/login?sent=1');
|
|
124
|
-
}
|
|
125
|
-
// ── /admin/auth/callback (GET) ──────────────────────────────────────────────
|
|
126
|
-
export async function authCallback(event) {
|
|
127
|
-
const env = event.platform?.env;
|
|
128
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.SESSION_SECRET) {
|
|
129
|
-
throw redirect(303, '/admin/login?error=config');
|
|
130
|
-
}
|
|
131
|
-
const token = event.url.searchParams.get('token') ?? '';
|
|
132
|
-
const email = await redeemMagicToken(token, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
133
|
-
if (!email) {
|
|
134
|
-
throw redirect(303, '/admin/login?error=expired');
|
|
135
|
-
}
|
|
136
|
-
// Re-check the allowlist at redemption — membership may have changed since issue.
|
|
137
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
138
|
-
if (!editor) {
|
|
139
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
140
|
-
}
|
|
141
|
-
const session = await createSession(editor, env.SESSION_SECRET);
|
|
142
|
-
event.cookies.set(SESSION_COOKIE, session, {
|
|
143
|
-
path: '/',
|
|
144
|
-
httpOnly: true,
|
|
145
|
-
secure: event.url.protocol === 'https:',
|
|
146
|
-
sameSite: 'lax',
|
|
147
|
-
maxAge: SESSION_MAX_AGE,
|
|
148
|
-
});
|
|
149
|
-
throw redirect(303, '/admin');
|
|
150
|
-
}
|
|
151
|
-
// ── /admin/auth/logout (POST) ───────────────────────────────────────────────
|
|
152
|
-
export function logout(event) {
|
|
153
|
-
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
154
|
-
throw redirect(303, '/admin/login');
|
|
155
|
-
}
|
|
156
88
|
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
157
89
|
export async function saveCommit(event, adapter) {
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
90
|
+
const user = event.locals.user;
|
|
91
|
+
if (!user)
|
|
160
92
|
throw error(401, 'Not signed in');
|
|
161
93
|
const env = event.platform?.env;
|
|
162
94
|
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
@@ -185,82 +117,33 @@ export async function saveCommit(event, adapter) {
|
|
|
185
117
|
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
186
118
|
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
187
119
|
});
|
|
188
|
-
|
|
120
|
+
try {
|
|
121
|
+
await commitFile(adapter.backend, `${collection.dir}/${id}.md`, markdown, { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } }, token);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
// Concurrent-edit 409 (C3): fail safe. Bounce back with a reload prompt; the editor reloads
|
|
125
|
+
// the current version and reapplies. Any other error is unexpected, so rethrow.
|
|
126
|
+
if (err instanceof CommitConflictError) {
|
|
127
|
+
const message = 'This file changed since you opened it. Reload and reapply your edits.';
|
|
128
|
+
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
189
132
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
190
133
|
}
|
|
191
|
-
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
192
134
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
135
|
+
* Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
|
|
136
|
+
* the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
|
|
137
|
+
* `/admin` guard (signed-in editors only); returns ok/fail with no secret in the body.
|
|
195
138
|
*/
|
|
196
|
-
function
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
throw error(403, 'Owner access required');
|
|
202
|
-
return editor;
|
|
203
|
-
}
|
|
204
|
-
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
205
|
-
function ownerKv(event) {
|
|
206
|
-
const kv = event.platform?.env?.AUTH_KV;
|
|
207
|
-
if (!kv)
|
|
208
|
-
throw error(500, 'Editor allowlist is not configured');
|
|
209
|
-
return kv;
|
|
210
|
-
}
|
|
211
|
-
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
212
|
-
export async function adminsLoad(event) {
|
|
213
|
-
const owner = requireOwner(event);
|
|
214
|
-
const admins = await listEditors(ownerKv(event));
|
|
215
|
-
return {
|
|
216
|
-
admins,
|
|
217
|
-
self: owner.email,
|
|
218
|
-
saved: event.url.searchParams.get('saved') === '1',
|
|
219
|
-
error: event.url.searchParams.get('error'),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
function parseRole(value) {
|
|
223
|
-
return value === 'owner' ? 'owner' : 'editor';
|
|
224
|
-
}
|
|
225
|
-
/** Add (or update) an allowlist entry. Owner-only. */
|
|
226
|
-
export async function addAdmin(event) {
|
|
227
|
-
requireOwner(event);
|
|
228
|
-
const kv = ownerKv(event);
|
|
229
|
-
const form = await event.request.formData();
|
|
230
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
231
|
-
const name = String(form.get('name') ?? '').trim();
|
|
232
|
-
if (!EMAIL_RE.test(email) || !name) {
|
|
233
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
234
|
-
}
|
|
235
|
-
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
236
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
237
|
-
}
|
|
238
|
-
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
239
|
-
export async function removeAdmin(event) {
|
|
240
|
-
const owner = requireOwner(event);
|
|
241
|
-
const kv = ownerKv(event);
|
|
242
|
-
const form = await event.request.formData();
|
|
243
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
244
|
-
if (email === owner.email) {
|
|
245
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
246
|
-
}
|
|
247
|
-
await removeEditor(email, kv);
|
|
248
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
249
|
-
}
|
|
250
|
-
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
251
|
-
export async function setAdminRole(event) {
|
|
252
|
-
const owner = requireOwner(event);
|
|
253
|
-
const kv = ownerKv(event);
|
|
254
|
-
const form = await event.request.formData();
|
|
255
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
256
|
-
const role = parseRole(form.get('role'));
|
|
257
|
-
if (email === owner.email && role !== 'owner') {
|
|
258
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
139
|
+
export async function healthLoad(event) {
|
|
140
|
+
const env = event.platform?.env;
|
|
141
|
+
let githubAppSigning;
|
|
142
|
+
if (env?.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
143
|
+
githubAppSigning = await signingSelfTest(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY_B64);
|
|
259
144
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
145
|
+
else {
|
|
146
|
+
githubAppSigning = { ok: false, detail: 'GitHub App not configured' };
|
|
263
147
|
}
|
|
264
|
-
|
|
265
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
148
|
+
return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
|
|
266
149
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/** Encode bytes as unpadded base64url (RFC 4648 §5)
|
|
1
|
+
/** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
|
|
2
2
|
export declare function bytesToB64url(bytes: Uint8Array): string;
|
|
3
3
|
//# sourceMappingURL=utils.d.ts.map
|
package/dist/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.ts"],"names":[],"mappings":"AAOA,
|
|
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
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// cairn-core: internal encoding helpers shared across modules.
|
|
2
2
|
//
|
|
3
|
-
// Deliberately NOT re-exported from index.ts
|
|
3
|
+
// Deliberately NOT re-exported from index.ts. These are implementation details of the
|
|
4
4
|
// auth/github crypto, not part of the public API (auth.ts signs tokens, github.ts builds
|
|
5
5
|
// the App JWT; both need base64url). Keeping them here stops bytesToB64url leaking through
|
|
6
6
|
// the `export *` barrel.
|
|
7
|
-
/** Encode bytes as unpadded base64url (RFC 4648 §5)
|
|
7
|
+
/** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
|
|
8
8
|
export function bytesToB64url(bytes) {
|
|
9
9
|
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
|
|
10
10
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,13 +9,22 @@
|
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/glw907/cairn-cms.git"
|
|
11
11
|
},
|
|
12
|
-
"keywords": [
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cms",
|
|
14
|
+
"sveltekit",
|
|
15
|
+
"cloudflare",
|
|
16
|
+
"github",
|
|
17
|
+
"magic-link",
|
|
18
|
+
"markdown"
|
|
19
|
+
],
|
|
13
20
|
"scripts": {
|
|
14
21
|
"package": "svelte-package",
|
|
15
22
|
"package:watch": "svelte-package --watch",
|
|
16
23
|
"prepublishOnly": "svelte-package",
|
|
17
24
|
"test": "vitest run",
|
|
18
|
-
"test:watch": "vitest"
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"auth:schema": "better-auth generate --config auth.cli.ts --output src/lib/auth/schema.ts -y",
|
|
27
|
+
"auth:sql": "drizzle-kit generate"
|
|
19
28
|
},
|
|
20
29
|
"exports": {
|
|
21
30
|
".": {
|
|
@@ -33,6 +42,11 @@
|
|
|
33
42
|
"svelte": "./src/lib/components/index.ts",
|
|
34
43
|
"default": "./src/lib/components/index.ts"
|
|
35
44
|
},
|
|
45
|
+
"./auth": {
|
|
46
|
+
"types": "./src/lib/auth/index.ts",
|
|
47
|
+
"svelte": "./src/lib/auth/index.ts",
|
|
48
|
+
"default": "./src/lib/auth/index.ts"
|
|
49
|
+
},
|
|
36
50
|
"./package.json": "./package.json"
|
|
37
51
|
},
|
|
38
52
|
"publishConfig": {
|
|
@@ -52,28 +66,56 @@
|
|
|
52
66
|
"svelte": "./dist/components/index.js",
|
|
53
67
|
"default": "./dist/components/index.js"
|
|
54
68
|
},
|
|
69
|
+
"./auth": {
|
|
70
|
+
"types": "./dist/auth/index.d.ts",
|
|
71
|
+
"svelte": "./dist/auth/index.js",
|
|
72
|
+
"default": "./dist/auth/index.js"
|
|
73
|
+
},
|
|
55
74
|
"./package.json": "./package.json"
|
|
56
75
|
}
|
|
57
76
|
},
|
|
58
|
-
"files": [
|
|
77
|
+
"files": [
|
|
78
|
+
"dist",
|
|
79
|
+
"src/lib"
|
|
80
|
+
],
|
|
59
81
|
"peerDependencies": {
|
|
60
82
|
"@sveltejs/kit": "^2",
|
|
83
|
+
"better-auth": "^1.6",
|
|
61
84
|
"carta-md": "^4.11",
|
|
85
|
+
"drizzle-orm": ">=0.40 <1",
|
|
62
86
|
"svelte": "^5.0.0"
|
|
63
87
|
},
|
|
64
88
|
"dependencies": {
|
|
65
|
-
"
|
|
89
|
+
"@types/hast": "^3.0.4",
|
|
90
|
+
"@types/mdast": "^4.0.4",
|
|
91
|
+
"gray-matter": "^4",
|
|
92
|
+
"hastscript": "^9.0.1",
|
|
93
|
+
"mdast-util-directive": "^3.1.0",
|
|
94
|
+
"rehype-raw": "^7.0.0",
|
|
95
|
+
"rehype-slug": "^6.0.0",
|
|
96
|
+
"rehype-stringify": "^10.0.1",
|
|
97
|
+
"remark-directive": "^4.0.0",
|
|
98
|
+
"remark-gfm": "^4",
|
|
99
|
+
"remark-parse": "^11.0.0",
|
|
100
|
+
"remark-rehype": "^11.1.2",
|
|
101
|
+
"unified": "^11.0.5",
|
|
102
|
+
"unist-util-visit": "^5.1.0"
|
|
66
103
|
},
|
|
67
104
|
"devDependencies": {
|
|
105
|
+
"@better-auth/cli": "^1.4.21",
|
|
68
106
|
"@cloudflare/workers-types": "^4.20260405.1",
|
|
69
107
|
"@sveltejs/kit": "^2",
|
|
70
108
|
"@sveltejs/package": "^2",
|
|
71
109
|
"@sveltejs/vite-plugin-svelte": "^7",
|
|
110
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
111
|
+
"better-auth": "^1.6.11",
|
|
112
|
+
"better-sqlite3": "^12.10.0",
|
|
72
113
|
"carta-md": "^4.11",
|
|
114
|
+
"drizzle-kit": "^0.31.10",
|
|
115
|
+
"drizzle-orm": "^0.45.2",
|
|
73
116
|
"svelte": "^5",
|
|
74
117
|
"svelte-check": "^4",
|
|
75
118
|
"typescript": "^6.0.3",
|
|
76
|
-
"unified": "^11.0.5",
|
|
77
119
|
"vitest": "^4.1.6"
|
|
78
120
|
}
|
|
79
121
|
}
|
package/src/lib/adapter.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// This is the single seam that lets one admin surface serve different designs. A site
|
|
4
4
|
// supplies a `CairnAdapter` (see `src/lib/cairn.config.ts`) describing its backend repo,
|
|
5
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
|
|
6
|
+
// plugin set. cairn-core never hard-codes a collection, tag, or directive; it reads them
|
|
7
7
|
// from the adapter. Field descriptors are plain data so a load function can hand them to
|
|
8
|
-
// the editor form across the server
|
|
8
|
+
// the editor form across the server-to-client boundary.
|
|
9
9
|
import type { PreviewPlugins } from './carta';
|
|
10
10
|
import type { RepoRef } from './github';
|
|
11
|
+
import type { ComponentRegistry } from './render';
|
|
11
12
|
|
|
12
13
|
interface FieldBase {
|
|
13
14
|
/** Frontmatter key and form input name. */
|
|
@@ -63,13 +64,21 @@ export interface CairnCollection {
|
|
|
63
64
|
export interface CairnAdapter {
|
|
64
65
|
/** Branding + magic-link email copy. */
|
|
65
66
|
siteName: string;
|
|
66
|
-
/** From: address for magic-link email
|
|
67
|
+
/** From: address for magic-link email (must be a domain-authenticated sender). */
|
|
67
68
|
sender: string;
|
|
68
69
|
/** The repository the admin reads content from and commits to. */
|
|
69
70
|
backend: RepoRef;
|
|
70
71
|
/** Site plugin set for the Carta preview (parity with the live render). */
|
|
71
72
|
preview: PreviewPlugins;
|
|
72
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;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/** Look up a collection by its route segment, or undefined if the segment is unknown. */
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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>;
|