@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +36 -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 +5 -3
  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/sveltekit/auth-routes.d.ts +2 -0
  22. package/dist/sveltekit/auth-routes.js +10 -3
  23. package/dist/sveltekit/content-routes.d.ts +6 -4
  24. package/dist/sveltekit/content-routes.js +2 -0
  25. package/dist/sveltekit/csrf-required-page.d.ts +2 -0
  26. package/dist/sveltekit/csrf-required-page.js +25 -0
  27. package/dist/sveltekit/csrf.d.ts +18 -0
  28. package/dist/sveltekit/csrf.js +60 -0
  29. package/dist/sveltekit/guard.js +57 -1
  30. package/dist/sveltekit/https-required-page.d.ts +5 -0
  31. package/dist/sveltekit/https-required-page.js +35 -0
  32. package/dist/sveltekit/static-admin-page.d.ts +11 -0
  33. package/dist/sveltekit/static-admin-page.js +195 -0
  34. package/package.json +2 -1
  35. package/src/lib/auth/crypto.ts +13 -0
  36. package/src/lib/components/AdminLayout.svelte +8 -1
  37. package/src/lib/components/ConceptList.svelte +3 -0
  38. package/src/lib/components/ConfirmPage.svelte +4 -2
  39. package/src/lib/components/CsrfField.svelte +20 -0
  40. package/src/lib/components/DeleteDialog.svelte +2 -0
  41. package/src/lib/components/EditPage.svelte +2 -0
  42. package/src/lib/components/LoginPage.svelte +5 -3
  43. package/src/lib/components/ManageEditors.svelte +4 -0
  44. package/src/lib/components/NavTree.svelte +2 -0
  45. package/src/lib/components/RenameDialog.svelte +3 -0
  46. package/src/lib/components/csrf-context.ts +2 -0
  47. package/src/lib/components/index.ts +1 -0
  48. package/src/lib/sveltekit/auth-routes.ts +12 -5
  49. package/src/lib/sveltekit/content-routes.ts +8 -2
  50. package/src/lib/sveltekit/csrf-required-page.ts +26 -0
  51. package/src/lib/sveltekit/csrf.ts +61 -0
  52. package/src/lib/sveltekit/guard.ts +68 -1
  53. package/src/lib/sveltekit/https-required-page.ts +36 -0
  54. package/src/lib/sveltekit/static-admin-page.ts +200 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.35.0
6
+
7
+ cairn now owns CSRF for the admin. A consuming site disables SvelteKit's global `checkOrigin`, and
8
+ cairn's guard becomes the single authority. Every unsafe admin form POST must carry a valid
9
+ `__Host-cairn_csrf` double-submit token (the cookie name is `cairn_csrf` bare on local http). The
10
+ token is issued lazily and stably by the login, confirm, and admin shell loads, rendered as a hidden
11
+ `csrf` field by the new `CsrfField` export, and validated centrally in the guard. A failed check
12
+ serves a branded 403 page in place of the framework's raw text. The session cookie stays a second
13
+ layer. The token tolerates a missing `Origin`, so the JS-free magic-link sign-in works from a
14
+ browser that omits the header. The guard restores the strict `Origin === url.origin` check for the
15
+ site's own non-admin form POSTs, so handing cairn the admin authority is not a net loss elsewhere.
16
+
17
+ The `CsrfField` component is a new export from `@glw907/cairn-cms/components`. The `LoginPage` and
18
+ `ConfirmPage` data now carries `csrf`, and `AdminLayout`'s `LayoutData` now carries `csrf`, which the
19
+ shell provides to its descendant forms through context.
20
+
21
+ Consumers must: set `csrf: { checkOrigin: false }` in `kit` in `svelte.config.js`. Without it the
22
+ framework's global check rejects the JS-free auth POST and the admin sign-in fails.
23
+
24
+ ## 0.34.0
25
+
26
+ A deployed admin request that arrives over http now gets a clear, branded help page instead of the
27
+ framework's opaque CSRF 403. The magic-link sign-in posts a JS-free form, and the framework rejects a
28
+ form POST unless the request carries a matching https origin, so an admin reached over http cannot sign
29
+ in. The auth guard detects that case on a deployed host and serves a self-contained page that names the
30
+ problem, links to the https version for one-click recovery, and gives the exact Cloudflare fix (Always
31
+ Use HTTPS). The page matches the admin design system in light and dark. Local `wrangler dev` over http
32
+ is exempt.
33
+
34
+ The release also adds a `check:prose` gate (`scripts/check-admin-prose.mjs`, in CI) that scans the admin
35
+ components' user-facing strings for AI-writing tells, since the component copy ships compiled and a
36
+ consuming site's prose tooling never sees it.
37
+
38
+ Consumers may: force HTTPS at the edge (Always Use HTTPS plus HSTS), which the deploy guide now requires.
39
+ The help page is a fallback for the window before that is set, not a substitute.
40
+
5
41
  ## 0.33.0
6
42
 
7
43
  The admin isolates itself from host chrome. A dev-only guard in the admin and login roots walks the
@@ -4,6 +4,8 @@
4
4
  * dev the prefix is dropped, since __Host- requires Secure and the dev cookie cannot set it.
5
5
  */
6
6
  export declare function sessionCookieName(secure: boolean): string;
7
+ /** The CSRF cookie name, mirroring sessionCookieName: __Host- on https, bare on local http. */
8
+ export declare function csrfCookieName(secure: boolean): string;
7
9
  /** Magic-link tokens live 10 minutes. */
8
10
  export declare const TOKEN_TTL_MS: number;
9
11
  /** Sessions live 30 days. */
@@ -14,5 +16,7 @@ export declare const SEND_COOLDOWN_MS: number;
14
16
  export declare function generateToken(): string;
15
17
  /** A fresh 256-bit session id, url-safe. */
16
18
  export declare function generateSessionId(): string;
19
+ /** A fresh 256-bit double-submit token, url-safe. */
20
+ export declare function generateCsrfToken(): string;
17
21
  /** The lowercase hex SHA-256 of a token, for storage and lookup. */
18
22
  export declare function hashToken(token: string): Promise<string>;
@@ -11,6 +11,12 @@ const COOKIE_BASE = 'cairn_session';
11
11
  export function sessionCookieName(secure) {
12
12
  return secure ? `__Host-${COOKIE_BASE}` : COOKIE_BASE;
13
13
  }
14
+ /** The CSRF double-submit cookie base name, __Host- prefixed when the cookie is Secure. */
15
+ const CSRF_COOKIE_BASE = 'cairn_csrf';
16
+ /** The CSRF cookie name, mirroring sessionCookieName: __Host- on https, bare on local http. */
17
+ export function csrfCookieName(secure) {
18
+ return secure ? `__Host-${CSRF_COOKIE_BASE}` : CSRF_COOKIE_BASE;
19
+ }
14
20
  /** Magic-link tokens live 10 minutes. */
15
21
  export const TOKEN_TTL_MS = 10 * 60 * 1000;
16
22
  /** Sessions live 30 days. */
@@ -33,6 +39,10 @@ export function generateToken() {
33
39
  export function generateSessionId() {
34
40
  return randomBase64Url(32);
35
41
  }
42
+ /** A fresh 256-bit double-submit token, url-safe. */
43
+ export function generateCsrfToken() {
44
+ return randomBase64Url(32);
45
+ }
36
46
  /** The lowercase hex SHA-256 of a token, for storage and lookup. */
37
47
  export async function hashToken(token) {
38
48
  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}
@@ -1,10 +1,11 @@
1
1
  import './cairn-admin.css';
2
2
  interface Props {
3
- /** The confirm load's data: the token to submit, the site name, and an optional error. */
3
+ /** The confirm load's data: the token to submit, the site name, an optional error, the CSRF token. */
4
4
  data: {
5
5
  token: string;
6
6
  siteName: string;
7
7
  error: string | null;
8
+ csrf: string;
8
9
  };
9
10
  }
10
11
  /**
@@ -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} />
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ /** The CSRF token. Falls back to the admin context when omitted. */
3
+ token?: string;
4
+ }
5
+ /**
6
+ * A hidden CSRF double-submit field for an admin form. Pass `token` directly (the pre-auth pages do),
7
+ * or omit it inside the authed shell, where AdminLayout provides the token through context. A form that
8
+ * omits this field fails the guard's token check, which is the intended fail-closed signal.
9
+ */
10
+ declare const CsrfField: import("svelte").Component<Props, {}, "">;
11
+ type CsrfField = ReturnType<typeof CsrfField>;
12
+ export default CsrfField;
@@ -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
  }
@@ -43,7 +44,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
43
44
  </div>
44
45
 
45
46
  <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
46
- <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email and we'll send you a one-time sign-in link. No password to remember.</p>
47
+ <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
47
48
 
48
49
  {#if form?.sent}
49
50
  <div role="status" class="alert alert-success text-sm">
@@ -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
@@ -1,9 +1,10 @@
1
1
  import './cairn-admin.css';
2
2
  interface Props {
3
- /** The login load's data: the site name and an optional error. */
3
+ /** The login load's data: the site name, an optional error, and the CSRF token. */
4
4
  data: {
5
5
  siteName: string;
6
6
  error: string | null;
7
+ csrf: string;
7
8
  };
8
9
  /** The action result: `sent` is true once a request was accepted. */
9
10
  form: {
@@ -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 declare const CSRF_CONTEXT_KEY = "cairn:csrf";
@@ -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';
@@ -1,6 +1,7 @@
1
1
  export { default as AdminLayout } from './AdminLayout.svelte';
2
2
  export { default as LoginPage } from './LoginPage.svelte';
3
3
  export { default as ConfirmPage } from './ConfirmPage.svelte';
4
+ export { default as CsrfField } from './CsrfField.svelte';
4
5
  export { default as ConceptList } from './ConceptList.svelte';
5
6
  export { default as EditPage } from './EditPage.svelte';
6
7
  export { default as ManageEditors } from './ManageEditors.svelte';
@@ -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';
@@ -8,6 +8,7 @@ export declare function createAuthRoutes(config: AuthRoutesConfig): {
8
8
  loginLoad: (event: RequestContext) => {
9
9
  siteName: string;
10
10
  error: string | null;
11
+ csrf: string;
11
12
  };
12
13
  requestAction: (event: RequestContext) => Promise<{
13
14
  sent: true;
@@ -16,6 +17,7 @@ export declare function createAuthRoutes(config: AuthRoutesConfig): {
16
17
  token: string;
17
18
  siteName: string;
18
19
  error: string | null;
20
+ csrf: string;
19
21
  };
20
22
  confirmAction: (event: RequestContext) => Promise<never>;
21
23
  logoutAction: (event: RequestContext) => Promise<never>;
@@ -6,6 +6,7 @@ import { requireOrigin, requireDb } from '../env.js';
6
6
  import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
7
7
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
8
8
  import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
9
+ import { issueCsrfToken } from './csrf.js';
9
10
  export function createAuthRoutes(config) {
10
11
  const send = config.send ?? cloudflareSend;
11
12
  /**
@@ -45,13 +46,18 @@ export function createAuthRoutes(config) {
45
46
  }
46
47
  return { sent: true };
47
48
  }
48
- /** GET /admin/login. Public. Carries the site name and an optional `?error` for the form. */
49
+ /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
49
50
  function loginLoad(event) {
50
- return { siteName: config.branding.siteName, error: event.url.searchParams.get('error') };
51
+ return {
52
+ siteName: config.branding.siteName,
53
+ error: event.url.searchParams.get('error'),
54
+ csrf: issueCsrfToken(event),
55
+ };
51
56
  }
52
57
  /**
53
58
  * GET /admin/auth/confirm. Renders the confirm page and consumes nothing; only the POST
54
- * verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer.
59
+ * verifies. Sets Referrer-Policy: no-referrer so the token does not leak to a referrer, and
60
+ * issues the CSRF token so the confirm form can render the hidden field.
55
61
  */
56
62
  function confirmLoad(event) {
57
63
  event.setHeaders({ 'Referrer-Policy': 'no-referrer' });
@@ -59,6 +65,7 @@ export function createAuthRoutes(config) {
59
65
  token: event.url.searchParams.get('token') ?? '',
60
66
  siteName: config.branding.siteName,
61
67
  error: event.url.searchParams.get('error'),
68
+ csrf: issueCsrfToken(event),
62
69
  };
63
70
  }
64
71
  /**
@@ -1,6 +1,7 @@
1
1
  import { fail } from '@sveltejs/kit';
2
2
  import { type GithubKeyEnv } from '../github/credentials.js';
3
3
  import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
+ import type { CookieJar } from './types.js';
4
5
  import type { CairnRuntime, FrontmatterField } from '../content/types.js';
5
6
  import type { Editor, Role } from '../auth/types.js';
6
7
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
@@ -26,6 +27,8 @@ export interface LayoutData {
26
27
  /** The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
27
28
  * collapsed group renders collapsed with no flash. Empty when none are collapsed. */
28
29
  collapsedNav: string[];
30
+ /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
31
+ csrf: string;
29
32
  }
30
33
  /** One row in a concept's list view. */
31
34
  export interface EntrySummary {
@@ -78,10 +81,9 @@ export interface ContentEvent {
78
81
  platform?: {
79
82
  env?: GithubKeyEnv;
80
83
  };
81
- /** SvelteKit's cookie jar; the layout load reads the persisted admin theme. Optional for non-route callers. */
82
- cookies?: {
83
- get(name: string): string | undefined;
84
- };
84
+ /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
85
+ * token. Optional for non-route callers. */
86
+ cookies?: CookieJar;
85
87
  }
86
88
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
87
89
  export interface ContentRoutesDeps {
@@ -13,6 +13,7 @@ import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
13
13
  import { cachedInstallationToken } from '../github/signing.js';
14
14
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
15
15
  import { CommitConflictError } from '../github/types.js';
16
+ import { issueCsrfToken } from './csrf.js';
16
17
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
17
18
  function sessionOf(event) {
18
19
  const editor = event.locals.editor;
@@ -47,6 +48,7 @@ export function createContentRoutes(runtime, deps = {}) {
47
48
  navLabel: runtime.navMenu?.label ?? null,
48
49
  theme,
49
50
  collapsedNav,
51
+ csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
50
52
  };
51
53
  }
52
54
  /** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
@@ -0,0 +1,2 @@
1
+ /** Render the full HTML document for the CSRF-failed page. */
2
+ export declare function csrfRequiredPage(): string;
@@ -0,0 +1,25 @@
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
+ /** Render the full HTML document for the CSRF-failed page. */
6
+ export function csrfRequiredPage() {
7
+ const inner = `
8
+ <span class="eyebrow">
9
+ <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>
10
+ Security check
11
+ </span>
12
+ <h1>Let's try that again</h1>
13
+ <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>
14
+
15
+ <a class="cta" href="/admin/login">
16
+ Back to sign-in
17
+ <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>
18
+ </a>
19
+
20
+ <div class="fix">
21
+ <h2>If it keeps happening</h2>
22
+ <p>Allow cookies for this site, then open the sign-in page fresh and request a new link.</p>
23
+ </div>`;
24
+ return renderStaticAdminPage({ title: 'Security check · Cairn', innerHtml: inner });
25
+ }
@@ -0,0 +1,18 @@
1
+ import type { CookieJar, RequestContext } from './types.js';
2
+ /** True for a request SvelteKit's CSRF guard screens: an unsafe method with a form content type. */
3
+ export declare function isUnsafeFormRequest(request: Request): boolean;
4
+ /** The faithful framework check: the Origin header equals the request's own origin. */
5
+ export declare function originMatches(event: Pick<RequestContext, 'url' | 'request'>): boolean;
6
+ /** A length-checked constant-time compare, so the token check leaks no timing. */
7
+ export declare function tokensMatch(a: string, b: string): boolean;
8
+ /**
9
+ * Return the session's CSRF token, minting and setting it when absent. Lazy and stable: a second
10
+ * open admin tab reuses the same value, so its form field still matches the cookie. Session-scoped
11
+ * (no maxAge), HttpOnly (the server sets both halves), SameSite=Strict, and __Host- on https.
12
+ */
13
+ export declare function issueCsrfToken(event: {
14
+ url: URL;
15
+ cookies: CookieJar;
16
+ }): string;
17
+ /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
18
+ export declare function validateCsrfToken(event: RequestContext): Promise<boolean>;
@@ -0,0 +1,60 @@
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
+ const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
6
+ const FORM_CONTENT_TYPES = new Set([
7
+ 'application/x-www-form-urlencoded',
8
+ 'multipart/form-data',
9
+ 'text/plain',
10
+ ]);
11
+ /** True for a request SvelteKit's CSRF guard screens: an unsafe method with a form content type. */
12
+ export function isUnsafeFormRequest(request) {
13
+ if (!UNSAFE_METHODS.has(request.method))
14
+ return false;
15
+ const type = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();
16
+ return FORM_CONTENT_TYPES.has(type);
17
+ }
18
+ /** The faithful framework check: the Origin header equals the request's own origin. */
19
+ export function originMatches(event) {
20
+ return event.request.headers.get('origin') === event.url.origin;
21
+ }
22
+ /** A length-checked constant-time compare, so the token check leaks no timing. */
23
+ export function tokensMatch(a, b) {
24
+ if (a.length === 0 || a.length !== b.length)
25
+ return false;
26
+ let diff = 0;
27
+ for (let i = 0; i < a.length; i++)
28
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
29
+ return diff === 0;
30
+ }
31
+ /**
32
+ * Return the session's CSRF token, minting and setting it when absent. Lazy and stable: a second
33
+ * open admin tab reuses the same value, so its form field still matches the cookie. Session-scoped
34
+ * (no maxAge), HttpOnly (the server sets both halves), SameSite=Strict, and __Host- on https.
35
+ */
36
+ export function issueCsrfToken(event) {
37
+ const secure = event.url.protocol === 'https:';
38
+ const name = csrfCookieName(secure);
39
+ const existing = event.cookies.get(name);
40
+ if (existing)
41
+ return existing;
42
+ const token = generateCsrfToken();
43
+ event.cookies.set(name, token, { path: '/', httpOnly: true, secure, sameSite: 'strict' });
44
+ return token;
45
+ }
46
+ /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
47
+ export async function validateCsrfToken(event) {
48
+ const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
49
+ if (!cookie)
50
+ return false;
51
+ let submitted = '';
52
+ try {
53
+ const form = await event.request.clone().formData();
54
+ submitted = String(form.get('csrf') ?? '');
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ return tokensMatch(submitted, cookie);
60
+ }