@acms-subir/core 0.3.9 → 0.3.11
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/CHANGELOG.md +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/src/layouts/Admin.astro
CHANGED
|
@@ -4,8 +4,10 @@ import { getRepo } from '../lib/repo';
|
|
|
4
4
|
import { BRAND } from '../lib/brand';
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { OWNER_ENV } from '../lib/auth';
|
|
8
|
+
import { validateRequestSession } from '../lib/auth/request';
|
|
9
|
+
import { AUTH } from '../lib/auth/config';
|
|
10
|
+
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
9
11
|
import ThemeScript from '../components/ThemeScript.astro';
|
|
10
12
|
import CommandPalette from '../components/CommandPalette';
|
|
11
13
|
import '../styles/admin.css';
|
|
@@ -20,7 +22,7 @@ interface Props {
|
|
|
20
22
|
const { title, section, wide = false } = Astro.props;
|
|
21
23
|
|
|
22
24
|
const repo = await getRepo(Astro.locals);
|
|
23
|
-
const sessionUserId =
|
|
25
|
+
const sessionUserId = await validateRequestSession(Astro.request, Astro.cookies, Astro.locals);
|
|
24
26
|
const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
25
27
|
repo.listPages(),
|
|
26
28
|
repo.listPosts(),
|
|
@@ -64,6 +66,7 @@ const icons: Record<string, string> = {
|
|
|
64
66
|
out: 'M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3',
|
|
65
67
|
more: 'M5 12h.01M12 12h.01M19 12h.01',
|
|
66
68
|
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
|
|
69
|
+
shield: 'M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4',
|
|
67
70
|
calendar: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
|
|
68
71
|
calendars: 'M4 6h.01M4 12h.01M4 18h.01M8 6h12M8 12h12M8 18h12',
|
|
69
72
|
bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
@@ -128,8 +131,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
128
131
|
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
129
132
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
130
133
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
131
|
-
// push notifications
|
|
132
|
-
...(
|
|
134
|
+
// app access switches (+ push notifications when acms({ mobile: { push } }) is on), owners only
|
|
135
|
+
...(me.role === 'Owner' ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
133
136
|
],
|
|
134
137
|
},
|
|
135
138
|
{
|
|
@@ -163,6 +166,7 @@ const themeOpts = [
|
|
|
163
166
|
<meta name="robots" content="noindex" />
|
|
164
167
|
<title>{title} · {BRAND.name} admin</title>
|
|
165
168
|
<ThemeScript />
|
|
169
|
+
<ClerkSession keepAlive />
|
|
166
170
|
<script is:inline>
|
|
167
171
|
// restore the collapsed rail before paint
|
|
168
172
|
try { if (localStorage.getItem('acms-rail') === '1') document.documentElement.classList.add('rail-pending'); } catch {}
|
|
@@ -171,6 +175,8 @@ const themeOpts = [
|
|
|
171
175
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
172
176
|
</head>
|
|
173
177
|
<body>
|
|
178
|
+
{/* Clerk sign-out is a same-origin POST; the Sign out buttons submit this */}
|
|
179
|
+
{AUTH.provider === 'clerk' && <form id="acms-logout" method="post" action="/admin/logout" hidden></form>}
|
|
174
180
|
<div class="shell" id="acms-shell">
|
|
175
181
|
<aside class="sidebar" aria-label="Admin navigation">
|
|
176
182
|
<div class="brand">
|
|
@@ -231,9 +237,20 @@ const themeOpts = [
|
|
|
231
237
|
<a class="menu-item" href="/" target="_blank" rel="noopener" role="menuitem">
|
|
232
238
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.globe} /></svg>View site
|
|
233
239
|
</a>
|
|
234
|
-
|
|
235
|
-
<
|
|
236
|
-
|
|
240
|
+
{AUTH.provider === 'clerk' && (
|
|
241
|
+
<button class="menu-item" type="button" role="menuitem" data-clerk-profile hidden>
|
|
242
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.shield} /></svg>Sign-in & security
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
{AUTH.provider === 'clerk' ? (
|
|
246
|
+
<button class="menu-item" type="submit" form="acms-logout" role="menuitem">
|
|
247
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>Sign out
|
|
248
|
+
</button>
|
|
249
|
+
) : (
|
|
250
|
+
<a class="menu-item" href="/admin/logout" role="menuitem">
|
|
251
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>Sign out
|
|
252
|
+
</a>
|
|
253
|
+
)}
|
|
237
254
|
</div>
|
|
238
255
|
</div>
|
|
239
256
|
</aside>
|
|
@@ -313,10 +330,23 @@ const themeOpts = [
|
|
|
313
330
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.globe} /></svg>
|
|
314
331
|
<span class="grow">View site</span>
|
|
315
332
|
</a>
|
|
316
|
-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
{AUTH.provider === 'clerk' && (
|
|
334
|
+
<a href="#" data-clerk-profile hidden>
|
|
335
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.shield} /></svg>
|
|
336
|
+
<span class="grow">Sign-in & security</span>
|
|
337
|
+
</a>
|
|
338
|
+
)}
|
|
339
|
+
{AUTH.provider === 'clerk' ? (
|
|
340
|
+
<button type="submit" form="acms-logout" class="danger">
|
|
341
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>
|
|
342
|
+
<span class="grow">Sign out</span>
|
|
343
|
+
</button>
|
|
344
|
+
) : (
|
|
345
|
+
<a href="/admin/logout" class="danger">
|
|
346
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>
|
|
347
|
+
<span class="grow">Sign out</span>
|
|
348
|
+
</a>
|
|
349
|
+
)}
|
|
320
350
|
</div>
|
|
321
351
|
</div>
|
|
322
352
|
</dialog>
|
package/src/layouts/Site.astro
CHANGED
|
@@ -16,6 +16,7 @@ import { analyticsHead, analyticsBody } from '../lib/analytics';
|
|
|
16
16
|
import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
|
|
17
17
|
import type { SiteChrome } from '../lib/site-chrome';
|
|
18
18
|
import SiteLogo from '../components/site/SiteLogo.astro';
|
|
19
|
+
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
19
20
|
import '../styles/site.css';
|
|
20
21
|
import '../styles/cards.css';
|
|
21
22
|
import '../styles/live.css';
|
|
@@ -37,10 +38,13 @@ interface Props {
|
|
|
37
38
|
authorName?: string;
|
|
38
39
|
jsonLd?: Record<string, unknown>;
|
|
39
40
|
noindex?: boolean;
|
|
41
|
+
// a signed-in staff member is viewing a draft or preview: keep their Clerk
|
|
42
|
+
// session cookie fresh so reloads and actions still work after a while
|
|
43
|
+
staffPreview?: boolean;
|
|
40
44
|
}
|
|
41
45
|
const {
|
|
42
46
|
title, description, bare = false, exactTitle = false,
|
|
43
|
-
image, imageAlt, type = 'website', publishedAt, modifiedAt, authorName, jsonLd, noindex = false,
|
|
47
|
+
image, imageAlt, type = 'website', publishedAt, modifiedAt, authorName, jsonLd, noindex = false, staffPreview = false,
|
|
44
48
|
} = Astro.props;
|
|
45
49
|
|
|
46
50
|
const repo = await getRepo(Astro.locals);
|
|
@@ -237,6 +241,7 @@ const homeJsonLd =
|
|
|
237
241
|
<Fragment set:html={`<style>${design.css}</style>`} />
|
|
238
242
|
{analyticsHtml && <Fragment set:html={analyticsHtml} />}
|
|
239
243
|
{settings.headCode && <Fragment set:html={settings.headCode} />}
|
|
244
|
+
{(staffPreview || preview.role === 'admin') && <ClerkSession keepAlive signedOutUrl="" freshPosts={false} />}
|
|
240
245
|
</head>
|
|
241
246
|
<body>
|
|
242
247
|
{liveSignals?.bar && (
|
package/src/lib/api.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Role } from './schema';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { OWNER_ENV } from './auth';
|
|
4
|
+
import { getRequestAuth, validateRequestSession } from './auth/request';
|
|
4
5
|
import type { ContentRepository } from './repo/types';
|
|
5
6
|
|
|
6
7
|
export class ApiError extends Error {
|
|
7
|
-
|
|
8
|
+
/** retryAfter: seconds a 429 should wait (mobile API Retry-After; default 60) */
|
|
9
|
+
constructor(public status: number, message: string, public retryAfter?: number) { super(message); }
|
|
8
10
|
}
|
|
9
11
|
export const json = (body: unknown, status = 200) => Response.json(body, {
|
|
10
12
|
status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
|
|
@@ -48,7 +50,7 @@ export async function sessionRole(
|
|
|
48
50
|
request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository,
|
|
49
51
|
): Promise<{ id: string; role: Role }> {
|
|
50
52
|
await requireSession(request, cookies, repo);
|
|
51
|
-
const id = (await
|
|
53
|
+
const id = (await validateRequestSession(request, cookies, repo))!;
|
|
52
54
|
if (id === OWNER_ENV) return { id, role: 'owner' };
|
|
53
55
|
const user = await repo.getUser(id);
|
|
54
56
|
return { id, role: user!.role };
|
|
@@ -57,7 +59,9 @@ export async function sessionRole(
|
|
|
57
59
|
export async function requireSession(
|
|
58
60
|
request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository, ownerOnly = false,
|
|
59
61
|
) {
|
|
60
|
-
const
|
|
62
|
+
const auth = await getRequestAuth(request, cookies, repo);
|
|
63
|
+
if (auth.error === 'unavailable' || auth.error === 'configuration') throw new ApiError(503, 'Sign-in is temporarily unavailable. Please retry.');
|
|
64
|
+
const id = auth.userId;
|
|
61
65
|
if (!id) throw new ApiError(401, 'Sign in to continue.');
|
|
62
66
|
const user = id === OWNER_ENV ? null : await repo.getUser(id);
|
|
63
67
|
if (id !== OWNER_ENV && (!user || (ownerOnly && user.role !== 'owner'))) throw new ApiError(403, 'Owner access is required.');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { Role } from '../schema';
|
|
3
|
+
import { AUTH, clerkConfig } from './config';
|
|
4
|
+
import { clerkClient, clerkOperation } from './request';
|
|
5
|
+
import { validAddress } from '../email/config';
|
|
6
|
+
|
|
7
|
+
export async function inviteClerkUser(repo: ContentRepository, data: { email: string; name: string; role: unknown }, locals?: unknown) {
|
|
8
|
+
if (AUTH.provider !== 'clerk') throw new Error('Clerk authentication is not enabled.');
|
|
9
|
+
const config = clerkConfig(locals), email = data.email.trim().toLowerCase(), role = Role.parse(data.role);
|
|
10
|
+
if (!validAddress(email)) throw new Error('Enter a valid email address.');
|
|
11
|
+
if (await repo.getUserAuth(email)) throw new Error('This account already exists. Link its Clerk user ID instead.');
|
|
12
|
+
const client = await clerkClient(locals);
|
|
13
|
+
const existing = await clerkOperation(() => client.users.getUserList({ emailAddress: [email], limit: 1 }));
|
|
14
|
+
if (existing.data.length) throw new Error('This email already has a Clerk account. Link its Clerk user ID to an existing ACMS user instead of sending a new-account invitation.');
|
|
15
|
+
const id = crypto.randomUUID();
|
|
16
|
+
// Expired/failed invitations do not prevent a deliberate retry.
|
|
17
|
+
for (const old of await repo.auth.invitations(config.issuer)) if (old.email === email && old.state === 'expired') await repo.auth.invitationState(old.id, 'expired');
|
|
18
|
+
await repo.auth.reserveInvitation(config.issuer, { id, email, name: data.name.trim().slice(0, 120) || email, role });
|
|
19
|
+
try {
|
|
20
|
+
const invitation = await clerkOperation(() => client.invitations.createInvitation({ emailAddress: email, expiresInDays: 7, ignoreExisting: false,
|
|
21
|
+
redirectUrl: `${config.authorizedParties[0]}/admin/login?signUp=1`, publicMetadata: { acmsInvitationId: id } }));
|
|
22
|
+
await repo.auth.invitationState(id, 'pending', invitation.id);
|
|
23
|
+
} catch {
|
|
24
|
+
await repo.auth.invitationState(id, 'failed');
|
|
25
|
+
throw new Error('Clerk could not complete the invitation. Check its dashboard before retrying.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function revokeClerkInvitation(repo: ContentRepository, id: string, locals?: unknown) {
|
|
29
|
+
const config = clerkConfig(locals), invitation = await repo.auth.invitation(config.issuer, id);
|
|
30
|
+
if (!invitation || invitation.state === 'accepted') throw new Error('This invitation cannot be revoked.');
|
|
31
|
+
// Remove local authority first, even if the remote service is unavailable.
|
|
32
|
+
await repo.auth.invitationState(id, 'revoked');
|
|
33
|
+
if (invitation.remoteId) {
|
|
34
|
+
try { const client = await clerkClient(locals); await clerkOperation(() => client.invitations.revokeInvitation(invitation.remoteId)); }
|
|
35
|
+
catch { throw new Error('ACMS access was revoked. Clerk delivery could not be cancelled; check the Clerk dashboard.'); }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function linkClerkUser(repo: ContentRepository, userId: string, subject: string, locals?: unknown) {
|
|
39
|
+
if (AUTH.provider !== 'clerk' || !/^user_[A-Za-z0-9]+$/.test(subject)) throw new Error('Enter a valid Clerk user ID.');
|
|
40
|
+
if (!await repo.getUser(userId)) throw new Error('The ACMS account no longer exists.');
|
|
41
|
+
const config = clerkConfig(locals), client = await clerkClient(locals);
|
|
42
|
+
const external = await clerkOperation(() => client.users.getUser(subject));
|
|
43
|
+
if (external.banned || external.locked || !external.emailAddresses.some(e => e.id === external.primaryEmailAddressId && e.verification?.status === 'verified')) throw new Error('The Clerk account needs an active, verified email address.');
|
|
44
|
+
try { await repo.auth.link(config.issuer, subject, userId); }
|
|
45
|
+
catch { throw new Error('This Clerk identity or ACMS user is already linked. Active accounts cannot be reassigned.'); }
|
|
46
|
+
await repo.editor.revokeUserDevices(userId, 'authentication provider linked', Date.now());
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Owner-initiated email change. Clerk-linked accounts sign in by Clerk user
|
|
50
|
+
* ID, so their email belongs to Clerk (see syncClerkEmails). Elsewhere it's
|
|
51
|
+
* stored here; with built-in sign-in it is the login, so a change signs the
|
|
52
|
+
* person out everywhere and can notify the previous address.
|
|
53
|
+
*/
|
|
54
|
+
export async function changeAccountEmail(repo: ContentRepository, userId: string, value: string, opts: { notify?: boolean } = {}, locals?: unknown) {
|
|
55
|
+
const email = value.trim().toLowerCase();
|
|
56
|
+
if (!validAddress(email)) throw new Error('Enter a valid email address.');
|
|
57
|
+
const user = await repo.getUser(userId);
|
|
58
|
+
if (!user) throw new Error('The ACMS account no longer exists.');
|
|
59
|
+
if (user.email === email) return false;
|
|
60
|
+
if (AUTH.provider === 'clerk') {
|
|
61
|
+
const linked = (await repo.auth.identities(clerkConfig(locals).issuer)).some((r) => String(r.userId) === userId);
|
|
62
|
+
if (linked) throw new Error('This account signs in with Clerk. Change its email in Clerk; ACMS picks it up automatically.');
|
|
63
|
+
}
|
|
64
|
+
const taken = await repo.getUserAuth(email);
|
|
65
|
+
if (taken && taken.user.id !== userId) throw new Error('Another account already uses this email.');
|
|
66
|
+
const builtin = AUTH.provider === 'builtin';
|
|
67
|
+
await repo.changeUserEmail(userId, email, { signOut: builtin, notify: builtin && opts.notify !== false });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Copy each linked account's verified primary Clerk email into ACMS so the
|
|
72
|
+
* users list, bylines and notifications follow changes made in Clerk. Runs
|
|
73
|
+
* when owners open the users page; failures leave the stored email as is.
|
|
74
|
+
*/
|
|
75
|
+
export async function syncClerkEmails(repo: ContentRepository, locals?: unknown) {
|
|
76
|
+
if (AUTH.provider !== 'clerk') return 0;
|
|
77
|
+
const config = clerkConfig(locals);
|
|
78
|
+
const links = new Map((await repo.auth.identities(config.issuer)).map((r) => [String(r.subject), String(r.userId)]));
|
|
79
|
+
if (!links.size) return 0;
|
|
80
|
+
const client = await clerkClient(locals);
|
|
81
|
+
const { data } = await clerkOperation(() => client.users.getUserList({ userId: [...links.keys()].slice(0, 100), limit: 100 }));
|
|
82
|
+
let changed = 0;
|
|
83
|
+
for (const external of data) {
|
|
84
|
+
const userId = links.get(external.id);
|
|
85
|
+
const email = external.emailAddresses.find(e => e.id === external.primaryEmailAddressId && e.verification?.status === 'verified')?.emailAddress.toLowerCase();
|
|
86
|
+
if (!userId || !email || !validAddress(email)) continue;
|
|
87
|
+
const user = await repo.getUser(userId);
|
|
88
|
+
if (!user || user.email === email) continue;
|
|
89
|
+
// never take an address another ACMS account holds; leave both untouched
|
|
90
|
+
if (await repo.getUserAuth(email)) continue;
|
|
91
|
+
try { await repo.changeUserEmail(userId, email, { signOut: false, notify: false }); changed++; } catch { /* concurrent change */ }
|
|
92
|
+
}
|
|
93
|
+
return changed;
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
import { runtimeValue } from '../email/config';
|
|
3
|
+
|
|
4
|
+
export const AUTH = { provider: (userConfig as { auth?: { provider: 'builtin' | 'clerk' } }).auth?.provider ?? 'builtin' };
|
|
5
|
+
export function clerkPublicConfig(locals?: unknown) {
|
|
6
|
+
const publishableKey = runtimeValue('PUBLIC_CLERK_PUBLISHABLE_KEY', locals).trim();
|
|
7
|
+
if (!/^pk_(test|live)_[A-Za-z0-9=]+$/.test(publishableKey)) throw new Error('Configure PUBLIC_CLERK_PUBLISHABLE_KEY.');
|
|
8
|
+
const host = Buffer.from(publishableKey.replace(/^pk_(test|live)_/, ''), 'base64').toString('utf8');
|
|
9
|
+
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\$$/.test(host) || !host.includes('.')) throw new Error('Invalid Clerk publishable key.');
|
|
10
|
+
return { publishableKey, frontendApi: host.slice(0, -1) };
|
|
11
|
+
}
|
|
12
|
+
export function clerkConfig(locals?: unknown) {
|
|
13
|
+
const publicConfig = clerkPublicConfig(locals);
|
|
14
|
+
const secretKey = runtimeValue('CLERK_SECRET_KEY', locals).trim();
|
|
15
|
+
if (!/^sk_(test|live)_/.test(secretKey)) throw new Error('Configure CLERK_SECRET_KEY.');
|
|
16
|
+
const authorizedParties = runtimeValue('CLERK_AUTHORIZED_PARTIES', locals).split(',').map(s => s.trim()).filter(Boolean);
|
|
17
|
+
if (!authorizedParties.length || authorizedParties.some(s => {
|
|
18
|
+
try { const u = new URL(s); return u.origin !== s || (u.protocol !== 'https:' && !(u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))); } catch { return true; }
|
|
19
|
+
})) throw new Error('Configure CLERK_AUTHORIZED_PARTIES with comma-separated trusted site origins.');
|
|
20
|
+
return { ...publicConfig, secretKey, jwtKey: runtimeValue('CLERK_JWT_KEY', locals) || undefined, authorizedParties, issuer: `https://${publicConfig.frontendApi}`,
|
|
21
|
+
ownerSubject: runtimeValue('CLERK_OWNER_USER_ID', locals).trim(), ownerLocalId: runtimeValue('CLERK_OWNER_LOCAL_USER_ID', locals).trim() };
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { sha256Hex } from '../api-keys';
|
|
3
|
+
|
|
4
|
+
// Builtin password sign-in throttle, in the same store and 15-minute windows
|
|
5
|
+
// as password reset. Counted before the password is checked, so it also caps
|
|
6
|
+
// hashing work. 20 per address leaves room for a shared office/church NAT; 10
|
|
7
|
+
// per account (the owner password is one account) stops spreading guesses for
|
|
8
|
+
// one login across many addresses. Windows expire, so a flood aimed at an
|
|
9
|
+
// account only delays its owner by minutes.
|
|
10
|
+
const WINDOW = 15 * 60_000;
|
|
11
|
+
export async function allowLogin(repo: ContentRepository, address: string, email: string): Promise<boolean> {
|
|
12
|
+
const ip = await repo.email.rateLimit(`login:ip:${await sha256Hex(address)}`, 20, WINDOW);
|
|
13
|
+
const account = await repo.email.rateLimit(`login:account:${await sha256Hex(email.trim().toLowerCase() || 'owner')}`, 10, WINDOW);
|
|
14
|
+
return ip && account;
|
|
15
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ClerkClient } from '@clerk/backend';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { validateSession, SESSION_COOKIE } from '../auth';
|
|
4
|
+
import { AUTH, clerkConfig } from './config';
|
|
5
|
+
import type { ClerkBinding } from './store';
|
|
6
|
+
|
|
7
|
+
type Cookies = { get(name: string): { value: string } | undefined };
|
|
8
|
+
export interface RequestAuth {
|
|
9
|
+
userId: string | null;
|
|
10
|
+
provider: 'builtin' | 'clerk';
|
|
11
|
+
clerk: ClerkBinding | null;
|
|
12
|
+
headers: Headers;
|
|
13
|
+
error: 'configuration' | 'unavailable' | 'access_denied' | null;
|
|
14
|
+
reverified: boolean;
|
|
15
|
+
}
|
|
16
|
+
const requests = new WeakMap<Request, Promise<RequestAuth>>();
|
|
17
|
+
const environments = new WeakMap<Request, unknown>();
|
|
18
|
+
const contexts = new WeakMap<object, Promise<RequestAuth>>();
|
|
19
|
+
const liveSessions = new Map<string, { until: number; active: boolean }>();
|
|
20
|
+
const operations = new Map<object, number>();
|
|
21
|
+
/** Bound caller wait time and concurrent callers, including cancelled Worker requests. */
|
|
22
|
+
export async function clerkOperation<T>(start: () => Promise<T>): Promise<T> {
|
|
23
|
+
// Expiring leases also recover when a Worker cancels the whole request
|
|
24
|
+
// before either its timer or promise continuation can run.
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
for (const [slot, until] of operations) if (until <= now) operations.delete(slot);
|
|
27
|
+
if (operations.size >= 64) throw new Error('Authentication service is busy.');
|
|
28
|
+
const slot = {}; operations.set(slot, now + 8000);
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
const pending = Promise.resolve().then(start);
|
|
31
|
+
try {
|
|
32
|
+
return await Promise.race([pending, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('Authentication service timed out.')), 8000); })]);
|
|
33
|
+
} finally { clearTimeout(timer); operations.delete(slot); }
|
|
34
|
+
}
|
|
35
|
+
export function invalidateClerkSession(binding: ClerkBinding) { liveSessions.delete(`${binding.issuer}:${binding.subject}:${binding.sessionId}`); }
|
|
36
|
+
export function bindAuthEnvironment(request: Request, locals: unknown) { environments.set(request, locals); }
|
|
37
|
+
export function shouldResolveClerk(request: Request): boolean {
|
|
38
|
+
const path = new URL(request.url).pathname;
|
|
39
|
+
if (/^\/(media|embed)(\/|$)/.test(path) || path.startsWith('/api/v1/')) return false;
|
|
40
|
+
return path.startsWith('/admin') || /(?:^|;\s*)__session(?:_[^=;]+)?=/.test(request.headers.get('cookie') ?? '');
|
|
41
|
+
}
|
|
42
|
+
export function withClerkHeaders(response: Response, auth: RequestAuth | null, request: Request): Response {
|
|
43
|
+
if (!auth) return response;
|
|
44
|
+
const output = new Response(response.body, response);
|
|
45
|
+
for (const value of auth.headers.getSetCookie()) output.headers.append('Set-Cookie', value);
|
|
46
|
+
if (auth.clerk || auth.headers.has('set-cookie') || /(?:^|;\s*)__session(?:_[^=;]+)?=/.test(request.headers.get('cookie') ?? '') || new URL(request.url).pathname.startsWith('/admin')) {
|
|
47
|
+
output.headers.set('Cache-Control', 'private, no-store');
|
|
48
|
+
output.headers.append('Vary', 'Cookie');
|
|
49
|
+
}
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
export function authEnvironment(request: Request, locals?: unknown) { return environments.get(request) ?? locals; }
|
|
53
|
+
export async function clerkClient(locals?: unknown): Promise<ClerkClient> {
|
|
54
|
+
const config = clerkConfig(locals);
|
|
55
|
+
const { createClerkClient } = await import('@clerk/backend');
|
|
56
|
+
return createClerkClient({ secretKey: config.secretKey, publishableKey: config.publishableKey, jwtKey: config.jwtKey });
|
|
57
|
+
}
|
|
58
|
+
function clerkStatus(client: ClerkClient, binding: ClerkBinding) {
|
|
59
|
+
return clerkOperation(() => Promise.all([client.sessions.getSession(binding.sessionId), client.users.getUser(binding.subject)]));
|
|
60
|
+
}
|
|
61
|
+
export async function activeClerkSession(client: ClerkClient, binding: ClerkBinding, fresh = true): Promise<boolean> {
|
|
62
|
+
const key = `${binding.issuer}:${binding.subject}:${binding.sessionId}`;
|
|
63
|
+
const cached = liveSessions.get(key);
|
|
64
|
+
if (!fresh && cached && cached.until > Date.now()) return cached.active;
|
|
65
|
+
let status: Awaited<ReturnType<typeof clerkStatus>>;
|
|
66
|
+
try { status = await clerkStatus(client, binding); }
|
|
67
|
+
catch (error) {
|
|
68
|
+
const { isClerkAPIResponseError } = await import('@clerk/backend/errors');
|
|
69
|
+
if (isClerkAPIResponseError(error) && error.status === 404) { liveSessions.delete(key); return false; }
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const [session, user] = status;
|
|
73
|
+
const active = session.userId === binding.subject && session.status === 'active' && session.expireAt > Date.now() && !user.banned && !user.locked;
|
|
74
|
+
for (const [k,v] of liveSessions) if (v.until <= Date.now()) liveSessions.delete(k);
|
|
75
|
+
if (liveSessions.size >= 256) liveSessions.delete(liveSessions.keys().next().value!);
|
|
76
|
+
liveSessions.set(key, { active, until: Math.min(Date.now() + 10000, session.expireAt) });
|
|
77
|
+
return active;
|
|
78
|
+
}
|
|
79
|
+
export function getRequestAuth(request: Request, cookies: Cookies, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
80
|
+
if (AUTH.provider === 'builtin') return validateSession(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(userId => ({ userId, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false }));
|
|
81
|
+
let cached = requests.get(request);
|
|
82
|
+
if (!cached) {
|
|
83
|
+
if (!(localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo)) environments.set(request, localsOrRepo);
|
|
84
|
+
cached = resolveClerk(request, localsOrRepo);
|
|
85
|
+
requests.set(request, cached);
|
|
86
|
+
if (localsOrRepo && typeof localsOrRepo === 'object' && !('email' in localsOrRepo)) contexts.set(localsOrRepo, cached);
|
|
87
|
+
}
|
|
88
|
+
return cached;
|
|
89
|
+
}
|
|
90
|
+
export async function validatePreviewSession(cookies: Cookies, locals?: unknown) {
|
|
91
|
+
if (AUTH.provider === 'builtin') return validateSession(cookies.get(SESSION_COOKIE)?.value, locals);
|
|
92
|
+
return locals && typeof locals === 'object' ? (await contexts.get(locals))?.userId ?? null : null;
|
|
93
|
+
}
|
|
94
|
+
async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
95
|
+
const result: RequestAuth = { userId: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false };
|
|
96
|
+
if (!shouldResolveClerk(request)) return result;
|
|
97
|
+
let config: ReturnType<typeof clerkConfig>;
|
|
98
|
+
try { config = clerkConfig(authEnvironment(request, localsOrRepo)); } catch { return { ...result, error: 'configuration' }; }
|
|
99
|
+
if (!config.authorizedParties.includes(new URL(request.url).origin)) return { ...result, error: 'configuration' };
|
|
100
|
+
try {
|
|
101
|
+
const client = await clerkClient(authEnvironment(request, localsOrRepo));
|
|
102
|
+
// Admin web routes accept cookie sessions only; mobile uses its separate
|
|
103
|
+
// device-token API. Never let a bearer header override a browser cookie.
|
|
104
|
+
const headers = new Headers(request.headers); headers.delete('authorization');
|
|
105
|
+
const authUrl = new URL(request.url);
|
|
106
|
+
// OAuth callback codes and other API query secrets must not enter Clerk's
|
|
107
|
+
// handshake redirect URL. APIs fail closed instead of redirecting.
|
|
108
|
+
if (authUrl.pathname.startsWith('/api/')) authUrl.search = '';
|
|
109
|
+
const state = await clerkOperation(() => client.authenticateRequest(new Request(authUrl, { method: request.method, headers }), { authorizedParties: config.authorizedParties, acceptsToken: 'session_token' }));
|
|
110
|
+
result.headers = state.headers;
|
|
111
|
+
const auth = state.toAuth();
|
|
112
|
+
if (!auth || !auth.userId || !auth.sessionId || !state.isAuthenticated) return result;
|
|
113
|
+
if (auth.sessionClaims.iss !== config.issuer || typeof auth.sessionClaims.azp !== 'string' || !config.authorizedParties.includes(auth.sessionClaims.azp)) return result;
|
|
114
|
+
const binding = { issuer: config.issuer, subject: auth.userId, sessionId: auth.sessionId };
|
|
115
|
+
if (!await activeClerkSession(client, binding, !['GET','HEAD'].includes(request.method))) return result;
|
|
116
|
+
result.clerk = binding;
|
|
117
|
+
result.reverified = auth.has({ reverification: 'strict' });
|
|
118
|
+
const { getRepo } = await import('../repo');
|
|
119
|
+
const repo = localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo ? localsOrRepo as ContentRepository : await getRepo(localsOrRepo);
|
|
120
|
+
let id = await repo.auth.identity(config.issuer, auth.userId);
|
|
121
|
+
if (!id) {
|
|
122
|
+
const user = await clerkOperation(() => client.users.getUser(auth.userId));
|
|
123
|
+
const email = user.emailAddresses.find(e => e.id === user.primaryEmailAddressId && e.verification?.status === 'verified')?.emailAddress.toLowerCase();
|
|
124
|
+
if (email) {
|
|
125
|
+
try {
|
|
126
|
+
if (config.ownerSubject && config.ownerSubject === user.id) {
|
|
127
|
+
if (config.ownerLocalId && (await repo.getUser(config.ownerLocalId))?.role !== 'owner') return { ...result, error: 'access_denied' };
|
|
128
|
+
await repo.auth.bootstrap(config.issuer, user.id, { email, name: [user.firstName, user.lastName].filter(Boolean).join(' ') || email, localId: config.ownerLocalId });
|
|
129
|
+
} else if (typeof user.publicMetadata.acmsInvitationId === 'string') {
|
|
130
|
+
await repo.auth.acceptInvitation(config.issuer, user.id, user.publicMetadata.acmsInvitationId, email);
|
|
131
|
+
}
|
|
132
|
+
} catch { /* Concurrent provisioning or a conflicting identity: re-read, never merge. */ }
|
|
133
|
+
id = await repo.auth.identity(config.issuer, auth.userId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
result.userId = id && await repo.getUser(id) ? id : null;
|
|
137
|
+
if (!result.userId) result.error = 'access_denied';
|
|
138
|
+
return result;
|
|
139
|
+
} catch { return { ...result, userId: null, error: 'unavailable' }; }
|
|
140
|
+
}
|
|
141
|
+
export async function validateRequestSession(request: Request, cookies: Cookies, localsOrRepo?: unknown) {
|
|
142
|
+
return (await getRequestAuth(request, cookies, localsOrRepo)).userId;
|
|
143
|
+
}
|
|
144
|
+
/** Stable browser-session binding for consent and third-party OAuth state. */
|
|
145
|
+
export async function requestSessionBinding(request: Request, cookies: Cookies, localsOrRepo?: unknown) {
|
|
146
|
+
const auth = await getRequestAuth(request, cookies, localsOrRepo);
|
|
147
|
+
if (!auth.userId) return '';
|
|
148
|
+
return auth.clerk ? `${auth.clerk.issuer}:${auth.clerk.sessionId}:${auth.userId}` : cookies.get(SESSION_COOKIE)?.value ?? '';
|
|
149
|
+
}
|
|
150
|
+
export class ClerkUnavailableError extends Error {
|
|
151
|
+
constructor() { super('Sign-in is temporarily unavailable. Please retry.'); }
|
|
152
|
+
}
|
|
153
|
+
export async function validateMobileBinding(repo: ContentRepository, kind: 'code' | 'device', id: string, userId: string, locals?: unknown, fresh = true) {
|
|
154
|
+
const binding = await repo.auth.mobile(kind, id);
|
|
155
|
+
if (AUTH.provider === 'builtin') return !binding;
|
|
156
|
+
if (!binding) return false;
|
|
157
|
+
try {
|
|
158
|
+
if (binding.issuer !== clerkConfig(locals).issuer || await repo.auth.identity(binding.issuer, binding.subject) !== userId) return false;
|
|
159
|
+
return await activeClerkSession(await clerkClient(locals), binding, fresh);
|
|
160
|
+
} catch { throw new ClerkUnavailableError(); }
|
|
161
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
import type { Role } from '../schema';
|
|
3
|
+
|
|
4
|
+
export interface ClerkBinding { issuer: string; subject: string; sessionId: string }
|
|
5
|
+
export interface AuthInvitation { id: string; email: string; name: string; role: Role; remoteId: string; state: string; expiresAt: number }
|
|
6
|
+
function invitation(r: Record<string, unknown>): AuthInvitation {
|
|
7
|
+
return { id: String(r.id), email: String(r.email), name: String(r.name), role: r.role as Role, remoteId: String(r.remote_id), state: r.state === 'pending' && Number(r.expires_at) <= Date.now() ? 'expired' : String(r.state), expiresAt: Number(r.expires_at) };
|
|
8
|
+
}
|
|
9
|
+
export class AuthStore {
|
|
10
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
11
|
+
async identity(issuer: string, subject: string): Promise<string | null> {
|
|
12
|
+
const r = await (await this.driver()).all('SELECT user_id FROM auth_identities WHERE issuer=? AND subject=? AND disabled=0', [issuer, subject]);
|
|
13
|
+
return r[0] ? String(r[0].user_id) : null;
|
|
14
|
+
}
|
|
15
|
+
async identities(issuer: string) {
|
|
16
|
+
return (await this.driver()).all('SELECT user_id AS userId, subject FROM auth_identities WHERE issuer=? AND disabled=0', [issuer]);
|
|
17
|
+
}
|
|
18
|
+
async link(issuer: string, subject: string, userId: string) {
|
|
19
|
+
// Explicit owner-approved links may replace disabled tombstones only.
|
|
20
|
+
// Active identities are immutable; uniqueness conflicts roll back the batch.
|
|
21
|
+
await (await this.driver()).batch([
|
|
22
|
+
{ sql: 'DELETE FROM auth_identities WHERE issuer=? AND disabled=1 AND (subject=? OR user_id=?)', args: [issuer, subject, userId] },
|
|
23
|
+
{ sql: 'INSERT INTO auth_identities(issuer,subject,user_id) SELECT ?,?,id FROM users WHERE id=?', args: [issuer, subject, userId] },
|
|
24
|
+
{ sql: 'UPDATE users SET session_version=session_version+1 WHERE id=?', args: [userId] },
|
|
25
|
+
]);
|
|
26
|
+
if (await this.identity(issuer, subject) !== userId) throw new Error('This Clerk identity is already linked to another account.');
|
|
27
|
+
}
|
|
28
|
+
async bootstrap(issuer: string, subject: string, data: { email: string; name: string; localId: string }) {
|
|
29
|
+
const db = await this.driver(), id = data.localId || crypto.randomUUID();
|
|
30
|
+
const exists = await db.all('SELECT 1 FROM auth_bootstrap WHERE issuer=?', [issuer]);
|
|
31
|
+
if (exists.length) return;
|
|
32
|
+
await db.batch([
|
|
33
|
+
{ sql: 'INSERT INTO auth_bootstrap(issuer) VALUES (?)', args: [issuer] },
|
|
34
|
+
...(data.localId ? [] : [{ sql: "INSERT INTO users(id,name,email,role,pass_hash,created_at) VALUES (?,?,?,'owner','',?)", args: [id, data.name, data.email, Date.now()] }]),
|
|
35
|
+
{ sql: "INSERT INTO auth_identities(issuer,subject,user_id) SELECT ?,?,id FROM users WHERE id=? AND role='owner'", args: [issuer, subject, id] },
|
|
36
|
+
{ sql: 'UPDATE users SET session_version=session_version+1 WHERE id=?', args: [id] },
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
async invitations(issuer: string): Promise<AuthInvitation[]> {
|
|
40
|
+
return (await (await this.driver()).all('SELECT * FROM auth_invitations WHERE issuer=? ORDER BY created_at DESC LIMIT 200', [issuer])).map(invitation);
|
|
41
|
+
}
|
|
42
|
+
async invitation(issuer: string, id: string) {
|
|
43
|
+
const rows = await (await this.driver()).all('SELECT * FROM auth_invitations WHERE issuer=? AND id=?', [issuer, id]);
|
|
44
|
+
return rows[0] ? invitation(rows[0]) : null;
|
|
45
|
+
}
|
|
46
|
+
async reserveInvitation(issuer: string, data: { id: string; email: string; name: string; role: Role }) {
|
|
47
|
+
await (await this.driver()).run("UPDATE auth_invitations SET state='expired' WHERE issuer=? AND email=? AND state='pending' AND expires_at<=?", [issuer,data.email,Date.now()]);
|
|
48
|
+
await (await this.driver()).run("INSERT INTO auth_invitations(id,issuer,email,name,role,state,remote_id,expires_at,created_at) VALUES (?,?,?,?,?,'sending','',?,?)", [data.id, issuer, data.email, data.name, data.role, Date.now() + 7 * 86400000, Date.now()]);
|
|
49
|
+
}
|
|
50
|
+
async invitationState(id: string, state: string, remoteId = '') {
|
|
51
|
+
const guard = state === 'pending' || state === 'failed' ? " AND state='sending'" : " AND state<>'accepted'";
|
|
52
|
+
await (await this.driver()).run(`UPDATE auth_invitations SET state=?,remote_id=CASE WHEN ?='' THEN remote_id ELSE ? END WHERE id=?${guard}`, [state, remoteId, remoteId, id]);
|
|
53
|
+
}
|
|
54
|
+
async acceptInvitation(issuer: string, subject: string, id: string, email: string): Promise<void> {
|
|
55
|
+
const userId = crypto.randomUUID(), db = await this.driver();
|
|
56
|
+
// The metadata ID came from Clerk's server-controlled publicMetadata;
|
|
57
|
+
// verified email plus the live local invitation are both required.
|
|
58
|
+
await db.batch([
|
|
59
|
+
{ sql: `INSERT INTO users(id,name,email,role,pass_hash,created_at) SELECT ?,name,email,role,'',? FROM auth_invitations
|
|
60
|
+
WHERE id=? AND issuer=? AND email=? AND state='pending' AND expires_at>?`, args: [userId, Date.now(), id, issuer, email, Date.now()] },
|
|
61
|
+
{ sql: 'INSERT INTO auth_identities(issuer,subject,user_id) SELECT ?,?,id FROM users WHERE id=?', args: [issuer, subject, userId] },
|
|
62
|
+
{ sql: "UPDATE auth_invitations SET state='accepted' WHERE id=? AND EXISTS(SELECT 1 FROM users WHERE id=?)", args: [id, userId] },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
async bindMobile(kind: 'code' | 'device', id: string, binding: ClerkBinding) {
|
|
66
|
+
await (await this.driver()).run('INSERT INTO auth_mobile(kind,id,issuer,subject,session_id) VALUES (?,?,?,?,?)', [kind, id, binding.issuer, binding.subject, binding.sessionId]);
|
|
67
|
+
}
|
|
68
|
+
async mobile(kind: 'code' | 'device', id: string): Promise<ClerkBinding | null> {
|
|
69
|
+
const r = await (await this.driver()).all('SELECT * FROM auth_mobile WHERE kind=? AND id=?', [kind, id]);
|
|
70
|
+
return r[0] ? { issuer: String(r[0].issuer), subject: String(r[0].subject), sessionId: String(r[0].session_id) } : null;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTH } from './auth/config';
|
|
1
2
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import { env } from './env';
|
|
3
4
|
|
|
@@ -60,6 +61,7 @@ export function verifySessionToken(token: string | undefined): string | null {
|
|
|
60
61
|
|
|
61
62
|
// The env master password — bootstrap owner login, kept for backward compat.
|
|
62
63
|
export function checkPassword(input: string): boolean {
|
|
64
|
+
if (AUTH.provider !== 'builtin') return false;
|
|
63
65
|
const pw = env('ADMIN_PASSWORD');
|
|
64
66
|
if (!pw) return false;
|
|
65
67
|
return safeEqual(input, pw);
|
|
@@ -103,6 +105,7 @@ export const SESSION_COOKIE = COOKIE;
|
|
|
103
105
|
|
|
104
106
|
// All authorization paths must consult the database, not just the signature.
|
|
105
107
|
export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
|
|
108
|
+
if (AUTH.provider !== 'builtin') return null;
|
|
106
109
|
const id = verifySessionToken(token);
|
|
107
110
|
if (!id || id === OWNER_ENV) return id;
|
|
108
111
|
const { getRepo } = await import('./repo');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { getMediaStore } from '../media/store';
|
|
3
|
+
import { exportContent } from './content';
|
|
4
|
+
import { zipStream, type ZipEntry } from './zip';
|
|
5
|
+
|
|
6
|
+
// The full-site backup ZIP: content.json with every table, fetched through
|
|
7
|
+
// the repo so sqlite and D1 both export the same shape, plus the media
|
|
8
|
+
// binaries (originals + variants) under media/<key>. Password hashes never
|
|
9
|
+
// leave the repo layer, so user accounts export without them. Shared by the
|
|
10
|
+
// download (Admin → Backup) and scheduled off-site backups.
|
|
11
|
+
export async function backupArchive(repo: ContentRepository, locals: unknown, now = Date.now()): Promise<ReadableStream<Uint8Array>> {
|
|
12
|
+
const content = await exportContent(repo, now);
|
|
13
|
+
const { media } = content;
|
|
14
|
+
const store = getMediaStore(locals);
|
|
15
|
+
async function* entries(): AsyncGenerator<ZipEntry> {
|
|
16
|
+
yield { name: 'content.json', data: new TextEncoder().encode(JSON.stringify(content, null, 2)) };
|
|
17
|
+
for (const m of media) {
|
|
18
|
+
for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
|
|
19
|
+
const obj = await store.get(key);
|
|
20
|
+
if (!obj) throw new Error('Backup incomplete: a media object is missing.');
|
|
21
|
+
yield { name: `media/${key}`, data: new Uint8Array(await new Response(obj.body).arrayBuffer()) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return zipStream(entries(), now);
|
|
26
|
+
}
|
|
@@ -38,7 +38,7 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
38
38
|
]);
|
|
39
39
|
return {
|
|
40
40
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
41
|
-
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
|
|
41
|
+
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
42
42
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
43
43
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
44
44
|
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
|