@glw907/cairn-cms 0.3.1 → 0.4.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 +14 -6
- 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/components/AdminLayout.svelte +6 -6
- package/dist/components/AdminLayout.svelte.d.ts +2 -2
- package/dist/components/AdminLayout.svelte.d.ts.map +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/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 +15 -7
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/sveltekit/index.d.ts +5 -58
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +9 -153
- package/package.json +34 -4
- 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/components/AdminLayout.svelte +6 -6
- package/src/lib/components/ConfirmPage.svelte +31 -0
- 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 +14 -7
- package/src/lib/index.ts +2 -2
- package/src/lib/sveltekit/index.ts +15 -224
- 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,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
|
+
}));
|
|
@@ -7,13 +7,13 @@
|
|
|
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
|
|
|
@@ -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,7 +68,7 @@
|
|
|
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
|
|
|
@@ -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>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The scanner-safe confirm surface (C2). A GET renders this static page — nothing is consumed.
|
|
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>
|
|
@@ -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
|
+
// and 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
|
@@ -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/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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';
|
|
@@ -1,41 +1,22 @@
|
|
|
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
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
|
-
//
|
|
10
|
-
|
|
11
|
-
import
|
|
8
|
+
// `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
|
|
9
|
+
// class identity with the host's runtime — else the redirect 500s). Auth/session/manage-editors
|
|
10
|
+
// logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
|
|
11
|
+
import { redirect, error } from '@sveltejs/kit';
|
|
12
12
|
import matter from 'gray-matter';
|
|
13
|
-
import {
|
|
14
|
-
createMagicLink,
|
|
15
|
-
redeemMagicToken,
|
|
16
|
-
createSession,
|
|
17
|
-
lookupEditor,
|
|
18
|
-
listEditors,
|
|
19
|
-
setEditor,
|
|
20
|
-
removeEditor,
|
|
21
|
-
SESSION_COOKIE,
|
|
22
|
-
SESSION_MAX_AGE,
|
|
23
|
-
type Editor,
|
|
24
|
-
type Role,
|
|
25
|
-
} from '../auth';
|
|
26
|
-
import { sendMagicLink, type EmailSender } from '../email';
|
|
13
|
+
import type { CairnUser } from '../auth/guard';
|
|
27
14
|
import { listMarkdown, readRaw, commitFile, installationToken, type RepoFile } from '../github';
|
|
28
15
|
import { serializeMarkdown } from '../content';
|
|
29
16
|
import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
|
|
30
17
|
|
|
31
|
-
/** The `platform.env` bindings the
|
|
18
|
+
/** The `platform.env` bindings the content routes read. All optional — the handlers guard. */
|
|
32
19
|
export interface AdminEnv {
|
|
33
|
-
AUTH_KV?: KVNamespace;
|
|
34
|
-
MAGIC_LINK_SECRET?: string;
|
|
35
|
-
SESSION_SECRET?: string;
|
|
36
|
-
EMAIL?: EmailSender;
|
|
37
|
-
/** Overrides `url.origin` for the magic-link base (set in dev, unset in prod). */
|
|
38
|
-
PUBLIC_ORIGIN?: string;
|
|
39
20
|
GITHUB_APP_ID?: string;
|
|
40
21
|
GITHUB_APP_INSTALLATION_ID?: string;
|
|
41
22
|
GITHUB_APP_PRIVATE_KEY_B64?: string;
|
|
@@ -45,8 +26,6 @@ interface PlatformEvent {
|
|
|
45
26
|
platform?: { env?: AdminEnv };
|
|
46
27
|
}
|
|
47
28
|
|
|
48
|
-
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
49
|
-
|
|
50
29
|
/**
|
|
51
30
|
* Mint a GitHub App installation token for *reads* when the App is configured, else undefined
|
|
52
31
|
* (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
|
|
@@ -73,7 +52,7 @@ async function readToken(env: AdminEnv | undefined): Promise<string | undefined>
|
|
|
73
52
|
// ── /admin layout ──────────────────────────────────────────────────────────
|
|
74
53
|
|
|
75
54
|
export interface AdminLayoutData {
|
|
76
|
-
|
|
55
|
+
user: CairnUser | null;
|
|
77
56
|
siteName: string;
|
|
78
57
|
pathname: string;
|
|
79
58
|
}
|
|
@@ -86,10 +65,10 @@ export interface AdminLayoutData {
|
|
|
86
65
|
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
87
66
|
*/
|
|
88
67
|
export function adminLayoutLoad(
|
|
89
|
-
event: { locals: {
|
|
68
|
+
event: { locals: { user: CairnUser | null }; url: URL },
|
|
90
69
|
adapter: CairnAdapter,
|
|
91
70
|
): AdminLayoutData {
|
|
92
|
-
return {
|
|
71
|
+
return { user: event.locals.user, siteName: adapter.siteName, pathname: event.url.pathname };
|
|
93
72
|
}
|
|
94
73
|
|
|
95
74
|
// ── /admin (content list) ────────────────────────────────────────────────────
|
|
@@ -120,20 +99,6 @@ export async function adminListLoad(
|
|
|
120
99
|
return { collections };
|
|
121
100
|
}
|
|
122
101
|
|
|
123
|
-
// ── /admin/login ──────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
export interface LoginData {
|
|
126
|
-
sent: boolean;
|
|
127
|
-
error: string | null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function loginLoad(event: { url: URL }): LoginData {
|
|
131
|
-
return {
|
|
132
|
-
sent: event.url.searchParams.get('sent') === '1',
|
|
133
|
-
error: event.url.searchParams.get('error'),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
102
|
// ── /admin/edit/[type]/[id] ─────────────────────────────────────────────────
|
|
138
103
|
|
|
139
104
|
export interface EditData {
|
|
@@ -179,92 +144,14 @@ export async function editLoad(
|
|
|
179
144
|
};
|
|
180
145
|
}
|
|
181
146
|
|
|
182
|
-
// ── /admin/auth/request (POST) ──────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
export async function authRequest(
|
|
185
|
-
event: PlatformEvent & { request: Request; url: URL },
|
|
186
|
-
adapter: CairnAdapter,
|
|
187
|
-
): Promise<never> {
|
|
188
|
-
const env = event.platform?.env;
|
|
189
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.EMAIL) {
|
|
190
|
-
throw redirect(303, '/admin/login?error=config');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const form = await event.request.formData();
|
|
194
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
195
|
-
if (!EMAIL_RE.test(email)) {
|
|
196
|
-
throw redirect(303, '/admin/login?error=invalid');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
200
|
-
if (!editor) {
|
|
201
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const token = await createMagicLink(email, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
205
|
-
// PUBLIC_ORIGIN overrides url.origin for local dev (where wrangler's custom-domain
|
|
206
|
-
// route makes url.origin the production host); unset in prod → url.origin is correct.
|
|
207
|
-
const origin = env.PUBLIC_ORIGIN || event.url.origin;
|
|
208
|
-
const link = `${origin}/admin/auth/callback?token=${encodeURIComponent(token)}`;
|
|
209
|
-
try {
|
|
210
|
-
await sendMagicLink(env.EMAIL, email, link, adapter.siteName, adapter.sender);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
console.error('magic-link send failed:', err);
|
|
213
|
-
throw redirect(303, '/admin/login?error=config');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
throw redirect(303, '/admin/login?sent=1');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ── /admin/auth/callback (GET) ──────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
export async function authCallback(
|
|
222
|
-
event: PlatformEvent & { url: URL; cookies: Cookies },
|
|
223
|
-
): Promise<never> {
|
|
224
|
-
const env = event.platform?.env;
|
|
225
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.SESSION_SECRET) {
|
|
226
|
-
throw redirect(303, '/admin/login?error=config');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const token = event.url.searchParams.get('token') ?? '';
|
|
230
|
-
const email = await redeemMagicToken(token, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
231
|
-
if (!email) {
|
|
232
|
-
throw redirect(303, '/admin/login?error=expired');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Re-check the allowlist at redemption — membership may have changed since issue.
|
|
236
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
237
|
-
if (!editor) {
|
|
238
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const session = await createSession(editor, env.SESSION_SECRET);
|
|
242
|
-
event.cookies.set(SESSION_COOKIE, session, {
|
|
243
|
-
path: '/',
|
|
244
|
-
httpOnly: true,
|
|
245
|
-
secure: event.url.protocol === 'https:',
|
|
246
|
-
sameSite: 'lax',
|
|
247
|
-
maxAge: SESSION_MAX_AGE,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
throw redirect(303, '/admin');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ── /admin/auth/logout (POST) ───────────────────────────────────────────────
|
|
254
|
-
|
|
255
|
-
export function logout(event: { cookies: Cookies }): never {
|
|
256
|
-
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
257
|
-
throw redirect(303, '/admin/login');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
147
|
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
261
148
|
|
|
262
149
|
export async function saveCommit(
|
|
263
|
-
event: PlatformEvent & { request: Request; locals: {
|
|
150
|
+
event: PlatformEvent & { request: Request; locals: { user: CairnUser | null } },
|
|
264
151
|
adapter: CairnAdapter,
|
|
265
152
|
): Promise<never> {
|
|
266
|
-
const
|
|
267
|
-
if (!
|
|
153
|
+
const user = event.locals.user;
|
|
154
|
+
if (!user) throw error(401, 'Not signed in');
|
|
268
155
|
|
|
269
156
|
const env = event.platform?.env;
|
|
270
157
|
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
@@ -299,105 +186,9 @@ export async function saveCommit(
|
|
|
299
186
|
adapter.backend,
|
|
300
187
|
`${collection.dir}/${id}.md`,
|
|
301
188
|
markdown,
|
|
302
|
-
{ message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name:
|
|
189
|
+
{ message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
|
|
303
190
|
token,
|
|
304
191
|
);
|
|
305
192
|
|
|
306
193
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
307
194
|
}
|
|
308
|
-
|
|
309
|
-
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
313
|
-
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
314
|
-
*/
|
|
315
|
-
function requireOwner(event: { locals: { editor: Editor | null } }): Editor {
|
|
316
|
-
const editor = event.locals.editor;
|
|
317
|
-
if (!editor) throw error(401, 'Not signed in');
|
|
318
|
-
if (editor.role !== 'owner') throw error(403, 'Owner access required');
|
|
319
|
-
return editor;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
323
|
-
function ownerKv(event: PlatformEvent): KVNamespace {
|
|
324
|
-
const kv = event.platform?.env?.AUTH_KV;
|
|
325
|
-
if (!kv) throw error(500, 'Editor allowlist is not configured');
|
|
326
|
-
return kv;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export interface AdminsData {
|
|
330
|
-
admins: Editor[];
|
|
331
|
-
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
332
|
-
self: string;
|
|
333
|
-
saved: boolean;
|
|
334
|
-
error: string | null;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
338
|
-
export async function adminsLoad(
|
|
339
|
-
event: PlatformEvent & { locals: { editor: Editor | null }; url: URL },
|
|
340
|
-
): Promise<AdminsData> {
|
|
341
|
-
const owner = requireOwner(event);
|
|
342
|
-
const admins = await listEditors(ownerKv(event));
|
|
343
|
-
return {
|
|
344
|
-
admins,
|
|
345
|
-
self: owner.email,
|
|
346
|
-
saved: event.url.searchParams.get('saved') === '1',
|
|
347
|
-
error: event.url.searchParams.get('error'),
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
type AdminsActionEvent = PlatformEvent & {
|
|
352
|
-
request: Request;
|
|
353
|
-
locals: { editor: Editor | null };
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
function parseRole(value: unknown): Role {
|
|
357
|
-
return value === 'owner' ? 'owner' : 'editor';
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Add (or update) an allowlist entry. Owner-only. */
|
|
361
|
-
export async function addAdmin(event: AdminsActionEvent): Promise<never> {
|
|
362
|
-
requireOwner(event);
|
|
363
|
-
const kv = ownerKv(event);
|
|
364
|
-
const form = await event.request.formData();
|
|
365
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
366
|
-
const name = String(form.get('name') ?? '').trim();
|
|
367
|
-
if (!EMAIL_RE.test(email) || !name) {
|
|
368
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
369
|
-
}
|
|
370
|
-
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
371
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
375
|
-
export async function removeAdmin(event: AdminsActionEvent): Promise<never> {
|
|
376
|
-
const owner = requireOwner(event);
|
|
377
|
-
const kv = ownerKv(event);
|
|
378
|
-
const form = await event.request.formData();
|
|
379
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
380
|
-
if (email === owner.email) {
|
|
381
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
382
|
-
}
|
|
383
|
-
await removeEditor(email, kv);
|
|
384
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
388
|
-
export async function setAdminRole(event: AdminsActionEvent): Promise<never> {
|
|
389
|
-
const owner = requireOwner(event);
|
|
390
|
-
const kv = ownerKv(event);
|
|
391
|
-
const form = await event.request.formData();
|
|
392
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
393
|
-
const role = parseRole(form.get('role'));
|
|
394
|
-
if (email === owner.email && role !== 'owner') {
|
|
395
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
396
|
-
}
|
|
397
|
-
const existing = await lookupEditor(email, kv);
|
|
398
|
-
if (!existing) {
|
|
399
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
400
|
-
}
|
|
401
|
-
await setEditor(email, existing.name, role, kv);
|
|
402
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
403
|
-
}
|
package/dist/auth.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
2
|
-
/** Two-tier, per-site role. `owner`s manage the editor allowlist; `editor`s only edit content. */
|
|
3
|
-
export type Role = 'owner' | 'editor';
|
|
4
|
-
export interface Editor {
|
|
5
|
-
email: string;
|
|
6
|
-
name: string;
|
|
7
|
-
role: Role;
|
|
8
|
-
}
|
|
9
|
-
export declare const SESSION_COOKIE = "cairn_session";
|
|
10
|
-
export declare const SESSION_MAX_AGE: number;
|
|
11
|
-
/** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
|
|
12
|
-
export declare function createMagicLink(email: string, secret: string, kv: KVNamespace): Promise<string>;
|
|
13
|
-
/** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
|
|
14
|
-
export declare function redeemMagicToken(token: string, secret: string, kv: KVNamespace): Promise<string | null>;
|
|
15
|
-
export declare function createSession(editor: Editor, secret: string): Promise<string>;
|
|
16
|
-
export declare function verifySession(token: string, secret: string): Promise<Editor | null>;
|
|
17
|
-
/** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
|
|
18
|
-
export declare function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null>;
|
|
19
|
-
/** Every allowlisted editor, sorted by email — the manage-admins list. */
|
|
20
|
-
export declare function listEditors(kv: KVNamespace): Promise<Editor[]>;
|
|
21
|
-
/** Add or update an allowlist entry (JSON value). Email is normalized. */
|
|
22
|
-
export declare function setEditor(email: string, name: string, role: Role, kv: KVNamespace): Promise<void>;
|
|
23
|
-
/** Remove an allowlist entry. */
|
|
24
|
-
export declare function removeEditor(email: string, kv: KVNamespace): Promise<void>;
|
|
25
|
-
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/lib/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,kGAAkG;AAClG,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtC,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,eAAO,MAAM,cAAc,kBAAkB,CAAC;AAK9C,eAAO,MAAM,eAAe,QAAsB,CAAC;AA8DnD,mFAAmF;AACnF,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED,+FAA+F;AAC/F,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAMD,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGnF;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAyBD,iFAAiF;AACjF,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAED,0EAA0E;AAC1E,wBAAsB,WAAW,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpE;AAED,0EAA0E;AAC1E,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,IAAI,EACV,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,iCAAiC;AACjC,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhF"}
|