@glw907/cairn-cms 0.34.0 → 0.36.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/auth/crypto.d.ts +4 -0
  3. package/dist/auth/crypto.js +10 -0
  4. package/dist/components/AdminLayout.svelte +8 -1
  5. package/dist/components/ConceptList.svelte +3 -0
  6. package/dist/components/ConfirmPage.svelte +4 -2
  7. package/dist/components/ConfirmPage.svelte.d.ts +2 -1
  8. package/dist/components/CsrfField.svelte +20 -0
  9. package/dist/components/CsrfField.svelte.d.ts +12 -0
  10. package/dist/components/DeleteDialog.svelte +2 -0
  11. package/dist/components/EditPage.svelte +2 -0
  12. package/dist/components/LoginPage.svelte +4 -2
  13. package/dist/components/LoginPage.svelte.d.ts +2 -1
  14. package/dist/components/ManageEditors.svelte +4 -0
  15. package/dist/components/NavTree.svelte +2 -0
  16. package/dist/components/RenameDialog.svelte +3 -0
  17. package/dist/components/csrf-context.d.ts +2 -0
  18. package/dist/components/csrf-context.js +2 -0
  19. package/dist/components/index.d.ts +1 -0
  20. package/dist/components/index.js +1 -0
  21. package/dist/log/emit.d.ts +14 -0
  22. package/dist/log/emit.js +18 -0
  23. package/dist/log/events.d.ts +1 -0
  24. package/dist/log/events.js +1 -0
  25. package/dist/log/index.d.ts +3 -0
  26. package/dist/log/index.js +1 -0
  27. package/dist/sveltekit/auth-routes.d.ts +2 -0
  28. package/dist/sveltekit/auth-routes.js +22 -5
  29. package/dist/sveltekit/content-routes.d.ts +6 -4
  30. package/dist/sveltekit/content-routes.js +23 -0
  31. package/dist/sveltekit/csrf-required-page.d.ts +2 -0
  32. package/dist/sveltekit/csrf-required-page.js +25 -0
  33. package/dist/sveltekit/csrf.d.ts +18 -0
  34. package/dist/sveltekit/csrf.js +60 -0
  35. package/dist/sveltekit/guard.js +35 -6
  36. package/dist/sveltekit/https-required-page.js +10 -191
  37. package/dist/sveltekit/nav-routes.js +5 -0
  38. package/dist/sveltekit/static-admin-page.d.ts +11 -0
  39. package/dist/sveltekit/static-admin-page.js +195 -0
  40. package/package.json +1 -1
  41. package/src/lib/auth/crypto.ts +13 -0
  42. package/src/lib/components/AdminLayout.svelte +8 -1
  43. package/src/lib/components/ConceptList.svelte +3 -0
  44. package/src/lib/components/ConfirmPage.svelte +4 -2
  45. package/src/lib/components/CsrfField.svelte +20 -0
  46. package/src/lib/components/DeleteDialog.svelte +2 -0
  47. package/src/lib/components/EditPage.svelte +2 -0
  48. package/src/lib/components/LoginPage.svelte +4 -2
  49. package/src/lib/components/ManageEditors.svelte +4 -0
  50. package/src/lib/components/NavTree.svelte +2 -0
  51. package/src/lib/components/RenameDialog.svelte +3 -0
  52. package/src/lib/components/csrf-context.ts +2 -0
  53. package/src/lib/components/index.ts +1 -0
  54. package/src/lib/log/emit.ts +42 -0
  55. package/src/lib/log/events.ts +13 -0
  56. package/src/lib/log/index.ts +3 -0
  57. package/src/lib/sveltekit/auth-routes.ts +25 -7
  58. package/src/lib/sveltekit/content-routes.ts +29 -2
  59. package/src/lib/sveltekit/csrf-required-page.ts +26 -0
  60. package/src/lib/sveltekit/csrf.ts +61 -0
  61. package/src/lib/sveltekit/guard.ts +43 -6
  62. package/src/lib/sveltekit/https-required-page.ts +10 -194
  63. package/src/lib/sveltekit/nav-routes.ts +5 -0
  64. package/src/lib/sveltekit/static-admin-page.ts +200 -0
@@ -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, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -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, and an optional error. */
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">
@@ -8,12 +8,13 @@ the allowlist, so the page never leaks membership (spec §7.1).
8
8
  import './cairn-admin.css';
9
9
  import { onMount } from 'svelte';
10
10
  import CairnLogo from './CairnLogo.svelte';
11
+ import CsrfField from './CsrfField.svelte';
11
12
  import { cairnFaviconHref } from './cairn-favicon.js';
12
13
  import { warnIfChromeWrapped } from './chrome-guard.js';
13
14
 
14
15
  interface Props {
15
- /** The login load's data: the site name and an optional error. */
16
- data: { siteName: string; error: string | null };
16
+ /** The login load's data: the site name, an optional error, and the CSRF token. */
17
+ data: { siteName: string; error: string | null; csrf: string };
17
18
  /** The action result: `sent` is true once a request was accepted. */
18
19
  form: { sent?: boolean } | null;
19
20
  }
@@ -54,6 +55,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
54
55
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
55
56
  {/if}
56
57
  <form method="POST" class="flex flex-col gap-3">
58
+ <CsrfField token={data.csrf} />
57
59
  <label class="flex flex-col gap-1">
58
60
  <span class="text-sm font-medium">Email</span>
59
61
  <input
@@ -6,6 +6,7 @@ last-owner anti-lockout rule itself is enforced server-side (editors-routes). Ac
6
6
  named `?/setRole`, `?/remove`, and `?/add` actions.
7
7
  -->
8
8
  <script lang="ts">
9
+ import CsrfField from './CsrfField.svelte';
9
10
  import type { Editor } from '../auth/types.js';
10
11
 
11
12
  interface Props {
@@ -45,6 +46,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
45
46
  </td>
46
47
  <td class="flex justify-end gap-2">
47
48
  <form method="POST" action="?/setRole">
49
+ <CsrfField />
48
50
  <input type="hidden" name="email" value={editor.email} />
49
51
  <input type="hidden" name="role" value={editor.role === 'owner' ? 'editor' : 'owner'} />
50
52
  <button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf} aria-label={`Toggle role for ${editor.displayName}`}>
@@ -52,6 +54,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
52
54
  </button>
53
55
  </form>
54
56
  <form method="POST" action="?/remove">
57
+ <CsrfField />
55
58
  <input type="hidden" name="email" value={editor.email} />
56
59
  <button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
57
60
  Remove
@@ -65,6 +68,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
65
68
  </div>
66
69
 
67
70
  <form method="POST" action="?/add" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
71
+ <CsrfField />
68
72
  <label class="flex flex-col gap-1">
69
73
  <span class="text-sm font-medium">Name</span>
70
74
  <input class="input" name="name" aria-label="Name" required />
@@ -8,6 +8,7 @@ validates on save.
8
8
  -->
9
9
  <script lang="ts">
10
10
  import { untrack } from 'svelte';
11
+ import CsrfField from './CsrfField.svelte';
11
12
  import { SortableList, sortItems } from '@rodrigodagostino/svelte-sortable-list';
12
13
  import type { SortableList as SortableListNS } from '@rodrigodagostino/svelte-sortable-list';
13
14
  import '@rodrigodagostino/svelte-sortable-list/styles.css';
@@ -98,6 +99,7 @@ validates on save.
98
99
  {/if}
99
100
 
100
101
  <form method="POST" action="?/save">
102
+ <CsrfField />
101
103
  <input type="hidden" name="tree" value={treeJson} />
102
104
 
103
105
  <div class="mb-2">
@@ -6,6 +6,8 @@ dated post keeps its date; only the slug changes. Built on a native <dialog>, fo
6
6
  DeleteDialog a11y conventions.
7
7
  -->
8
8
  <script lang="ts">
9
+ import CsrfField from './CsrfField.svelte';
10
+
9
11
  interface Props {
10
12
  /** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
11
13
  conceptId: string;
@@ -50,6 +52,7 @@ DeleteDialog a11y conventions.
50
52
  <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
51
53
  </div>
52
54
  <form method="POST" action="?/rename" class="flex flex-col gap-3">
55
+ <CsrfField />
53
56
  <input type="hidden" name="concept" value={conceptId} />
54
57
  <input type="hidden" name="id" value={id} />
55
58
  <label class="flex flex-col gap-1">
@@ -0,0 +1,2 @@
1
+ /** The Svelte context key AdminLayout uses to hand a CSRF-token getter to descendant admin forms. */
2
+ export const CSRF_CONTEXT_KEY = 'cairn:csrf';
@@ -3,6 +3,7 @@
3
3
  export { default as AdminLayout } from './AdminLayout.svelte';
4
4
  export { default as LoginPage } from './LoginPage.svelte';
5
5
  export { default as ConfirmPage } from './ConfirmPage.svelte';
6
+ export { default as CsrfField } from './CsrfField.svelte';
6
7
  export { default as ConceptList } from './ConceptList.svelte';
7
8
  export { default as EditPage } from './EditPage.svelte';
8
9
  export { default as ManageEditors } from './ManageEditors.svelte';
@@ -0,0 +1,42 @@
1
+ // The engine's one logger and the single console chokepoint. Every diagnostic routes through
2
+ // `log`; today each call writes a structured JSON object to console, which Workers Logs ingests
3
+ // and indexes when a consumer sets observability.enabled. A future admin-extension pass adds a
4
+ // subscriber fan-out inside this module, leaving every call site unchanged.
5
+ import type { CairnLogEvent } from './events.js';
6
+
7
+ export type LogLevel = 'info' | 'warn' | 'error';
8
+
9
+ export interface LogRecord {
10
+ level: LogLevel;
11
+ event: CairnLogEvent;
12
+ timestamp: string;
13
+ [field: string]: unknown;
14
+ }
15
+
16
+ export interface Logger {
17
+ info(event: CairnLogEvent, fields?: Record<string, unknown>): void;
18
+ warn(event: CairnLogEvent, fields?: Record<string, unknown>): void;
19
+ error(event: CairnLogEvent, fields?: Record<string, unknown>): void;
20
+ }
21
+
22
+ const sinkByLevel: Record<LogLevel, (record: LogRecord) => void> = {
23
+ info: (record) => console.log(record),
24
+ warn: (record) => console.warn(record),
25
+ error: (record) => console.error(record),
26
+ };
27
+
28
+ function buildRecord(level: LogLevel, event: CairnLogEvent, fields: Record<string, unknown>): LogRecord {
29
+ // The envelope keys are written last, so a stray field named level/event/timestamp cannot
30
+ // corrupt the record shape a subscriber relies on.
31
+ return { ...fields, level, event, timestamp: new Date().toISOString() };
32
+ }
33
+
34
+ function emit(level: LogLevel, event: CairnLogEvent, fields: Record<string, unknown> = {}): void {
35
+ sinkByLevel[level](buildRecord(level, event, fields));
36
+ }
37
+
38
+ export const log: Logger = {
39
+ info: (event, fields) => emit('info', event, fields),
40
+ warn: (event, fields) => emit('warn', event, fields),
41
+ error: (event, fields) => emit('error', event, fields),
42
+ };
@@ -0,0 +1,13 @@
1
+ // The cairn engine's diagnostic event vocabulary. Each name is the stable `type` a future
2
+ // admin-extension subscriber switches on, so it is public-observable API: renaming one is a
3
+ // breaking change. See docs/reference/log-events.md, kept in step with this union.
4
+ export type CairnLogEvent =
5
+ | 'auth.link.requested'
6
+ | 'auth.link.send_failed'
7
+ | 'auth.token.minted'
8
+ | 'auth.token.confirmed'
9
+ | 'auth.session.created'
10
+ | 'auth.session.destroyed'
11
+ | 'commit.succeeded'
12
+ | 'commit.failed'
13
+ | 'guard.rejected';
@@ -0,0 +1,3 @@
1
+ export { log } from './emit.js';
2
+ export type { Logger, LogLevel, LogRecord } from './emit.js';
3
+ export type { CairnLogEvent } from './events.js';
@@ -14,6 +14,8 @@ 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';
18
+ import { log } from '../log/index.js';
17
19
  import type { RequestContext } from './types.js';
18
20
 
19
21
  export interface AuthRoutesConfig {
@@ -35,6 +37,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
35
37
  const db = requireDb(env);
36
38
  const form = await event.request.formData();
37
39
  const email = String(form.get('email') ?? '').trim().toLowerCase();
40
+ // `email` here is unvalidated request input logged before the allowlist check, so bound the
41
+ // logged value to the RFC 5321 maximum to cap an abusive record's size. A real editor's address
42
+ // fits well under this; only a junk payload is truncated.
43
+ log.info('auth.link.requested', { email: email.slice(0, 320) });
38
44
 
39
45
  const editor = email ? await findEditor(db, email) : null;
40
46
  if (editor) {
@@ -45,13 +51,14 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
45
51
  if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
46
52
  const token = generateToken();
47
53
  await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
54
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
48
55
  const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
49
56
  // The token row is the security-critical write the email depends on, so it is awaited. The
50
57
  // send is a post-response side effect, handed to waitUntil so a slow email provider does not
51
58
  // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
52
59
  // failure is logged so observability survives a backgrounded send.
53
60
  const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
54
- (err) => console.error('cairn: magic-link send failed', err),
61
+ (err) => log.error('auth.link.send_failed', { email, error: String(err) }),
55
62
  );
56
63
  // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
57
64
  // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
@@ -63,23 +70,29 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
63
70
  return { sent: true };
64
71
  }
65
72
 
66
- /** GET /admin/login. Public. Carries the site name and an optional `?error` for the form. */
67
- function loginLoad(event: RequestContext): { siteName: string; error: string | null } {
68
- return { siteName: config.branding.siteName, error: event.url.searchParams.get('error') };
73
+ /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
74
+ function loginLoad(event: RequestContext): { siteName: string; error: string | null; csrf: string } {
75
+ return {
76
+ siteName: config.branding.siteName,
77
+ error: event.url.searchParams.get('error'),
78
+ csrf: issueCsrfToken(event),
79
+ };
69
80
  }
70
81
 
71
82
  /**
72
83
  * 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.
84
+ * verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer, and
85
+ * issues the CSRF token so the confirm form can render the hidden field.
74
86
  */
75
87
  function confirmLoad(
76
88
  event: RequestContext,
77
- ): { token: string; siteName: string; error: string | null } {
89
+ ): { token: string; siteName: string; error: string | null; csrf: string } {
78
90
  event.setHeaders({ 'Referrer-Policy': 'no-referrer' });
79
91
  return {
80
92
  token: event.url.searchParams.get('token') ?? '',
81
93
  siteName: config.branding.siteName,
82
94
  error: event.url.searchParams.get('error'),
95
+ csrf: issueCsrfToken(event),
83
96
  };
84
97
  }
85
98
 
@@ -97,9 +110,11 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
97
110
  const now = Date.now();
98
111
  const email = await consumeToken(db, await hashToken(token), now);
99
112
  if (!email) throw redirect(303, '/admin/login?error=expired');
113
+ log.info('auth.token.confirmed', { email });
100
114
 
101
115
  const id = generateSessionId();
102
116
  await createSession(db, id, email, now + SESSION_TTL_MS, now);
117
+ log.info('auth.session.created', { email });
103
118
  const secure = event.url.protocol === 'https:';
104
119
  event.cookies.set(sessionCookieName(secure), id, {
105
120
  path: '/',
@@ -117,7 +132,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
117
132
  const db = requireDb(event.platform?.env ?? {});
118
133
  const name = sessionCookieName(event.url.protocol === 'https:');
119
134
  const id = event.cookies.get(name);
120
- if (id) await deleteSession(db, id);
135
+ if (id) {
136
+ await deleteSession(db, id);
137
+ log.info('auth.session.destroyed');
138
+ }
121
139
  event.cookies.delete(name, { path: '/' });
122
140
  throw redirect(303, '/admin/login');
123
141
  }