@glw907/cairn-cms 0.34.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 +19 -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 +4 -2
- 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 +30 -6
- package/dist/sveltekit/https-required-page.js +10 -191
- package/dist/sveltekit/static-admin-page.d.ts +11 -0
- package/dist/sveltekit/static-admin-page.js +195 -0
- package/package.json +1 -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 +4 -2
- 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 +37 -6
- package/src/lib/sveltekit/https-required-page.ts +10 -194
- package/src/lib/sveltekit/static-admin-page.ts +200 -0
package/dist/sveltekit/guard.js
CHANGED
|
@@ -5,6 +5,8 @@ import { redirect, error } from '@sveltejs/kit';
|
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
7
|
import { httpsRequiredPage } from './https-required-page.js';
|
|
8
|
+
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
9
|
+
import { csrfRequiredPage } from './csrf-required-page.js';
|
|
8
10
|
/** The login page and the auth endpoints are public; everything else under /admin is gated. */
|
|
9
11
|
function isPublicAdminPath(pathname) {
|
|
10
12
|
return pathname === '/admin/login' || pathname.startsWith('/admin/auth/');
|
|
@@ -37,23 +39,40 @@ function applySecurityHeaders(headers) {
|
|
|
37
39
|
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
38
40
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
39
41
|
}
|
|
42
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
43
|
+
function brandedAdminPage(status, body) {
|
|
44
|
+
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
45
|
+
applySecurityHeaders(headers);
|
|
46
|
+
return new Response(body, { status, headers });
|
|
47
|
+
}
|
|
40
48
|
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
41
49
|
function httpsRequiredResponse(url) {
|
|
42
50
|
const httpsUrl = new URL(url);
|
|
43
51
|
httpsUrl.protocol = 'https:';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
53
|
+
}
|
|
54
|
+
/** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
|
|
55
|
+
function csrfForbidden() {
|
|
56
|
+
return new Response('Cross-site POST form submissions are forbidden', {
|
|
57
|
+
status: 403,
|
|
58
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
47
59
|
});
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
}
|
|
61
|
+
/** The branded 403 for a failed admin double-submit token check. */
|
|
62
|
+
function csrfRequiredResponse() {
|
|
63
|
+
return brandedAdminPage(403, csrfRequiredPage());
|
|
50
64
|
}
|
|
51
65
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
52
66
|
export function createAuthGuard() {
|
|
53
67
|
return async function handle({ event, resolve }) {
|
|
54
68
|
const { pathname } = event.url;
|
|
55
|
-
|
|
69
|
+
// Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
|
|
70
|
+
// they set checkOrigin: false to hand cairn the admin CSRF authority.
|
|
71
|
+
if (!isAdminPath(pathname)) {
|
|
72
|
+
if (isUnsafeFormRequest(event.request) && !originMatches(event))
|
|
73
|
+
return csrfForbidden();
|
|
56
74
|
return resolve(event);
|
|
75
|
+
}
|
|
57
76
|
// A deployed admin request over http never works: the magic-link form POST would fail the
|
|
58
77
|
// framework's CSRF guard with an opaque 403. Serve the help page instead, before resolve()
|
|
59
78
|
// runs that check. This covers the public login/auth paths too, since that is where the form
|
|
@@ -61,6 +80,11 @@ export function createAuthGuard() {
|
|
|
61
80
|
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
62
81
|
return httpsRequiredResponse(event.url);
|
|
63
82
|
}
|
|
83
|
+
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
84
|
+
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
85
|
+
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
86
|
+
return csrfRequiredResponse();
|
|
87
|
+
}
|
|
64
88
|
if (!isPublicAdminPath(pathname)) {
|
|
65
89
|
const env = event.platform?.env ?? {};
|
|
66
90
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
@@ -1,195 +1,17 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
// request: the Warm Stone tokens are inlined for both colour schemes and the type falls back to the
|
|
9
|
-
// system stack (the shipped admin fonts are not reachable from here). The cairn glyph is the same
|
|
10
|
-
// public-domain Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
|
|
11
|
-
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
12
|
-
function escapeHtml(value) {
|
|
13
|
-
return value
|
|
14
|
-
.replace(/&/g, '&')
|
|
15
|
-
.replace(/</g, '<')
|
|
16
|
-
.replace(/>/g, '>')
|
|
17
|
-
.replace(/"/g, '"');
|
|
18
|
-
}
|
|
19
|
-
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
20
|
-
const CAIRN_GLYPH = '<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 ' +
|
|
21
|
-
'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 ' +
|
|
22
|
-
'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 ' +
|
|
23
|
-
'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 ' +
|
|
24
|
-
'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 ' +
|
|
25
|
-
'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 ' +
|
|
26
|
-
'9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"/>';
|
|
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';
|
|
27
8
|
/**
|
|
28
9
|
* Render the full HTML document for the HTTPS-required page.
|
|
29
10
|
* @param httpsUrl The same request rebuilt over https, offered as the one-click recovery link.
|
|
30
11
|
*/
|
|
31
12
|
export function httpsRequiredPage(httpsUrl) {
|
|
32
13
|
const href = escapeHtml(httpsUrl);
|
|
33
|
-
|
|
34
|
-
<html lang="en">
|
|
35
|
-
<head>
|
|
36
|
-
<meta charset="utf-8" />
|
|
37
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
38
|
-
<meta name="robots" content="noindex, nofollow" />
|
|
39
|
-
<title>HTTPS required · Cairn</title>
|
|
40
|
-
<style>
|
|
41
|
-
:root {
|
|
42
|
-
color-scheme: light;
|
|
43
|
-
--bg: oklch(96.5% 0.006 75);
|
|
44
|
-
--glow: oklch(52% 0.2 293 / 0.06);
|
|
45
|
-
--panel: oklch(99% 0.004 75);
|
|
46
|
-
--recessed: oklch(95% 0.008 75);
|
|
47
|
-
--ink: oklch(26% 0.014 75);
|
|
48
|
-
--muted: oklch(48% 0.01 75);
|
|
49
|
-
--subtle: oklch(42% 0.01 75);
|
|
50
|
-
--primary: oklch(52% 0.2 293);
|
|
51
|
-
--primary-content: oklch(98% 0.012 293);
|
|
52
|
-
--border: oklch(93% 0.008 75);
|
|
53
|
-
--shadow: 0 1px 2px oklch(28% 0.02 75 / 0.05), 0 18px 40px -12px oklch(28% 0.02 75 / 0.16);
|
|
54
|
-
--radius-box: 1rem;
|
|
55
|
-
--radius-field: 0.625rem;
|
|
56
|
-
--font: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
57
|
-
}
|
|
58
|
-
@media (prefers-color-scheme: dark) {
|
|
59
|
-
:root {
|
|
60
|
-
color-scheme: dark;
|
|
61
|
-
--bg: oklch(15.5% 0.009 75);
|
|
62
|
-
--glow: oklch(68% 0.18 293 / 0.1);
|
|
63
|
-
--panel: oklch(24% 0.01 75);
|
|
64
|
-
--recessed: oklch(20% 0.01 75);
|
|
65
|
-
--ink: oklch(93% 0.006 75);
|
|
66
|
-
--muted: oklch(72% 0.01 75);
|
|
67
|
-
--subtle: oklch(80% 0.008 75);
|
|
68
|
-
--primary: oklch(68% 0.18 293);
|
|
69
|
-
--primary-content: oklch(20% 0.04 293);
|
|
70
|
-
--border: oklch(30% 0.014 75);
|
|
71
|
-
--shadow: 0 1px 2px oklch(0% 0 0 / 0.35), 0 18px 40px -12px oklch(0% 0 0 / 0.55);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
* { box-sizing: border-box; }
|
|
75
|
-
body {
|
|
76
|
-
margin: 0;
|
|
77
|
-
min-height: 100vh;
|
|
78
|
-
display: flex;
|
|
79
|
-
align-items: center;
|
|
80
|
-
justify-content: center;
|
|
81
|
-
padding: 1.5rem;
|
|
82
|
-
font-family: var(--font);
|
|
83
|
-
color: var(--ink);
|
|
84
|
-
background-color: var(--bg);
|
|
85
|
-
background-image: radial-gradient(80rem 50rem at 50% -20%, var(--glow), transparent 60%);
|
|
86
|
-
-webkit-font-smoothing: antialiased;
|
|
87
|
-
-moz-osx-font-smoothing: grayscale;
|
|
88
|
-
line-height: 1.55;
|
|
89
|
-
}
|
|
90
|
-
main {
|
|
91
|
-
width: 100%;
|
|
92
|
-
max-width: 30rem;
|
|
93
|
-
background: var(--panel);
|
|
94
|
-
border: 1px solid var(--border);
|
|
95
|
-
border-radius: var(--radius-box);
|
|
96
|
-
box-shadow: var(--shadow);
|
|
97
|
-
padding: 2.25rem;
|
|
98
|
-
}
|
|
99
|
-
.brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; }
|
|
100
|
-
.brand .tile {
|
|
101
|
-
display: grid;
|
|
102
|
-
place-items: center;
|
|
103
|
-
width: 2rem;
|
|
104
|
-
height: 2rem;
|
|
105
|
-
border-radius: 0.75rem;
|
|
106
|
-
background: var(--primary);
|
|
107
|
-
color: var(--primary-content);
|
|
108
|
-
box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12);
|
|
109
|
-
}
|
|
110
|
-
.brand .tile svg { width: 1.25rem; height: 1.25rem; }
|
|
111
|
-
.brand .word {
|
|
112
|
-
font-weight: 700;
|
|
113
|
-
font-size: 1.25rem;
|
|
114
|
-
letter-spacing: -0.01em;
|
|
115
|
-
}
|
|
116
|
-
.eyebrow {
|
|
117
|
-
display: inline-flex;
|
|
118
|
-
align-items: center;
|
|
119
|
-
gap: 0.4rem;
|
|
120
|
-
font-size: 0.6875rem;
|
|
121
|
-
font-weight: 600;
|
|
122
|
-
text-transform: uppercase;
|
|
123
|
-
letter-spacing: 0.08em;
|
|
124
|
-
color: var(--muted);
|
|
125
|
-
margin-bottom: 0.6rem;
|
|
126
|
-
}
|
|
127
|
-
.eyebrow svg { width: 0.85rem; height: 0.85rem; }
|
|
128
|
-
h1 {
|
|
129
|
-
margin: 0 0 0.75rem;
|
|
130
|
-
font-size: 1.6rem;
|
|
131
|
-
font-weight: 800;
|
|
132
|
-
letter-spacing: -0.02em;
|
|
133
|
-
line-height: 1.15;
|
|
134
|
-
}
|
|
135
|
-
p { margin: 0 0 1rem; color: var(--subtle); }
|
|
136
|
-
.cta {
|
|
137
|
-
display: inline-flex;
|
|
138
|
-
align-items: center;
|
|
139
|
-
gap: 0.5rem;
|
|
140
|
-
margin: 0.25rem 0 0.5rem;
|
|
141
|
-
padding: 0.7rem 1.15rem;
|
|
142
|
-
border-radius: var(--radius-field);
|
|
143
|
-
background: var(--primary);
|
|
144
|
-
color: var(--primary-content);
|
|
145
|
-
font-weight: 600;
|
|
146
|
-
font-size: 0.95rem;
|
|
147
|
-
text-decoration: none;
|
|
148
|
-
box-shadow: 0 4px 14px -4px oklch(52% 0.2 293 / 0.5);
|
|
149
|
-
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
150
|
-
}
|
|
151
|
-
.cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px -6px oklch(52% 0.2 293 / 0.55); }
|
|
152
|
-
.cta:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
153
|
-
.cta svg { width: 1rem; height: 1rem; }
|
|
154
|
-
.fix {
|
|
155
|
-
margin-top: 1.75rem;
|
|
156
|
-
padding: 1.1rem 1.2rem;
|
|
157
|
-
background: var(--recessed);
|
|
158
|
-
border: 1px solid var(--border);
|
|
159
|
-
border-radius: var(--radius-field);
|
|
160
|
-
}
|
|
161
|
-
.fix h2 {
|
|
162
|
-
margin: 0 0 0.5rem;
|
|
163
|
-
font-size: 0.8125rem;
|
|
164
|
-
font-weight: 700;
|
|
165
|
-
letter-spacing: 0.01em;
|
|
166
|
-
}
|
|
167
|
-
.fix p { margin: 0 0 0.65rem; font-size: 0.875rem; }
|
|
168
|
-
.fix p:last-child { margin-bottom: 0; }
|
|
169
|
-
.path {
|
|
170
|
-
display: block;
|
|
171
|
-
font-size: 0.8125rem;
|
|
172
|
-
font-weight: 600;
|
|
173
|
-
color: var(--ink);
|
|
174
|
-
letter-spacing: 0.01em;
|
|
175
|
-
margin: 0 0 0.65rem;
|
|
176
|
-
}
|
|
177
|
-
.path .arrow { color: var(--muted); padding: 0 0.35rem; font-weight: 400; }
|
|
178
|
-
.foot {
|
|
179
|
-
margin-top: 1.75rem;
|
|
180
|
-
text-align: center;
|
|
181
|
-
font-size: 0.75rem;
|
|
182
|
-
color: var(--muted);
|
|
183
|
-
}
|
|
184
|
-
</style>
|
|
185
|
-
</head>
|
|
186
|
-
<body>
|
|
187
|
-
<main>
|
|
188
|
-
<div class="brand">
|
|
189
|
-
<span class="tile"><svg viewBox="0 0 15 15" fill="currentColor" aria-hidden="true">${CAIRN_GLYPH}</svg></span>
|
|
190
|
-
<span class="word">Cairn</span>
|
|
191
|
-
</div>
|
|
192
|
-
|
|
14
|
+
const inner = `
|
|
193
15
|
<span class="eyebrow">
|
|
194
16
|
<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>
|
|
195
17
|
Secure connection required
|
|
@@ -208,9 +30,6 @@ p { margin: 0 0 1rem; color: var(--subtle); }
|
|
|
208
30
|
<span class="path">SSL/TLS<span class="arrow">›</span>Edge Certificates<span class="arrow">›</span>Always Use HTTPS</span>
|
|
209
31
|
<p>Keep HSTS on too. The browser then stays on https and sign-in works.</p>
|
|
210
32
|
</div>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
</main>
|
|
214
|
-
</body>
|
|
215
|
-
</html>`;
|
|
33
|
+
`;
|
|
34
|
+
return renderStaticAdminPage({ title: 'HTTPS required · Cairn', innerHtml: inner });
|
|
216
35
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
2
|
+
export declare function escapeHtml(value: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Render a full self-contained admin page document. The caller supplies trusted inner HTML
|
|
5
|
+
* (eyebrow, heading, copy, CTA); the helper owns the head, the inlined style, the brand tile,
|
|
6
|
+
* and the footer.
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderStaticAdminPage(opts: {
|
|
9
|
+
title: string;
|
|
10
|
+
innerHtml: string;
|
|
11
|
+
}): string;
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
6
|
+
export function escapeHtml(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"');
|
|
12
|
+
}
|
|
13
|
+
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
14
|
+
const CAIRN_GLYPH = '<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 ' +
|
|
15
|
+
'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 ' +
|
|
16
|
+
'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 ' +
|
|
17
|
+
'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 ' +
|
|
18
|
+
'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 ' +
|
|
19
|
+
'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 ' +
|
|
20
|
+
'9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"/>';
|
|
21
|
+
// The verbatim rule set lifted from the original <style> block: the `:root` light tokens, the
|
|
22
|
+
// `@media (prefers-color-scheme: dark)` block, and every rule through `.foot`. It already covers
|
|
23
|
+
// every class both pages use (brand, eyebrow, cta, fix, path, foot).
|
|
24
|
+
const SHARED_STYLE = `:root {
|
|
25
|
+
color-scheme: light;
|
|
26
|
+
--bg: oklch(96.5% 0.006 75);
|
|
27
|
+
--glow: oklch(52% 0.2 293 / 0.06);
|
|
28
|
+
--panel: oklch(99% 0.004 75);
|
|
29
|
+
--recessed: oklch(95% 0.008 75);
|
|
30
|
+
--ink: oklch(26% 0.014 75);
|
|
31
|
+
--muted: oklch(48% 0.01 75);
|
|
32
|
+
--subtle: oklch(42% 0.01 75);
|
|
33
|
+
--primary: oklch(52% 0.2 293);
|
|
34
|
+
--primary-content: oklch(98% 0.012 293);
|
|
35
|
+
--border: oklch(93% 0.008 75);
|
|
36
|
+
--shadow: 0 1px 2px oklch(28% 0.02 75 / 0.05), 0 18px 40px -12px oklch(28% 0.02 75 / 0.16);
|
|
37
|
+
--radius-box: 1rem;
|
|
38
|
+
--radius-field: 0.625rem;
|
|
39
|
+
--font: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
40
|
+
}
|
|
41
|
+
@media (prefers-color-scheme: dark) {
|
|
42
|
+
:root {
|
|
43
|
+
color-scheme: dark;
|
|
44
|
+
--bg: oklch(15.5% 0.009 75);
|
|
45
|
+
--glow: oklch(68% 0.18 293 / 0.1);
|
|
46
|
+
--panel: oklch(24% 0.01 75);
|
|
47
|
+
--recessed: oklch(20% 0.01 75);
|
|
48
|
+
--ink: oklch(93% 0.006 75);
|
|
49
|
+
--muted: oklch(72% 0.01 75);
|
|
50
|
+
--subtle: oklch(80% 0.008 75);
|
|
51
|
+
--primary: oklch(68% 0.18 293);
|
|
52
|
+
--primary-content: oklch(20% 0.04 293);
|
|
53
|
+
--border: oklch(30% 0.014 75);
|
|
54
|
+
--shadow: 0 1px 2px oklch(0% 0 0 / 0.35), 0 18px 40px -12px oklch(0% 0 0 / 0.55);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
* { box-sizing: border-box; }
|
|
58
|
+
body {
|
|
59
|
+
margin: 0;
|
|
60
|
+
min-height: 100vh;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
padding: 1.5rem;
|
|
65
|
+
font-family: var(--font);
|
|
66
|
+
color: var(--ink);
|
|
67
|
+
background-color: var(--bg);
|
|
68
|
+
background-image: radial-gradient(80rem 50rem at 50% -20%, var(--glow), transparent 60%);
|
|
69
|
+
-webkit-font-smoothing: antialiased;
|
|
70
|
+
-moz-osx-font-smoothing: grayscale;
|
|
71
|
+
line-height: 1.55;
|
|
72
|
+
}
|
|
73
|
+
main {
|
|
74
|
+
width: 100%;
|
|
75
|
+
max-width: 30rem;
|
|
76
|
+
background: var(--panel);
|
|
77
|
+
border: 1px solid var(--border);
|
|
78
|
+
border-radius: var(--radius-box);
|
|
79
|
+
box-shadow: var(--shadow);
|
|
80
|
+
padding: 2.25rem;
|
|
81
|
+
}
|
|
82
|
+
.brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; }
|
|
83
|
+
.brand .tile {
|
|
84
|
+
display: grid;
|
|
85
|
+
place-items: center;
|
|
86
|
+
width: 2rem;
|
|
87
|
+
height: 2rem;
|
|
88
|
+
border-radius: 0.75rem;
|
|
89
|
+
background: var(--primary);
|
|
90
|
+
color: var(--primary-content);
|
|
91
|
+
box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12);
|
|
92
|
+
}
|
|
93
|
+
.brand .tile svg { width: 1.25rem; height: 1.25rem; }
|
|
94
|
+
.brand .word {
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
font-size: 1.25rem;
|
|
97
|
+
letter-spacing: -0.01em;
|
|
98
|
+
}
|
|
99
|
+
.eyebrow {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: 0.4rem;
|
|
103
|
+
font-size: 0.6875rem;
|
|
104
|
+
font-weight: 600;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: 0.08em;
|
|
107
|
+
color: var(--muted);
|
|
108
|
+
margin-bottom: 0.6rem;
|
|
109
|
+
}
|
|
110
|
+
.eyebrow svg { width: 0.85rem; height: 0.85rem; }
|
|
111
|
+
h1 {
|
|
112
|
+
margin: 0 0 0.75rem;
|
|
113
|
+
font-size: 1.6rem;
|
|
114
|
+
font-weight: 800;
|
|
115
|
+
letter-spacing: -0.02em;
|
|
116
|
+
line-height: 1.15;
|
|
117
|
+
}
|
|
118
|
+
p { margin: 0 0 1rem; color: var(--subtle); }
|
|
119
|
+
.cta {
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: 0.5rem;
|
|
123
|
+
margin: 0.25rem 0 0.5rem;
|
|
124
|
+
padding: 0.7rem 1.15rem;
|
|
125
|
+
border-radius: var(--radius-field);
|
|
126
|
+
background: var(--primary);
|
|
127
|
+
color: var(--primary-content);
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
font-size: 0.95rem;
|
|
130
|
+
text-decoration: none;
|
|
131
|
+
box-shadow: 0 4px 14px -4px oklch(52% 0.2 293 / 0.5);
|
|
132
|
+
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
133
|
+
}
|
|
134
|
+
.cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px -6px oklch(52% 0.2 293 / 0.55); }
|
|
135
|
+
.cta:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
136
|
+
.cta svg { width: 1rem; height: 1rem; }
|
|
137
|
+
.fix {
|
|
138
|
+
margin-top: 1.75rem;
|
|
139
|
+
padding: 1.1rem 1.2rem;
|
|
140
|
+
background: var(--recessed);
|
|
141
|
+
border: 1px solid var(--border);
|
|
142
|
+
border-radius: var(--radius-field);
|
|
143
|
+
}
|
|
144
|
+
.fix h2 {
|
|
145
|
+
margin: 0 0 0.5rem;
|
|
146
|
+
font-size: 0.8125rem;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
letter-spacing: 0.01em;
|
|
149
|
+
}
|
|
150
|
+
.fix p { margin: 0 0 0.65rem; font-size: 0.875rem; }
|
|
151
|
+
.fix p:last-child { margin-bottom: 0; }
|
|
152
|
+
.path {
|
|
153
|
+
display: block;
|
|
154
|
+
font-size: 0.8125rem;
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
color: var(--ink);
|
|
157
|
+
letter-spacing: 0.01em;
|
|
158
|
+
margin: 0 0 0.65rem;
|
|
159
|
+
}
|
|
160
|
+
.path .arrow { color: var(--muted); padding: 0 0.35rem; font-weight: 400; }
|
|
161
|
+
.foot {
|
|
162
|
+
margin-top: 1.75rem;
|
|
163
|
+
text-align: center;
|
|
164
|
+
font-size: 0.75rem;
|
|
165
|
+
color: var(--muted);
|
|
166
|
+
}`;
|
|
167
|
+
/**
|
|
168
|
+
* Render a full self-contained admin page document. The caller supplies trusted inner HTML
|
|
169
|
+
* (eyebrow, heading, copy, CTA); the helper owns the head, the inlined style, the brand tile,
|
|
170
|
+
* and the footer.
|
|
171
|
+
*/
|
|
172
|
+
export function renderStaticAdminPage(opts) {
|
|
173
|
+
return `<!doctype html>
|
|
174
|
+
<html lang="en">
|
|
175
|
+
<head>
|
|
176
|
+
<meta charset="utf-8" />
|
|
177
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
178
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
179
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
180
|
+
<style>
|
|
181
|
+
${SHARED_STYLE}
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<main>
|
|
186
|
+
<div class="brand">
|
|
187
|
+
<span class="tile"><svg viewBox="0 0 15 15" fill="currentColor" aria-hidden="true">${CAIRN_GLYPH}</svg></span>
|
|
188
|
+
<span class="word">Cairn</span>
|
|
189
|
+
</div>
|
|
190
|
+
${opts.innerHtml}
|
|
191
|
+
<p class="foot">Powered by Cairn</p>
|
|
192
|
+
</main>
|
|
193
|
+
</body>
|
|
194
|
+
</html>`;
|
|
195
|
+
}
|
package/package.json
CHANGED
package/src/lib/auth/crypto.ts
CHANGED
|
@@ -14,6 +14,14 @@ export function sessionCookieName(secure: boolean): string {
|
|
|
14
14
|
return secure ? `__Host-${COOKIE_BASE}` : COOKIE_BASE;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The CSRF double-submit cookie base name, __Host- prefixed when the cookie is Secure. */
|
|
18
|
+
const CSRF_COOKIE_BASE = 'cairn_csrf';
|
|
19
|
+
|
|
20
|
+
/** The CSRF cookie name, mirroring sessionCookieName: __Host- on https, bare on local http. */
|
|
21
|
+
export function csrfCookieName(secure: boolean): string {
|
|
22
|
+
return secure ? `__Host-${CSRF_COOKIE_BASE}` : CSRF_COOKIE_BASE;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/** Magic-link tokens live 10 minutes. */
|
|
18
26
|
export const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
19
27
|
|
|
@@ -41,6 +49,11 @@ export function generateSessionId(): string {
|
|
|
41
49
|
return randomBase64Url(32);
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/** A fresh 256-bit double-submit token, url-safe. */
|
|
53
|
+
export function generateCsrfToken(): string {
|
|
54
|
+
return randomBase64Url(32);
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
/** The lowercase hex SHA-256 of a token, for storage and lookup. */
|
|
45
58
|
export async function hashToken(token: string): Promise<string> {
|
|
46
59
|
const data = new TextEncoder().encode(token);
|
|
@@ -7,8 +7,10 @@ flipped by the topbar toggle) and imports the self-contained Warm Stone theme, s
|
|
|
7
7
|
identical on every host regardless of the site's own theme.
|
|
8
8
|
-->
|
|
9
9
|
<script lang="ts">
|
|
10
|
-
import { onMount, untrack, type Component, type Snippet } from 'svelte';
|
|
10
|
+
import { onMount, setContext, untrack, type Component, type Snippet } from 'svelte';
|
|
11
11
|
import type { LayoutData } from '../sveltekit/content-routes.js';
|
|
12
|
+
import CsrfField from './CsrfField.svelte';
|
|
13
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
12
14
|
import { MenuIcon, LogOutIcon, SunIcon, MoonIcon, ChevronRightIcon, SearchIcon } from './admin-icons.js';
|
|
13
15
|
import CairnLogo from './CairnLogo.svelte';
|
|
14
16
|
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
@@ -30,6 +32,10 @@ identical on every host regardless of the site's own theme.
|
|
|
30
32
|
|
|
31
33
|
let { data, children }: Props = $props();
|
|
32
34
|
|
|
35
|
+
// Hand descendant forms a live getter for the CSRF token layoutLoad issued, so the field stays
|
|
36
|
+
// correct even if the token ever rotates mid-session.
|
|
37
|
+
setContext(CSRF_CONTEXT_KEY, () => data.csrf);
|
|
38
|
+
|
|
33
39
|
// Persist an admin preference for a year, path-scoped to /admin so the cookie never reaches the
|
|
34
40
|
// host's own pages.
|
|
35
41
|
function writeAdminCookie(name: string, value: string) {
|
|
@@ -397,6 +403,7 @@ identical on every host regardless of the site's own theme.
|
|
|
397
403
|
</div>
|
|
398
404
|
</div>
|
|
399
405
|
<form method="POST" action="/admin/auth/logout" class="mt-4">
|
|
406
|
+
<CsrfField token={data.csrf} />
|
|
400
407
|
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
|
|
401
408
|
<LogOutIcon class="h-4 w-4" /> Sign out
|
|
402
409
|
</button>
|
|
@@ -9,6 +9,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
9
9
|
import { slugify } from '../content/ids.js';
|
|
10
10
|
import type { EntrySummary, ListData } from '../sveltekit/content-routes.js';
|
|
11
11
|
import type { InboundLink } from '../content/manifest.js';
|
|
12
|
+
import CsrfField from './CsrfField.svelte';
|
|
12
13
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
13
14
|
import CairnLogo from './CairnLogo.svelte';
|
|
14
15
|
import { SearchIcon, ArrowUpIcon, ArrowDownIcon, ChevronsUpDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon, Trash2Icon } from './admin-icons.js';
|
|
@@ -202,6 +203,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
202
203
|
<DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} />
|
|
203
204
|
{:else}
|
|
204
205
|
<form method="POST" action="?/delete">
|
|
206
|
+
<CsrfField />
|
|
205
207
|
<input type="hidden" name="id" value={entry.id} />
|
|
206
208
|
<button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
|
|
207
209
|
<Trash2Icon class="h-4 w-4 text-error" />
|
|
@@ -246,6 +248,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
246
248
|
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => createDialog?.close()}>✕</button>
|
|
247
249
|
</div>
|
|
248
250
|
<form method="POST" action="?/create" onsubmit={() => (creating = true)} class="flex flex-col gap-3">
|
|
251
|
+
<CsrfField />
|
|
249
252
|
<label class="flex flex-col gap-1">
|
|
250
253
|
<span class="text-sm font-medium">Title</span>
|
|
251
254
|
<input class="input w-full" name="title" bind:value={title} required />
|
|
@@ -6,11 +6,12 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
|
|
|
6
6
|
<script lang="ts">
|
|
7
7
|
import './cairn-admin.css';
|
|
8
8
|
import CairnLogo from './CairnLogo.svelte';
|
|
9
|
+
import CsrfField from './CsrfField.svelte';
|
|
9
10
|
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
|
-
/** The confirm load's data: the token to submit, the site name,
|
|
13
|
-
data: { token: string; siteName: string; error: string | null };
|
|
13
|
+
/** The confirm load's data: the token to submit, the site name, an optional error, the CSRF token. */
|
|
14
|
+
data: { token: string; siteName: string; error: string | null; csrf: string };
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
let { data }: Props = $props();
|
|
@@ -41,6 +42,7 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
|
|
|
41
42
|
<p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Confirm to finish signing in to {data.siteName}.</p>
|
|
42
43
|
<form method="POST">
|
|
43
44
|
<input type="hidden" name="token" value={data.token} />
|
|
45
|
+
<CsrfField token={data.csrf} />
|
|
44
46
|
<button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
|
|
45
47
|
</form>
|
|
46
48
|
{/if}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A hidden CSRF double-submit field for an admin form. Pass `token` directly (the pre-auth pages do),
|
|
4
|
+
or omit it inside the authed shell, where AdminLayout provides the token through context. A form that
|
|
5
|
+
omits this field fails the guard's token check, which is the intended fail-closed signal.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { getContext } from 'svelte';
|
|
9
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** The CSRF token. Falls back to the admin context when omitted. */
|
|
13
|
+
token?: string;
|
|
14
|
+
}
|
|
15
|
+
let { token }: Props = $props();
|
|
16
|
+
const fromContext = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
17
|
+
const value = $derived(token ?? fromContext?.() ?? '');
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<input type="hidden" name="csrf" value={value} />
|
|
@@ -6,6 +6,7 @@ each linking to its edit page, so the author repoints or removes those links fir
|
|
|
6
6
|
<dialog>, following the LinkPicker a11y conventions.
|
|
7
7
|
-->
|
|
8
8
|
<script lang="ts">
|
|
9
|
+
import CsrfField from './CsrfField.svelte';
|
|
9
10
|
import type { InboundLink } from '../content/manifest.js';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -68,6 +69,7 @@ each linking to its edit page, so the author repoints or removes those links fir
|
|
|
68
69
|
{:else}
|
|
69
70
|
<p class="mb-3 text-sm">This cannot be undone.</p>
|
|
70
71
|
<form method="POST" action="?/delete" class="flex justify-end gap-2">
|
|
72
|
+
<CsrfField />
|
|
71
73
|
<input type="hidden" name="concept" value={conceptId} />
|
|
72
74
|
<input type="hidden" name="id" value={id} />
|
|
73
75
|
<button type="button" class="btn btn-sm" onclick={close}>Cancel</button>
|
|
@@ -6,6 +6,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
6
6
|
-->
|
|
7
7
|
<script lang="ts">
|
|
8
8
|
import { untrack } from 'svelte';
|
|
9
|
+
import CsrfField from './CsrfField.svelte';
|
|
9
10
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
10
11
|
import ComponentInsertDialog from './ComponentInsertDialog.svelte';
|
|
11
12
|
import LinkPicker from './LinkPicker.svelte';
|
|
@@ -221,6 +222,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
221
222
|
{/if}
|
|
222
223
|
|
|
223
224
|
<form method="POST" action="?/save" onsubmit={() => (saving = true)} class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
|
|
225
|
+
<CsrfField />
|
|
224
226
|
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
225
227
|
|
|
226
228
|
<div class="lg:order-1">
|