@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// cairn-core: server-side auth helpers the site route shims delegate to. Each takes the
|
|
2
|
+
// SvelteKit event, typed structurally so the package never depends on a site's generated
|
|
3
|
+
// `App.*` ambient types, plus the per-request `Auth` from `locals`.
|
|
4
|
+
import { redirect } from '@sveltejs/kit';
|
|
5
|
+
import type { Auth } from './config';
|
|
6
|
+
|
|
7
|
+
/** The session shape the whole admin reads: layout, guards, content fns, manage-editors. */
|
|
8
|
+
export interface CairnUser {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
name: string;
|
|
12
|
+
role: 'owner' | 'editor';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read the better-auth session into a cairn user (or null). */
|
|
16
|
+
export async function loadSession(auth: Auth, request: Request): Promise<CairnUser | null> {
|
|
17
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
18
|
+
if (!session?.user) return null;
|
|
19
|
+
const u = session.user as { id: string; email: string; name: string; role?: string | null };
|
|
20
|
+
return { id: u.id, email: u.email, name: u.name, role: u.role === 'owner' ? 'owner' : 'editor' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireSession(user: CairnUser | null): CairnUser {
|
|
24
|
+
if (!user) throw redirect(303, '/admin/login');
|
|
25
|
+
return user;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ConfirmEvent = { request: Request; locals: { auth: Auth }; url: URL };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* POST-confirm verification (C2). Invoked from the confirm page's POST action: proxies the
|
|
32
|
+
* token to better-auth's GET verify endpoint via the per-request handler, then forwards the
|
|
33
|
+
* resulting Set-Cookie(s) onto a 303 to /admin. Scanners GET the confirm *page* (nothing is
|
|
34
|
+
* consumed); only this explicit POST consumes the token.
|
|
35
|
+
*/
|
|
36
|
+
export async function confirmSignIn(event: ConfirmEvent): Promise<Response> {
|
|
37
|
+
const form = await event.request.formData();
|
|
38
|
+
const token = String(form.get('token') ?? '');
|
|
39
|
+
if (!token) throw redirect(303, '/admin/login?error=expired');
|
|
40
|
+
|
|
41
|
+
const verifyUrl = `${event.url.origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}&callbackURL=/admin`;
|
|
42
|
+
const res = await event.locals.auth.handler(new Request(verifyUrl, { headers: event.request.headers }));
|
|
43
|
+
const cookies = res.headers.getSetCookie();
|
|
44
|
+
if (cookies.length === 0) throw redirect(303, '/admin/login?error=expired');
|
|
45
|
+
|
|
46
|
+
const headers = new Headers({ location: '/admin' });
|
|
47
|
+
for (const cookie of cookies) headers.append('set-cookie', cookie);
|
|
48
|
+
return new Response(null, { status: 303, headers });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Sign out via better-auth, forwarding the session-clearing cookies, then 303 to login. */
|
|
52
|
+
export async function signOut(event: { request: Request; locals: { auth: Auth } }): Promise<Response> {
|
|
53
|
+
const origin = new URL(event.request.url).origin;
|
|
54
|
+
const res = await event.locals.auth.handler(
|
|
55
|
+
new Request(`${origin}/api/auth/sign-out`, { method: 'POST', headers: event.request.headers }),
|
|
56
|
+
);
|
|
57
|
+
const headers = new Headers({ location: '/admin/login' });
|
|
58
|
+
for (const cookie of res.headers.getSetCookie()) headers.append('set-cookie', cookie);
|
|
59
|
+
return new Response(null, { status: 303, headers });
|
|
60
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public surface of `@glw907/cairn-cms/auth`: the per-request factory + server-side helpers
|
|
2
|
+
// the site route shims and hooks delegate to. The browser client is intentionally NOT here
|
|
3
|
+
// (it lives component-local in LoginPage to keep better-auth's deep client types out of dist).
|
|
4
|
+
export { createAuth, type Auth, type AuthEnv, type AuthBranding } from './config';
|
|
5
|
+
export { loadSession, requireSession, confirmSignIn, signOut, type CairnUser } from './guard';
|
|
6
|
+
export { adminsLoad, addAdmin, removeAdmin, setAdminRole, requireOwner, type AdminsData } from './admins';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { relations, sql } from "drizzle-orm";
|
|
2
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
3
|
+
|
|
4
|
+
export const user = sqliteTable("user", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
name: text("name").notNull(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
emailVerified: integer("email_verified", { mode: "boolean" })
|
|
9
|
+
.default(false)
|
|
10
|
+
.notNull(),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
13
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
14
|
+
.notNull(),
|
|
15
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
16
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
17
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
18
|
+
.notNull(),
|
|
19
|
+
role: text("role"),
|
|
20
|
+
banned: integer("banned", { mode: "boolean" }).default(false),
|
|
21
|
+
banReason: text("ban_reason"),
|
|
22
|
+
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const session = sqliteTable(
|
|
26
|
+
"session",
|
|
27
|
+
{
|
|
28
|
+
id: text("id").primaryKey(),
|
|
29
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
30
|
+
token: text("token").notNull().unique(),
|
|
31
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
32
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
33
|
+
.notNull(),
|
|
34
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
35
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
36
|
+
.notNull(),
|
|
37
|
+
ipAddress: text("ip_address"),
|
|
38
|
+
userAgent: text("user_agent"),
|
|
39
|
+
userId: text("user_id")
|
|
40
|
+
.notNull()
|
|
41
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
42
|
+
impersonatedBy: text("impersonated_by"),
|
|
43
|
+
},
|
|
44
|
+
(table) => [index("session_userId_idx").on(table.userId)],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export const account = sqliteTable(
|
|
48
|
+
"account",
|
|
49
|
+
{
|
|
50
|
+
id: text("id").primaryKey(),
|
|
51
|
+
accountId: text("account_id").notNull(),
|
|
52
|
+
providerId: text("provider_id").notNull(),
|
|
53
|
+
userId: text("user_id")
|
|
54
|
+
.notNull()
|
|
55
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
56
|
+
accessToken: text("access_token"),
|
|
57
|
+
refreshToken: text("refresh_token"),
|
|
58
|
+
idToken: text("id_token"),
|
|
59
|
+
accessTokenExpiresAt: integer("access_token_expires_at", {
|
|
60
|
+
mode: "timestamp_ms",
|
|
61
|
+
}),
|
|
62
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
|
63
|
+
mode: "timestamp_ms",
|
|
64
|
+
}),
|
|
65
|
+
scope: text("scope"),
|
|
66
|
+
password: text("password"),
|
|
67
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
68
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
69
|
+
.notNull(),
|
|
70
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
71
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
72
|
+
.notNull(),
|
|
73
|
+
},
|
|
74
|
+
(table) => [index("account_userId_idx").on(table.userId)],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const verification = sqliteTable(
|
|
78
|
+
"verification",
|
|
79
|
+
{
|
|
80
|
+
id: text("id").primaryKey(),
|
|
81
|
+
identifier: text("identifier").notNull(),
|
|
82
|
+
value: text("value").notNull(),
|
|
83
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
84
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
85
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
86
|
+
.notNull(),
|
|
87
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
88
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
89
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
90
|
+
.notNull(),
|
|
91
|
+
},
|
|
92
|
+
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const userRelations = relations(user, ({ many }) => ({
|
|
96
|
+
sessions: many(session),
|
|
97
|
+
accounts: many(account),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
export const sessionRelations = relations(session, ({ one }) => ({
|
|
101
|
+
user: one(user, {
|
|
102
|
+
fields: [session.userId],
|
|
103
|
+
references: [user.id],
|
|
104
|
+
}),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
export const accountRelations = relations(account, ({ one }) => ({
|
|
108
|
+
user: one(user, {
|
|
109
|
+
fields: [account.userId],
|
|
110
|
+
references: [user.id],
|
|
111
|
+
}),
|
|
112
|
+
}));
|
package/src/lib/carta.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// cairn-core: pure Carta options/transformer wiring for render-only preview.
|
|
2
2
|
//
|
|
3
|
-
// Plugins are passed in
|
|
3
|
+
// Plugins are passed in rather than imported; that seam is what the Pass D adapter formalises.
|
|
4
4
|
// No `carta-md` import: its index re-exports Svelte components that the node test env
|
|
5
5
|
// can't load. The Svelte component calls `new Carta(previewCartaOptions(...))` directly.
|
|
6
6
|
import type { Pluggable, Processor } from 'unified';
|
|
@@ -37,7 +37,7 @@ export function previewTransformers({ remarkPlugins, rehypePlugins }: PreviewPlu
|
|
|
37
37
|
return [...phase(remarkPlugins, 'remark'), ...phase(rehypePlugins, 'rehype')];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Minimal Options subset we populate
|
|
40
|
+
/** Minimal Options subset we populate (avoids importing carta-md, which re-exports Svelte components). */
|
|
41
41
|
interface PreviewCartaOptions {
|
|
42
42
|
sanitizer: false;
|
|
43
43
|
rehypeOptions: { allowDangerousHtml: boolean };
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
|
|
3
3
|
// adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
|
|
4
|
-
// (`drawer lg:drawer-open
|
|
4
|
+
// (`drawer lg:drawer-open`, sidebar pinned on desktop, slide-over + hamburger on mobile),
|
|
5
5
|
// patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
|
|
6
6
|
// role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
|
|
7
7
|
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
8
|
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
9
9
|
import type { Snippet } from 'svelte';
|
|
10
|
-
import type {
|
|
10
|
+
import type { CairnUser } from '../auth';
|
|
11
11
|
|
|
12
12
|
let {
|
|
13
13
|
data,
|
|
14
14
|
children,
|
|
15
15
|
}: {
|
|
16
|
-
data: { siteName: string;
|
|
16
|
+
data: { siteName: string; user: CairnUser | null; pathname: string };
|
|
17
17
|
children: Snippet;
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
label: string;
|
|
23
23
|
icon: Snippet;
|
|
24
24
|
active: boolean;
|
|
25
|
-
/** Owner-only surface
|
|
25
|
+
/** Owner-only surface; hidden from regular editors. */
|
|
26
26
|
owner?: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
active: data.pathname.startsWith('/admin/admins'),
|
|
42
42
|
},
|
|
43
43
|
]);
|
|
44
|
-
const visibleNav = $derived(nav.filter((item) => !item.owner || data.
|
|
44
|
+
const visibleNav = $derived(nav.filter((item) => !item.owner || data.user?.role === 'owner'));
|
|
45
45
|
|
|
46
46
|
// Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
|
|
47
47
|
function closeDrawer(): void {
|
|
@@ -68,12 +68,12 @@
|
|
|
68
68
|
<meta name="robots" content="noindex, nofollow" />
|
|
69
69
|
</svelte:head>
|
|
70
70
|
|
|
71
|
-
{#if data.
|
|
71
|
+
{#if data.user}
|
|
72
72
|
<div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
73
73
|
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
|
|
74
74
|
|
|
75
75
|
<div class="drawer-content">
|
|
76
|
-
<!-- Mobile top bar
|
|
76
|
+
<!-- Mobile top bar; the desktop sidebar replaces this at lg. -->
|
|
77
77
|
<div class="navbar bg-base-100 lg:hidden">
|
|
78
78
|
<div class="flex-1">
|
|
79
79
|
<span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
</ul>
|
|
112
112
|
|
|
113
113
|
<div class="border-t border-base-300 p-4">
|
|
114
|
-
<p class="text-sm font-medium">{data.
|
|
115
|
-
<p class="text-xs opacity-60">{data.
|
|
114
|
+
<p class="text-sm font-medium">{data.user.name}</p>
|
|
115
|
+
<p class="text-xs opacity-60">{data.user.email}</p>
|
|
116
116
|
<form method="POST" action="/admin/auth/logout" class="mt-3">
|
|
117
117
|
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
|
|
118
118
|
</form>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// The /admin content list: every collection's files, linking into the editor. Data comes
|
|
3
3
|
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
|
|
4
|
-
// (AdminLayout) owns the chrome
|
|
4
|
+
// (AdminLayout) owns the chrome (site title, signed-in identity, nav, sign out), so this
|
|
5
5
|
// page renders only the content body.
|
|
6
6
|
import type { AdminCollectionList } from '../sveltekit';
|
|
7
7
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The scanner-safe confirm surface (C2). A GET renders this static page and consumes nothing.
|
|
3
|
+
// The token rides in a hidden field; only the explicit form POST (the route's default action,
|
|
4
|
+
// confirmSignIn) verifies it. Mail scanners GET URLs but don't submit forms, so prefetch can't
|
|
5
|
+
// burn the link. JS-free by design.
|
|
6
|
+
interface Props {
|
|
7
|
+
data: { token: string; siteName: string; error: string | null };
|
|
8
|
+
}
|
|
9
|
+
let { data }: Props = $props();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<svelte:head>
|
|
13
|
+
<title>Confirm sign-in · {data.siteName} CMS</title>
|
|
14
|
+
</svelte:head>
|
|
15
|
+
|
|
16
|
+
<div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
|
|
17
|
+
<h1 class="text-2xl font-bold">Confirm sign-in</h1>
|
|
18
|
+
<p class="mt-1 text-sm opacity-70">to {data.siteName} CMS</p>
|
|
19
|
+
|
|
20
|
+
{#if data.error || !data.token}
|
|
21
|
+
<div class="alert alert-error mt-6">
|
|
22
|
+
<span>This sign-in link is invalid or expired. Request a new one.</span>
|
|
23
|
+
</div>
|
|
24
|
+
<a href="/admin/login" class="btn btn-primary mt-6">Back to sign-in</a>
|
|
25
|
+
{:else}
|
|
26
|
+
<form method="POST" class="mt-6 flex flex-col gap-3">
|
|
27
|
+
<input type="hidden" name="token" value={data.token} />
|
|
28
|
+
<button type="submit" class="btn btn-primary">Confirm sign-in</button>
|
|
29
|
+
</form>
|
|
30
|
+
{/if}
|
|
31
|
+
</div>
|
|
@@ -13,21 +13,21 @@
|
|
|
13
13
|
|
|
14
14
|
// Body is editable state; the Carta editor's preview runs the exact site plugin set, so it
|
|
15
15
|
// matches the live page. A hidden input carries the current value into the form.
|
|
16
|
-
// svelte-ignore state_referenced_locally
|
|
16
|
+
// svelte-ignore state_referenced_locally (seeding from the initial load is intended)
|
|
17
17
|
let body = $state(data.body);
|
|
18
18
|
|
|
19
|
-
// svelte-ignore state_referenced_locally
|
|
19
|
+
// svelte-ignore state_referenced_locally (the preview plugin set is fixed for the load)
|
|
20
20
|
const carta = new Carta(previewCartaOptions(preview));
|
|
21
21
|
|
|
22
22
|
// Carta's MarkdownEditor must not render on the worker (it pulls Shiki). onMount fires only
|
|
23
|
-
// in the browser, so SSR renders the plain textarea and the client swaps in the editor
|
|
24
|
-
// the kit-free equivalent of the per-site route's `$app/environment` `browser` guard.
|
|
23
|
+
// in the browser, so SSR renders the plain textarea and the client swaps in the editor.
|
|
24
|
+
// This is the kit-free equivalent of the per-site route's `$app/environment` `browser` guard.
|
|
25
25
|
let mounted = $state(false);
|
|
26
26
|
onMount(() => {
|
|
27
27
|
mounted = true;
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
// svelte-ignore state_referenced_locally
|
|
30
|
+
// svelte-ignore state_referenced_locally (form defaults from the initial load)
|
|
31
31
|
const fm = data.frontmatter as Record<string, unknown>;
|
|
32
32
|
|
|
33
33
|
function fmString(key: string): string {
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// The magic-link sign-in page.
|
|
3
|
-
//
|
|
2
|
+
// The magic-link sign-in page. Requests a link via the better-auth client (client-side, same
|
|
3
|
+
// origin). To avoid enumeration the UI shows the same neutral copy whether or not the email is
|
|
4
|
+
// on the allowlist. The server only emails actual editors (see auth/config.ts send gate).
|
|
5
|
+
import { createAuthClient } from 'better-auth/svelte';
|
|
6
|
+
import { magicLinkClient } from 'better-auth/client/plugins';
|
|
7
|
+
|
|
8
|
+
// The browser client lives in the one component that needs it (requesting a link). Sign-out
|
|
9
|
+
// and editor management go through server endpoints, so no shared client module is needed.
|
|
10
|
+
// A component-local const keeps better-auth's deep client types out of the packaged .d.ts.
|
|
11
|
+
const authClient = createAuthClient({ plugins: [magicLinkClient()] });
|
|
12
|
+
|
|
4
13
|
interface Props {
|
|
5
|
-
data: { siteName: string
|
|
14
|
+
data: { siteName: string };
|
|
6
15
|
}
|
|
7
16
|
let { data }: Props = $props();
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
let email = $state('');
|
|
19
|
+
let requested = $state(false);
|
|
20
|
+
let busy = $state(false);
|
|
21
|
+
|
|
22
|
+
async function request(event: SubmitEvent) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
busy = true;
|
|
25
|
+
// The magic-link email points at our /admin/auth/confirm page (built in config.ts), not a
|
|
26
|
+
// GET-verify URL, so the result is the same regardless of allowlist membership.
|
|
27
|
+
await authClient.signIn.magicLink({ email });
|
|
28
|
+
busy = false;
|
|
29
|
+
requested = true;
|
|
30
|
+
}
|
|
15
31
|
</script>
|
|
16
32
|
|
|
17
33
|
<svelte:head>
|
|
@@ -22,26 +38,27 @@
|
|
|
22
38
|
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
23
39
|
<p class="mt-1 text-sm opacity-70">Sign in with your editor email.</p>
|
|
24
40
|
|
|
25
|
-
{#if
|
|
41
|
+
{#if requested}
|
|
26
42
|
<div class="alert alert-success mt-6">
|
|
27
|
-
<span>
|
|
43
|
+
<span>
|
|
44
|
+
If that address is on the editor list, a sign-in link is on its way. It expires in 10
|
|
45
|
+
minutes.
|
|
46
|
+
</span>
|
|
28
47
|
</div>
|
|
29
48
|
{:else}
|
|
30
|
-
{
|
|
31
|
-
<div class="alert alert-error mt-6">
|
|
32
|
-
<span>{errorMessages[data.error] ?? 'Something went wrong. Try again.'}</span>
|
|
33
|
-
</div>
|
|
34
|
-
{/if}
|
|
35
|
-
<form method="POST" action="/admin/auth/request" class="mt-6 flex flex-col gap-3">
|
|
49
|
+
<form onsubmit={request} class="mt-6 flex flex-col gap-3">
|
|
36
50
|
<input
|
|
37
51
|
type="email"
|
|
38
52
|
name="email"
|
|
53
|
+
bind:value={email}
|
|
39
54
|
required
|
|
40
55
|
autocomplete="email"
|
|
41
56
|
placeholder="you@example.com"
|
|
42
57
|
class="input w-full"
|
|
43
58
|
/>
|
|
44
|
-
<button type="submit" class="btn btn-primary"
|
|
59
|
+
<button type="submit" class="btn btn-primary" disabled={busy}>
|
|
60
|
+
{busy ? 'Sending…' : 'Email me a sign-in link'}
|
|
61
|
+
</button>
|
|
45
62
|
</form>
|
|
46
63
|
{/if}
|
|
47
64
|
</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ones. Reuses the same neutral DaisyUI chrome as the rest of the admin (panels, alerts,
|
|
4
4
|
// table, buttons). Data comes from `adminsLoad` merged with `adminLayoutLoad` (siteName);
|
|
5
5
|
// mutations post to the page's named form actions (`?/add`, `?/remove`, `?/setRole`).
|
|
6
|
-
import type { AdminsData } from '../
|
|
6
|
+
import type { AdminsData } from '../auth';
|
|
7
7
|
|
|
8
8
|
interface Props {
|
|
9
9
|
data: AdminsData & { siteName: string };
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
4
4
|
export { default as AdminList } from './AdminList.svelte';
|
|
5
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
|
+
export { default as ConfirmPage } from './ConfirmPage.svelte';
|
|
6
7
|
export { default as EditPage } from './EditPage.svelte';
|
|
7
8
|
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
package/src/lib/email.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// cairn-core: pluggable magic-link email sender.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// recipients)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The default adapter targets Cloudflare Email Service (Email Sending, transactional,
|
|
4
|
+
// arbitrary recipients), distinct from Email Routing's recipient-restricted `EmailMessage`
|
|
5
|
+
// flow. Both share the same `send_email` binding (configured without a destination_address)
|
|
6
|
+
// but use a different call shape: `binding.send({ to, from, ... })`.
|
|
7
7
|
// Resend can slot in behind the same `sendMagicLink` signature if needed.
|
|
8
8
|
|
|
9
9
|
/** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
|
|
@@ -25,11 +25,18 @@ export async function sendMagicLink(
|
|
|
25
25
|
from: string,
|
|
26
26
|
): Promise<void> {
|
|
27
27
|
const expiry = "This link expires in 10 minutes and works only once. If you didn't request it, ignore this email.";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
try {
|
|
29
|
+
await sender.send({
|
|
30
|
+
to,
|
|
31
|
+
from,
|
|
32
|
+
subject: `Your ${siteName} sign-in link`,
|
|
33
|
+
text: `Sign in to ${siteName}:\n\n${link}\n\n${expiry}`,
|
|
34
|
+
html: `<p>Sign in to ${siteName}:</p><p><a href="${link}">Confirm sign-in</a></p><p style="color:#666;font-size:0.9em">${expiry}</p>`,
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// H6: Email Sending is beta + the sole auth channel. Surface + audit; a Resend fallback
|
|
38
|
+
// can slot in behind this same signature if Sending proves unreliable.
|
|
39
|
+
console.error(`magic-link email send failed for ${to}:`, err);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
35
42
|
}
|
package/src/lib/github.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Reads (Pass B) list a collection directory and fetch a file's raw markdown; the token
|
|
4
4
|
// is optional because ecnordic's repo is public. Writes (Pass C) mint a short-lived
|
|
5
|
-
// GitHub App installation token
|
|
6
|
-
// dependency
|
|
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
7
|
// App (cairn-cms[bot]). The same token also lifts reads to the authenticated rate limit
|
|
8
8
|
// and unlocks private repos (e.g. 907-life).
|
|
9
9
|
|
|
@@ -90,7 +90,7 @@ function derLength(n: number): number[] {
|
|
|
90
90
|
// AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
|
|
91
91
|
const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
|
|
92
92
|
|
|
93
|
-
/** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8
|
|
93
|
+
/** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
|
|
94
94
|
function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array {
|
|
95
95
|
const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
|
|
96
96
|
const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
|
|
@@ -124,7 +124,7 @@ export async function appJwt(appId: string, privateKeyPem: string): Promise<stri
|
|
|
124
124
|
export interface AppCredentials {
|
|
125
125
|
appId: string;
|
|
126
126
|
installationId: string;
|
|
127
|
-
/** The stored GITHUB_APP_PRIVATE_KEY_B64
|
|
127
|
+
/** The stored GITHUB_APP_PRIVATE_KEY_B64: base64 of the PEM, single line. */
|
|
128
128
|
privateKeyB64: string;
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -139,7 +139,7 @@ export async function installationToken(creds: AppCredentials): Promise<string>
|
|
|
139
139
|
return ((await res.json()) as { token: string }).token;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** Standard (padded) base64 of UTF-8 text
|
|
142
|
+
/** Standard (padded) base64 of UTF-8 text, as the contents API expects. */
|
|
143
143
|
function toBase64(text: string): string {
|
|
144
144
|
return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
|
|
145
145
|
}
|
|
@@ -157,11 +157,24 @@ export interface CommitAuthor {
|
|
|
157
157
|
email: string;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A concurrent edit lost the SHA race (C3): the file changed between the read and the PUT,
|
|
162
|
+
* from another editor or the site's own CI. Thrown so callers can fail safe (re-fetch and ask
|
|
163
|
+
* the editor to reapply) instead of surfacing a raw 409. Defined and caught inside the package
|
|
164
|
+
* so `instanceof` is reliable (no peer-boundary identity split, unlike kit's `redirect`/`error`).
|
|
165
|
+
*/
|
|
166
|
+
export class CommitConflictError extends Error {
|
|
167
|
+
constructor(public readonly path: string) {
|
|
168
|
+
super(`Commit conflict on ${path}: it changed since it was opened`);
|
|
169
|
+
this.name = 'CommitConflictError';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
/**
|
|
161
174
|
* Commit `content` to `path` on the configured branch via the contents API. Author is the
|
|
162
175
|
* editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
|
|
163
176
|
* the file in place when it exists (passing its sha), creates it otherwise. Returns the
|
|
164
|
-
* commit sha.
|
|
177
|
+
* commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`.
|
|
165
178
|
*/
|
|
166
179
|
export async function commitFile(
|
|
167
180
|
repo: RepoRef,
|
|
@@ -183,6 +196,25 @@ export async function commitFile(
|
|
|
183
196
|
...(sha ? { sha } : {}),
|
|
184
197
|
}),
|
|
185
198
|
});
|
|
199
|
+
// 409 = the blob sha we read is no longer current. Fail safe: the caller re-fetches and the
|
|
200
|
+
// editor reapplies. (Full three-way merge stays out of scope; see ARCHITECTURE §5.)
|
|
201
|
+
if (res.status === 409) throw new CommitConflictError(path);
|
|
186
202
|
if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
|
|
187
203
|
return ((await res.json()) as { commit: { sha: string } }).commit.sha;
|
|
188
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Deploy-time self-test for the GitHub App signer (M2): sign a dummy JWT with the configured
|
|
208
|
+
* private key. Exercises the brittle PKCS#1→PKCS#8 conversion + Web Crypto import/sign without
|
|
209
|
+
* any network call or secret in the result, so `/admin/healthz` catches a bad/rotated key
|
|
210
|
+
* before an editor's save fails. Returns `{ ok: false, detail }` rather than throwing.
|
|
211
|
+
*/
|
|
212
|
+
export async function signingSelfTest(appId: string, privateKeyB64: string): Promise<{ ok: boolean; detail?: string }> {
|
|
213
|
+
try {
|
|
214
|
+
const jwt = await appJwt(appId, atob(privateKeyB64));
|
|
215
|
+
if (jwt.split('.').length !== 3) return { ok: false, detail: 'malformed JWT' };
|
|
216
|
+
return { ok: true };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { ok: false, detail: err instanceof Error ? err.message : 'sign failed' };
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// cairn-cms public API. Consumers import
|
|
2
|
-
|
|
1
|
+
// cairn-cms public API. Consumers import content/email/github/adapter from 'cairn-cms';
|
|
2
|
+
// auth (better-auth factory, guards, manage-editors) lives at the 'cairn-cms/auth' subpath.
|
|
3
3
|
export * from './email';
|
|
4
4
|
export * from './github';
|
|
5
5
|
export * from './carta';
|
|
6
6
|
export * from './content';
|
|
7
7
|
export * from './adapter';
|
|
8
|
+
export * from './render';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { s } from 'hastscript';
|
|
2
|
+
import type { Element } from 'hast';
|
|
3
|
+
|
|
4
|
+
/** A glyph name → SVG path-data map (the site owns the icon set). */
|
|
5
|
+
export type IconSet = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
|
|
8
|
+
export function glyph(name: string, icons: IconSet): Element {
|
|
9
|
+
return s(
|
|
10
|
+
'svg',
|
|
11
|
+
{ className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
|
|
12
|
+
[s('path', { d: icons[name] })],
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// cairn-cms render engine: a directive-driven markdown → HTML pipeline whose
|
|
2
|
+
// component vocabulary is supplied by a site's component registry. The site owns the
|
|
3
|
+
// component builders, class names, icon set, and CSS; the engine owns the machinery.
|
|
4
|
+
export * from './registry';
|
|
5
|
+
export * from './glyph';
|
|
6
|
+
export * from './remark-directives';
|
|
7
|
+
export * from './rehype-dispatch';
|
|
8
|
+
export * from './pipeline';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { unified, type PluggableList } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import remarkDirective from 'remark-directive';
|
|
5
|
+
import remarkRehype from 'remark-rehype';
|
|
6
|
+
import rehypeRaw from 'rehype-raw';
|
|
7
|
+
import rehypeSlug from 'rehype-slug';
|
|
8
|
+
import rehypeStringify from 'rehype-stringify';
|
|
9
|
+
import { remarkDirectiveStamp } from './remark-directives';
|
|
10
|
+
import { rehypeDispatch } from './rehype-dispatch';
|
|
11
|
+
import type { ComponentRegistry } from './registry';
|
|
12
|
+
|
|
13
|
+
export interface RendererOptions {
|
|
14
|
+
/** A site's per-index motion formula for the top-level rise stagger
|
|
15
|
+
* (e.g. ecnordic's `(i) => '--rise:' + …`). Omit for no stagger. */
|
|
16
|
+
rise?: (idx: number) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compose a site's render pipeline from its component registry: directive syntax →
|
|
20
|
+
* stamped markers → registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
21
|
+
* rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
|
|
22
|
+
export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
|
|
23
|
+
const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
|
|
24
|
+
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.rise], rehypeSlug];
|
|
25
|
+
const processor = unified()
|
|
26
|
+
.use(remarkParse)
|
|
27
|
+
.use(remarkGfm)
|
|
28
|
+
.use(remarkPlugins)
|
|
29
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
30
|
+
.use(rehypePlugins)
|
|
31
|
+
.use(rehypeStringify);
|
|
32
|
+
return {
|
|
33
|
+
remarkPlugins,
|
|
34
|
+
rehypePlugins,
|
|
35
|
+
renderMarkdown: async (content: string): Promise<string> => String(await processor.process(content)),
|
|
36
|
+
};
|
|
37
|
+
}
|