@glw907/cairn-cms 0.33.0 → 0.35.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/CHANGELOG.md +36 -0
- package/dist/auth/crypto.d.ts +4 -0
- package/dist/auth/crypto.js +10 -0
- package/dist/components/AdminLayout.svelte +8 -1
- package/dist/components/ConceptList.svelte +3 -0
- package/dist/components/ConfirmPage.svelte +4 -2
- package/dist/components/ConfirmPage.svelte.d.ts +2 -1
- package/dist/components/CsrfField.svelte +20 -0
- package/dist/components/CsrfField.svelte.d.ts +12 -0
- package/dist/components/DeleteDialog.svelte +2 -0
- package/dist/components/EditPage.svelte +2 -0
- package/dist/components/LoginPage.svelte +5 -3
- package/dist/components/LoginPage.svelte.d.ts +2 -1
- package/dist/components/ManageEditors.svelte +4 -0
- package/dist/components/NavTree.svelte +2 -0
- package/dist/components/RenameDialog.svelte +3 -0
- package/dist/components/csrf-context.d.ts +2 -0
- package/dist/components/csrf-context.js +2 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/sveltekit/auth-routes.d.ts +2 -0
- package/dist/sveltekit/auth-routes.js +10 -3
- package/dist/sveltekit/content-routes.d.ts +6 -4
- package/dist/sveltekit/content-routes.js +2 -0
- package/dist/sveltekit/csrf-required-page.d.ts +2 -0
- package/dist/sveltekit/csrf-required-page.js +25 -0
- package/dist/sveltekit/csrf.d.ts +18 -0
- package/dist/sveltekit/csrf.js +60 -0
- package/dist/sveltekit/guard.js +57 -1
- package/dist/sveltekit/https-required-page.d.ts +5 -0
- package/dist/sveltekit/https-required-page.js +35 -0
- package/dist/sveltekit/static-admin-page.d.ts +11 -0
- package/dist/sveltekit/static-admin-page.js +195 -0
- package/package.json +2 -1
- package/src/lib/auth/crypto.ts +13 -0
- package/src/lib/components/AdminLayout.svelte +8 -1
- package/src/lib/components/ConceptList.svelte +3 -0
- package/src/lib/components/ConfirmPage.svelte +4 -2
- package/src/lib/components/CsrfField.svelte +20 -0
- package/src/lib/components/DeleteDialog.svelte +2 -0
- package/src/lib/components/EditPage.svelte +2 -0
- package/src/lib/components/LoginPage.svelte +5 -3
- package/src/lib/components/ManageEditors.svelte +4 -0
- package/src/lib/components/NavTree.svelte +2 -0
- package/src/lib/components/RenameDialog.svelte +3 -0
- package/src/lib/components/csrf-context.ts +2 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/sveltekit/auth-routes.ts +12 -5
- package/src/lib/sveltekit/content-routes.ts +8 -2
- package/src/lib/sveltekit/csrf-required-page.ts +26 -0
- package/src/lib/sveltekit/csrf.ts +61 -0
- package/src/lib/sveltekit/guard.ts +68 -1
- package/src/lib/sveltekit/https-required-page.ts +36 -0
- package/src/lib/sveltekit/static-admin-page.ts +200 -0
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../auth/crypto.js';
|
|
15
15
|
import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
|
|
16
16
|
import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
|
|
17
|
+
import { issueCsrfToken } from './csrf.js';
|
|
17
18
|
import type { RequestContext } from './types.js';
|
|
18
19
|
|
|
19
20
|
export interface AuthRoutesConfig {
|
|
@@ -63,23 +64,29 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
|
63
64
|
return { sent: true };
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/** GET /admin/login. Public. Carries the site name
|
|
67
|
-
function loginLoad(event: RequestContext): { siteName: string; error: string | null } {
|
|
68
|
-
return {
|
|
67
|
+
/** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
|
|
68
|
+
function loginLoad(event: RequestContext): { siteName: string; error: string | null; csrf: string } {
|
|
69
|
+
return {
|
|
70
|
+
siteName: config.branding.siteName,
|
|
71
|
+
error: event.url.searchParams.get('error'),
|
|
72
|
+
csrf: issueCsrfToken(event),
|
|
73
|
+
};
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* GET /admin/auth/confirm. Renders the confirm page and consumes nothing; only the POST
|
|
73
|
-
* verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer
|
|
78
|
+
* verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer, and
|
|
79
|
+
* issues the CSRF token so the confirm form can render the hidden field.
|
|
74
80
|
*/
|
|
75
81
|
function confirmLoad(
|
|
76
82
|
event: RequestContext,
|
|
77
|
-
): { token: string; siteName: string; error: string | null } {
|
|
83
|
+
): { token: string; siteName: string; error: string | null; csrf: string } {
|
|
78
84
|
event.setHeaders({ 'Referrer-Policy': 'no-referrer' });
|
|
79
85
|
return {
|
|
80
86
|
token: event.url.searchParams.get('token') ?? '',
|
|
81
87
|
siteName: config.branding.siteName,
|
|
82
88
|
error: event.url.searchParams.get('error'),
|
|
89
|
+
csrf: issueCsrfToken(event),
|
|
83
90
|
};
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -13,6 +13,8 @@ import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/r
|
|
|
13
13
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
14
14
|
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
15
15
|
import { CommitConflictError } from '../github/types.js';
|
|
16
|
+
import { issueCsrfToken } from './csrf.js';
|
|
17
|
+
import type { CookieJar } from './types.js';
|
|
16
18
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
17
19
|
import type { Editor, Role } from '../auth/types.js';
|
|
18
20
|
|
|
@@ -36,6 +38,8 @@ export interface LayoutData {
|
|
|
36
38
|
/** The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
|
|
37
39
|
* collapsed group renders collapsed with no flash. Empty when none are collapsed. */
|
|
38
40
|
collapsedNav: string[];
|
|
41
|
+
/** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
|
|
42
|
+
csrf: string;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/** One row in a concept's list view. */
|
|
@@ -88,8 +92,9 @@ export interface ContentEvent {
|
|
|
88
92
|
request: Request;
|
|
89
93
|
locals: { editor?: Editor | null };
|
|
90
94
|
platform?: { env?: GithubKeyEnv };
|
|
91
|
-
/** SvelteKit's cookie jar
|
|
92
|
-
|
|
95
|
+
/** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
|
|
96
|
+
* token. Optional for non-route callers. */
|
|
97
|
+
cookies?: CookieJar;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
@@ -134,6 +139,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
134
139
|
navLabel: runtime.navMenu?.label ?? null,
|
|
135
140
|
theme,
|
|
136
141
|
collapsedNav,
|
|
142
|
+
csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
|
|
137
143
|
};
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The branded 403 the guard serves when an admin form POST fails the double-submit token check.
|
|
2
|
+
// A sibling to https-required-page, built through the shared shell. It names the likely cause and
|
|
3
|
+
// offers a fresh sign-in, and it does not mention Origin headers (the token path does not read them).
|
|
4
|
+
import { renderStaticAdminPage } from './static-admin-page.js';
|
|
5
|
+
|
|
6
|
+
/** Render the full HTML document for the CSRF-failed page. */
|
|
7
|
+
export function csrfRequiredPage(): string {
|
|
8
|
+
const inner = `
|
|
9
|
+
<span class="eyebrow">
|
|
10
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
11
|
+
Security check
|
|
12
|
+
</span>
|
|
13
|
+
<h1>Let's try that again</h1>
|
|
14
|
+
<p>Your sign-in form could not be verified. This usually means the page was open across a browser restart, or cookies are blocked for this site.</p>
|
|
15
|
+
|
|
16
|
+
<a class="cta" href="/admin/login">
|
|
17
|
+
Back to sign-in
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
19
|
+
</a>
|
|
20
|
+
|
|
21
|
+
<div class="fix">
|
|
22
|
+
<h2>If it keeps happening</h2>
|
|
23
|
+
<p>Allow cookies for this site, then open the sign-in page fresh and request a new link.</p>
|
|
24
|
+
</div>`;
|
|
25
|
+
return renderStaticAdminPage({ title: 'Security check · Cairn', innerHtml: inner });
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// cairn owns CSRF for the admin once a site disables SvelteKit's global checkOrigin. These helpers
|
|
2
|
+
// back the guard's two rules and the loads that issue the double-submit token. See
|
|
3
|
+
// docs/superpowers/specs/2026-06-08-cairn-login-csrf-ownership-design.md.
|
|
4
|
+
import { csrfCookieName, generateCsrfToken } from '../auth/crypto.js';
|
|
5
|
+
import type { CookieJar, RequestContext } from './types.js';
|
|
6
|
+
|
|
7
|
+
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
8
|
+
const FORM_CONTENT_TYPES = new Set([
|
|
9
|
+
'application/x-www-form-urlencoded',
|
|
10
|
+
'multipart/form-data',
|
|
11
|
+
'text/plain',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** True for a request SvelteKit's CSRF guard screens: an unsafe method with a form content type. */
|
|
15
|
+
export function isUnsafeFormRequest(request: Request): boolean {
|
|
16
|
+
if (!UNSAFE_METHODS.has(request.method)) return false;
|
|
17
|
+
const type = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();
|
|
18
|
+
return FORM_CONTENT_TYPES.has(type);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The faithful framework check: the Origin header equals the request's own origin. */
|
|
22
|
+
export function originMatches(event: Pick<RequestContext, 'url' | 'request'>): boolean {
|
|
23
|
+
return event.request.headers.get('origin') === event.url.origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A length-checked constant-time compare, so the token check leaks no timing. */
|
|
27
|
+
export function tokensMatch(a: string, b: string): boolean {
|
|
28
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
29
|
+
let diff = 0;
|
|
30
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
31
|
+
return diff === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the session's CSRF token, minting and setting it when absent. Lazy and stable: a second
|
|
36
|
+
* open admin tab reuses the same value, so its form field still matches the cookie. Session-scoped
|
|
37
|
+
* (no maxAge), HttpOnly (the server sets both halves), SameSite=Strict, and __Host- on https.
|
|
38
|
+
*/
|
|
39
|
+
export function issueCsrfToken(event: { url: URL; cookies: CookieJar }): string {
|
|
40
|
+
const secure = event.url.protocol === 'https:';
|
|
41
|
+
const name = csrfCookieName(secure);
|
|
42
|
+
const existing = event.cookies.get(name);
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
const token = generateCsrfToken();
|
|
45
|
+
event.cookies.set(name, token, { path: '/', httpOnly: true, secure, sameSite: 'strict' });
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
|
|
50
|
+
export async function validateCsrfToken(event: RequestContext): Promise<boolean> {
|
|
51
|
+
const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
|
|
52
|
+
if (!cookie) return false;
|
|
53
|
+
let submitted = '';
|
|
54
|
+
try {
|
|
55
|
+
const form = await event.request.clone().formData();
|
|
56
|
+
submitted = String(form.get('csrf') ?? '');
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return tokensMatch(submitted, cookie);
|
|
61
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
+
import { httpsRequiredPage } from './https-required-page.js';
|
|
8
|
+
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
9
|
+
import { csrfRequiredPage } from './csrf-required-page.js';
|
|
7
10
|
import type { Editor } from '../auth/types.js';
|
|
8
11
|
import type { HandleInput, RequestContext } from './types.js';
|
|
9
12
|
|
|
@@ -16,6 +19,22 @@ function isAdminPath(pathname: string): boolean {
|
|
|
16
19
|
return pathname === '/admin' || pathname.startsWith('/admin/');
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Local development (`wrangler dev`) legitimately speaks http; a deployed host does not. The hostname
|
|
24
|
+
* comes from the client `Host` header, so this is UX only: it decides whether to show the help page,
|
|
25
|
+
* never whether to grant access. The session gate below runs regardless. Do not make it an auth check.
|
|
26
|
+
*/
|
|
27
|
+
function isLocalHost(hostname: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
hostname === 'localhost' ||
|
|
30
|
+
hostname === '127.0.0.1' ||
|
|
31
|
+
hostname === '0.0.0.0' ||
|
|
32
|
+
hostname === '::1' ||
|
|
33
|
+
hostname === '[::1]' ||
|
|
34
|
+
hostname.endsWith('.localhost')
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
/**
|
|
20
39
|
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
21
40
|
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
@@ -29,11 +48,59 @@ function applySecurityHeaders(headers: Headers): void {
|
|
|
29
48
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
52
|
+
function brandedAdminPage(status: number, body: string): Response {
|
|
53
|
+
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
54
|
+
applySecurityHeaders(headers);
|
|
55
|
+
return new Response(body, { status, headers });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
59
|
+
function httpsRequiredResponse(url: URL): Response {
|
|
60
|
+
const httpsUrl = new URL(url);
|
|
61
|
+
httpsUrl.protocol = 'https:';
|
|
62
|
+
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
|
|
66
|
+
function csrfForbidden(): Response {
|
|
67
|
+
return new Response('Cross-site POST form submissions are forbidden', {
|
|
68
|
+
status: 403,
|
|
69
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The branded 403 for a failed admin double-submit token check. */
|
|
74
|
+
function csrfRequiredResponse(): Response {
|
|
75
|
+
return brandedAdminPage(403, csrfRequiredPage());
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
33
79
|
export function createAuthGuard() {
|
|
34
80
|
return async function handle({ event, resolve }: HandleInput): Promise<Response> {
|
|
35
81
|
const { pathname } = event.url;
|
|
36
|
-
|
|
82
|
+
|
|
83
|
+
// Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
|
|
84
|
+
// they set checkOrigin: false to hand cairn the admin CSRF authority.
|
|
85
|
+
if (!isAdminPath(pathname)) {
|
|
86
|
+
if (isUnsafeFormRequest(event.request) && !originMatches(event)) return csrfForbidden();
|
|
87
|
+
return resolve(event);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A deployed admin request over http never works: the magic-link form POST would fail the
|
|
91
|
+
// framework's CSRF guard with an opaque 403. Serve the help page instead, before resolve()
|
|
92
|
+
// runs that check. This covers the public login/auth paths too, since that is where the form
|
|
93
|
+
// posts. Local http (wrangler dev) is exempt.
|
|
94
|
+
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
95
|
+
return httpsRequiredResponse(event.url);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
99
|
+
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
100
|
+
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
101
|
+
return csrfRequiredResponse();
|
|
102
|
+
}
|
|
103
|
+
|
|
37
104
|
if (!isPublicAdminPath(pathname)) {
|
|
38
105
|
const env = event.platform?.env ?? {};
|
|
39
106
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The "this admin needs HTTPS" page. The auth guard serves it when a request reaches a deployed
|
|
2
|
+
// Worker over http, which is the one case that makes the magic-link sign-in fail: the JS-free login
|
|
3
|
+
// form posts over http, and the framework's CSRF guard rejects a form POST whose origin scheme does
|
|
4
|
+
// not match, so the editor would otherwise hit an opaque 403. This page names the problem, says why
|
|
5
|
+
// https is needed, and gives the exact Cloudflare fix. The shared shell lives in
|
|
6
|
+
// static-admin-page.ts. See guard.ts.
|
|
7
|
+
import { escapeHtml, renderStaticAdminPage } from './static-admin-page.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render the full HTML document for the HTTPS-required page.
|
|
11
|
+
* @param httpsUrl The same request rebuilt over https, offered as the one-click recovery link.
|
|
12
|
+
*/
|
|
13
|
+
export function httpsRequiredPage(httpsUrl: string): string {
|
|
14
|
+
const href = escapeHtml(httpsUrl);
|
|
15
|
+
const inner = `
|
|
16
|
+
<span class="eyebrow">
|
|
17
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
18
|
+
Secure connection required
|
|
19
|
+
</span>
|
|
20
|
+
<h1>This admin needs a secure connection</h1>
|
|
21
|
+
<p>You opened this page over http. Sign-in only works over https, so open the secure version to continue.</p>
|
|
22
|
+
|
|
23
|
+
<a class="cta" href="${href}">
|
|
24
|
+
Open over HTTPS
|
|
25
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
26
|
+
</a>
|
|
27
|
+
|
|
28
|
+
<div class="fix">
|
|
29
|
+
<h2>If you run this site</h2>
|
|
30
|
+
<p>Turn on Always Use HTTPS in Cloudflare. It upgrades every request to https before it reaches the site:</p>
|
|
31
|
+
<span class="path">SSL/TLS<span class="arrow">›</span>Edge Certificates<span class="arrow">›</span>Always Use HTTPS</span>
|
|
32
|
+
<p>Keep HSTS on too. The browser then stays on https and sign-in works.</p>
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
return renderStaticAdminPage({ title: 'HTTPS required · Cairn', innerHtml: inner });
|
|
36
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The shared shell for cairn's edge-served admin pages (HTTPS-required, CSRF-failed). Each is a
|
|
2
|
+
// self-contained document with inlined Warm Stone tokens for both colour schemes and the system
|
|
3
|
+
// font stack, served raw before SvelteKit renders. The cairn glyph is the same public-domain
|
|
4
|
+
// Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
|
|
5
|
+
|
|
6
|
+
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
7
|
+
export function escapeHtml(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
16
|
+
const CAIRN_GLYPH =
|
|
17
|
+
'<path d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 ' +
|
|
18
|
+
'13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 ' +
|
|
19
|
+
'6.28 14ZM6.92 4.5C6.67 4.5 5 4.43 5 3.88C5 3.07 5.75 2.51 5.96 2.35C6.36 2.03 6.32 1.62 6.54 ' +
|
|
20
|
+
'1.27C6.84 0.79 7.61 0.5 7.88 0.5C8.1 0.5 8.75 0.9 9.23 1.42C9.45 1.66 10 2.77 10 3.12C10 4.22 ' +
|
|
21
|
+
'9.36 4.5 8.85 4.5C8.33 4.5 8.15 4.5 6.92 4.5ZM3.68 8.22C3 7.73 3.67 6.86 4.57 6.21C5.38 5.63 ' +
|
|
22
|
+
'5.92 5.96 6.79 5.7C8.33 5.24 9.02 5.72 9.02 5.72L10.9 6.82C12.03 7.63 10.99 7.67 10.38 8.56C9.79 ' +
|
|
23
|
+
'9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"/>';
|
|
24
|
+
|
|
25
|
+
// The verbatim rule set lifted from the original <style> block: the `:root` light tokens, the
|
|
26
|
+
// `@media (prefers-color-scheme: dark)` block, and every rule through `.foot`. It already covers
|
|
27
|
+
// every class both pages use (brand, eyebrow, cta, fix, path, foot).
|
|
28
|
+
const SHARED_STYLE = `:root {
|
|
29
|
+
color-scheme: light;
|
|
30
|
+
--bg: oklch(96.5% 0.006 75);
|
|
31
|
+
--glow: oklch(52% 0.2 293 / 0.06);
|
|
32
|
+
--panel: oklch(99% 0.004 75);
|
|
33
|
+
--recessed: oklch(95% 0.008 75);
|
|
34
|
+
--ink: oklch(26% 0.014 75);
|
|
35
|
+
--muted: oklch(48% 0.01 75);
|
|
36
|
+
--subtle: oklch(42% 0.01 75);
|
|
37
|
+
--primary: oklch(52% 0.2 293);
|
|
38
|
+
--primary-content: oklch(98% 0.012 293);
|
|
39
|
+
--border: oklch(93% 0.008 75);
|
|
40
|
+
--shadow: 0 1px 2px oklch(28% 0.02 75 / 0.05), 0 18px 40px -12px oklch(28% 0.02 75 / 0.16);
|
|
41
|
+
--radius-box: 1rem;
|
|
42
|
+
--radius-field: 0.625rem;
|
|
43
|
+
--font: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
44
|
+
}
|
|
45
|
+
@media (prefers-color-scheme: dark) {
|
|
46
|
+
:root {
|
|
47
|
+
color-scheme: dark;
|
|
48
|
+
--bg: oklch(15.5% 0.009 75);
|
|
49
|
+
--glow: oklch(68% 0.18 293 / 0.1);
|
|
50
|
+
--panel: oklch(24% 0.01 75);
|
|
51
|
+
--recessed: oklch(20% 0.01 75);
|
|
52
|
+
--ink: oklch(93% 0.006 75);
|
|
53
|
+
--muted: oklch(72% 0.01 75);
|
|
54
|
+
--subtle: oklch(80% 0.008 75);
|
|
55
|
+
--primary: oklch(68% 0.18 293);
|
|
56
|
+
--primary-content: oklch(20% 0.04 293);
|
|
57
|
+
--border: oklch(30% 0.014 75);
|
|
58
|
+
--shadow: 0 1px 2px oklch(0% 0 0 / 0.35), 0 18px 40px -12px oklch(0% 0 0 / 0.55);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
body {
|
|
63
|
+
margin: 0;
|
|
64
|
+
min-height: 100vh;
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
padding: 1.5rem;
|
|
69
|
+
font-family: var(--font);
|
|
70
|
+
color: var(--ink);
|
|
71
|
+
background-color: var(--bg);
|
|
72
|
+
background-image: radial-gradient(80rem 50rem at 50% -20%, var(--glow), transparent 60%);
|
|
73
|
+
-webkit-font-smoothing: antialiased;
|
|
74
|
+
-moz-osx-font-smoothing: grayscale;
|
|
75
|
+
line-height: 1.55;
|
|
76
|
+
}
|
|
77
|
+
main {
|
|
78
|
+
width: 100%;
|
|
79
|
+
max-width: 30rem;
|
|
80
|
+
background: var(--panel);
|
|
81
|
+
border: 1px solid var(--border);
|
|
82
|
+
border-radius: var(--radius-box);
|
|
83
|
+
box-shadow: var(--shadow);
|
|
84
|
+
padding: 2.25rem;
|
|
85
|
+
}
|
|
86
|
+
.brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; }
|
|
87
|
+
.brand .tile {
|
|
88
|
+
display: grid;
|
|
89
|
+
place-items: center;
|
|
90
|
+
width: 2rem;
|
|
91
|
+
height: 2rem;
|
|
92
|
+
border-radius: 0.75rem;
|
|
93
|
+
background: var(--primary);
|
|
94
|
+
color: var(--primary-content);
|
|
95
|
+
box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12);
|
|
96
|
+
}
|
|
97
|
+
.brand .tile svg { width: 1.25rem; height: 1.25rem; }
|
|
98
|
+
.brand .word {
|
|
99
|
+
font-weight: 700;
|
|
100
|
+
font-size: 1.25rem;
|
|
101
|
+
letter-spacing: -0.01em;
|
|
102
|
+
}
|
|
103
|
+
.eyebrow {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 0.4rem;
|
|
107
|
+
font-size: 0.6875rem;
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
text-transform: uppercase;
|
|
110
|
+
letter-spacing: 0.08em;
|
|
111
|
+
color: var(--muted);
|
|
112
|
+
margin-bottom: 0.6rem;
|
|
113
|
+
}
|
|
114
|
+
.eyebrow svg { width: 0.85rem; height: 0.85rem; }
|
|
115
|
+
h1 {
|
|
116
|
+
margin: 0 0 0.75rem;
|
|
117
|
+
font-size: 1.6rem;
|
|
118
|
+
font-weight: 800;
|
|
119
|
+
letter-spacing: -0.02em;
|
|
120
|
+
line-height: 1.15;
|
|
121
|
+
}
|
|
122
|
+
p { margin: 0 0 1rem; color: var(--subtle); }
|
|
123
|
+
.cta {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
gap: 0.5rem;
|
|
127
|
+
margin: 0.25rem 0 0.5rem;
|
|
128
|
+
padding: 0.7rem 1.15rem;
|
|
129
|
+
border-radius: var(--radius-field);
|
|
130
|
+
background: var(--primary);
|
|
131
|
+
color: var(--primary-content);
|
|
132
|
+
font-weight: 600;
|
|
133
|
+
font-size: 0.95rem;
|
|
134
|
+
text-decoration: none;
|
|
135
|
+
box-shadow: 0 4px 14px -4px oklch(52% 0.2 293 / 0.5);
|
|
136
|
+
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
137
|
+
}
|
|
138
|
+
.cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px -6px oklch(52% 0.2 293 / 0.55); }
|
|
139
|
+
.cta:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
140
|
+
.cta svg { width: 1rem; height: 1rem; }
|
|
141
|
+
.fix {
|
|
142
|
+
margin-top: 1.75rem;
|
|
143
|
+
padding: 1.1rem 1.2rem;
|
|
144
|
+
background: var(--recessed);
|
|
145
|
+
border: 1px solid var(--border);
|
|
146
|
+
border-radius: var(--radius-field);
|
|
147
|
+
}
|
|
148
|
+
.fix h2 {
|
|
149
|
+
margin: 0 0 0.5rem;
|
|
150
|
+
font-size: 0.8125rem;
|
|
151
|
+
font-weight: 700;
|
|
152
|
+
letter-spacing: 0.01em;
|
|
153
|
+
}
|
|
154
|
+
.fix p { margin: 0 0 0.65rem; font-size: 0.875rem; }
|
|
155
|
+
.fix p:last-child { margin-bottom: 0; }
|
|
156
|
+
.path {
|
|
157
|
+
display: block;
|
|
158
|
+
font-size: 0.8125rem;
|
|
159
|
+
font-weight: 600;
|
|
160
|
+
color: var(--ink);
|
|
161
|
+
letter-spacing: 0.01em;
|
|
162
|
+
margin: 0 0 0.65rem;
|
|
163
|
+
}
|
|
164
|
+
.path .arrow { color: var(--muted); padding: 0 0.35rem; font-weight: 400; }
|
|
165
|
+
.foot {
|
|
166
|
+
margin-top: 1.75rem;
|
|
167
|
+
text-align: center;
|
|
168
|
+
font-size: 0.75rem;
|
|
169
|
+
color: var(--muted);
|
|
170
|
+
}`;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render a full self-contained admin page document. The caller supplies trusted inner HTML
|
|
174
|
+
* (eyebrow, heading, copy, CTA); the helper owns the head, the inlined style, the brand tile,
|
|
175
|
+
* and the footer.
|
|
176
|
+
*/
|
|
177
|
+
export function renderStaticAdminPage(opts: { title: string; innerHtml: string }): string {
|
|
178
|
+
return `<!doctype html>
|
|
179
|
+
<html lang="en">
|
|
180
|
+
<head>
|
|
181
|
+
<meta charset="utf-8" />
|
|
182
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
183
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
184
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
185
|
+
<style>
|
|
186
|
+
${SHARED_STYLE}
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
<main>
|
|
191
|
+
<div class="brand">
|
|
192
|
+
<span class="tile"><svg viewBox="0 0 15 15" fill="currentColor" aria-hidden="true">${CAIRN_GLYPH}</svg></span>
|
|
193
|
+
<span class="word">Cairn</span>
|
|
194
|
+
</div>
|
|
195
|
+
${opts.innerHtml}
|
|
196
|
+
<p class="foot">Powered by Cairn</p>
|
|
197
|
+
</main>
|
|
198
|
+
</body>
|
|
199
|
+
</html>`;
|
|
200
|
+
}
|