@glw907/cairn-cms 0.37.0 → 0.37.1

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 CHANGED
@@ -2,7 +2,25 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
- ## 0.37.0
5
+ ## 0.37.1
6
+
7
+ Internal groundwork and a docs overhaul; nothing in the public surface or runtime behavior
8
+ changes, and no consumer action is needed.
9
+
10
+ The diagnostics foundation lands as an internal module: a condition registry
11
+ (`CairnCondition`), a `CairnError` throw primitive, and a shared condition-response renderer
12
+ that the admin guard's three rejection responses (the two CSRF reasons and the HTTPS check) now
13
+ route through. Those responses are unchanged and regression-pinned, and the module exports from
14
+ no package subpath. This is Pass 1 of the diagnostics initiative, the base the upcoming
15
+ `cairn doctor` and readiness checks build on.
16
+
17
+ Docs are reorganized and rewritten. A new README front door tells the save-flow story, says
18
+ what cairn is not, names the chosen stack, and then opens three doors: the tutorial, the
19
+ showcase, and the docs map. Stray top-level pages joined their Diátaxis arms (the admin route
20
+ contract is `docs/reference/admin-routes.md`, the sanitize floor is
21
+ `docs/explanation/render-safety.md`, key rotation is
22
+ `docs/guides/rotate-the-github-app-key.md`), and every adopter-facing page is rewritten in a
23
+ second-person, example-first voice with its technical content intact.
6
24
 
7
25
  The magic-link sign-in confirmation is now a branded panel in place of the flat success bar. After an
8
26
  editor requests a link, the page shows a mail icon in a soft success tile, a "Check your email"
package/README.md CHANGED
@@ -1,33 +1,62 @@
1
1
  # cairn-cms
2
2
 
3
- An embedded, **magic-link**, GitHub-committing CMS for SvelteKit + Cloudflare sites.
4
- Non-technical authors log in by email (no GitHub account, no password), edit **raw markdown**
5
- in a client-only CodeMirror editor with a live preview, and save. Each save commits to `main`
6
- via a **GitHub App** (committer = `cairn-cms[bot]`, author = the editor) and auto-deploys.
7
-
8
- It is **design-agnostic**. Each consumer site supplies an adapter (the content contract through
9
- `defineAdapter`/`defineFields`, the slug and permalink rules, and its render configuration), so the
10
- same engine drives sites with completely different markdown pipelines. Two run in production today:
11
- [ecnordic.ski](https://ecnordic.ski) (a remark-to-rehype directive pipeline) and
12
- [907.life](https://907.life) (the engine's own `createRenderer`). Content is a fixed set of
13
- first-class concepts (Posts and Pages), not open-ended collections.
3
+ A CMS that lives inside your SvelteKit site and commits to git. Your editors log in with an
4
+ email link (no GitHub account, no password), write raw markdown in a CodeMirror editor with a
5
+ live preview, and hit Save.
6
+
7
+ When they hit Save, cairn doesn't write to a database. It commits the markdown straight to
8
+ the repo's `main` branch. The commit goes through a GitHub App, so the editor never touches
9
+ GitHub; they still show up as the commit author, and `cairn-cms[bot]` does the signing. From
10
+ there your normal Cloudflare deploy takes over, the same as if you'd pushed from a terminal.
11
+ Commit is publish. If someone else changed the file mid-edit, cairn refuses the save instead
12
+ of guessing how to merge.
13
+
14
+ ## How it fits your site
15
+
16
+ cairn is an engine your site imports, not a platform you deploy to. The engine owns the
17
+ machinery that has to be right: the magic-link auth, the commit path, the admin app, the
18
+ render pipeline and its sanitize floor. Your site owns everything a visitor sees: the adapter
19
+ (your content concepts, frontmatter schema, and slug rules through
20
+ `defineAdapter`/`defineFields`), your markdown pipeline, your CSS. Two production sites run
21
+ the same engine today and look nothing alike: [ecnordic.ski](https://ecnordic.ski) renders
22
+ through its own remark directive pipeline, [907.life](https://907.life) through the engine's
23
+ `createRenderer`.
24
+
25
+ ## What cairn is not
26
+
27
+ - Not a hosted platform. There is no cairn server; the admin ships inside your site's Worker
28
+ and your repo is the source of truth.
29
+ - Not a database CMS. Content is markdown files in your repo, so it outlives the tool and
30
+ never needs an export.
31
+ - Not an open-ended collection builder. Content is a fixed set of first-class concepts (Posts
32
+ and Pages today), each with its own behavior, because the engine should have an opinion
33
+ about what a Post is.
34
+
35
+ ## The stack is chosen for you
36
+
37
+ SvelteKit on Cloudflare Workers, D1 for the auth store, GitHub for content. cairn is
38
+ deliberately opinionated: if that stack matches yours, the pieces click together, and if it
39
+ doesn't, cairn is the wrong tool and will not try to meet you halfway.
40
+
41
+ ## Start here
42
+
43
+ 1. **[Build your first cairn site](./docs/tutorial/build-your-first-cairn-site.md)**, the
44
+ tutorial, takes you from an empty directory to a deployed site with a working `/admin`.
45
+ 2. **[`examples/showcase`](./examples/showcase)** is a complete consumer site wired to the
46
+ engine, and the worked reference for every shape in the docs. When a guide says "mount the
47
+ route shims," the showcase shows the mounted result.
48
+ 3. **[The docs](./docs/README.md)** are organized in four arms: the tutorial, task guides,
49
+ one reference page per export, and explanation pages for the architecture and design
50
+ rules.
14
51
 
15
52
  ## Status
16
53
 
17
- cairn-cms runs two production sites today, [ecnordic.ski](https://ecnordic.ski) and
18
- [907.life](https://907.life). It is `0.x` and breaks between minor versions. The author is
19
- still working through the core-feature roadmap, and the project stays closely held until that
20
- core lands. See the [ROADMAP](./ROADMAP.md) for what is planned and the
21
- [CHANGELOG](./CHANGELOG.md) for what changed.
22
-
23
- Editor auth is self-owned: an atomic single-use magic-link token, a POST-confirm flow, opaque
24
- D1-backed session rows, and two-tier `owner`/`editor` roles. There is no better-auth, Drizzle,
25
- or ORM. Pin a caret range and read the CHANGELOG before bumping; every breaking entry carries a
26
- "Consumers must" line.
27
-
28
- A contributor who feels inspired is welcome to open an issue or a discussion to start a
29
- conversation. There is no formal contribution process yet, so this is not an open call for
30
- pull requests.
54
+ cairn-cms runs the two production sites above. It is `0.x` and breaks between minor versions,
55
+ so pin a caret range and read the [CHANGELOG](./CHANGELOG.md) before bumping; every breaking
56
+ entry carries a "Consumers must" line. The author is still working through the core-feature
57
+ [ROADMAP](./ROADMAP.md), and the project stays closely held until that core lands. A
58
+ contributor who feels inspired is welcome to open an issue or a discussion; there is no
59
+ formal contribution process yet, so this is not an open call for pull requests.
31
60
 
32
61
  ## Install
33
62
 
@@ -35,35 +64,27 @@ pull requests.
35
64
  npm install @glw907/cairn-cms
36
65
  ```
37
66
 
38
- Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a `CairnAdapter`
39
- and mounts thin `/admin` route shims around the package subpaths:
67
+ Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a
68
+ `CairnAdapter` and mounts thin `/admin` route shims around the package subpaths:
40
69
 
41
70
  - `@glw907/cairn-cms`: the core engine and adapter contract.
42
71
  - `@glw907/cairn-cms/sveltekit`: the server load and action logic.
43
72
  - `@glw907/cairn-cms/components`: the admin Svelte UI.
44
73
  - `@glw907/cairn-cms/delivery` and `/delivery/data`: the public read model (indexes, feeds,
45
- sitemap, SEO head). The `/delivery/data` barrel is node-safe, with no `@sveltejs/kit` in its graph.
46
- - `@glw907/cairn-cms/vite`: the `cairnManifest()` Vite plugin, paired with the `cairn-manifest` bin,
47
- that builds and verifies the committed content manifest at build time.
48
-
49
- Each site binds a Cloudflare D1 database as `AUTH_DB` (the editor allowlist, sessions, and single-use
50
- magic tokens) and a `[[send_email]]` binding named `EMAIL`. The worked reference for every shape is
51
- `examples/showcase`.
74
+ sitemap, SEO head). The `/delivery/data` barrel is node-safe, with no `@sveltejs/kit` in
75
+ its graph.
76
+ - `@glw907/cairn-cms/vite`: the `cairnManifest()` Vite plugin, paired with the
77
+ `cairn-manifest` bin, that builds and verifies the committed content manifest at build
78
+ time.
52
79
 
53
- ## Documentation
54
-
55
- The [`docs/`](./docs/README.md) tree is organized in four arms: a tutorial that builds a first
56
- site end to end, how-to guides for each setup task, a reference for every package export, and
57
- explanation pages for the architecture and design rules. Start at the
58
- [documentation index](./docs/README.md). The [security policy](./SECURITY.md) covers reporting
59
- and the security posture.
80
+ Each site binds a Cloudflare D1 database as `AUTH_DB` (the editor allowlist, sessions, and
81
+ single-use magic tokens) and a `[[send_email]]` binding named `EMAIL`. The
82
+ [security policy](./SECURITY.md) covers reporting and the security posture.
60
83
 
61
84
  ## How it's developed
62
85
 
63
- This is a standalone repo. Consumer sites install the published package from the npm registry by
64
- version range. The library's own development proves changes against `examples/showcase`, a
65
- self-contained SvelteKit site that consumes the package through the relative `file:../..` path, so a
66
- change is exercised end to end before it publishes.
67
-
68
- The historical rebuild plan and the early architecture writeups live under `docs/internal/`.
69
- They are kept for history and are not current.
86
+ This is a standalone repo. Consumer sites install the published package from the npm registry
87
+ by version range. The library's own development proves changes against `examples/showcase`,
88
+ which consumes the package through the relative `file:../..` path, so a change is exercised
89
+ end to end before it publishes. The historical rebuild plan and the early architecture
90
+ writeups live under `docs/internal/history/` and are not current.
@@ -8,6 +8,7 @@ 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 MailCheckIcon from '@lucide/svelte/icons/mail-check';
11
+ import InfoIcon from '@lucide/svelte/icons/info';
11
12
  import CairnLogo from './CairnLogo.svelte';
12
13
  import CsrfField from './CsrfField.svelte';
13
14
  import { cairnFaviconHref } from './cairn-favicon.js';
@@ -37,46 +38,54 @@ the allowlist, so the page never leaks membership (spec §7.1).
37
38
  <meta name="robots" content="noindex, nofollow" />
38
39
  </svelte:head>
39
40
 
41
+ <!-- The brand mark renders in both states; the parent container sets its alignment (left for the
42
+ form, centered for the confirmation), so one snippet covers both. -->
43
+ {#snippet brand()}
44
+ <div class="flex items-center gap-2">
45
+ <CairnLogo class="h-8 w-8 text-primary" />
46
+ <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
47
+ </div>
48
+ {/snippet}
49
+
40
50
  <!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
41
51
  level in (a class on the theme element itself would not match). -->
42
52
  <div data-theme="cairn-admin" bind:this={rootEl}>
43
53
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
44
54
  <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
45
- <div class="mb-6 flex items-center gap-2">
46
- <CairnLogo class="h-8 w-8 text-primary" />
47
- <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
48
- </div>
49
-
50
- <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
51
-
52
55
  {#if form?.sent && !dismissed}
53
- <div role="status" class="mt-5 flex flex-col items-center text-center">
56
+ <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
57
+ instruction. The fallback help sits in a gentle inset note below. -->
58
+ <div role="status" class="flex flex-col items-center text-center">
59
+ <div class="mb-7">{@render brand()}</div>
54
60
  <div
55
- class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl text-[var(--color-success)]"
56
- style="background-color: color-mix(in oklch, var(--color-success) 16%, transparent);"
61
+ class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
62
+ style="background-color: color-mix(in oklch, var(--color-success) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-success) 22%, transparent);"
57
63
  >
58
64
  <MailCheckIcon class="h-6 w-6" />
59
65
  </div>
60
- <h2 class="text-lg font-semibold">Check your email</h2>
61
- <p class="mt-1 text-sm text-[var(--color-muted)]">
66
+ <h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
67
+ <p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
62
68
  We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
63
69
  </p>
64
- <div class="mt-5 w-full border-t border-[var(--cairn-card-border)] pt-4 text-left">
65
- <p class="text-sm text-[var(--color-muted)]">
66
- No link after a minute or two? Check your spam folder first. If it still hasn't arrived,
67
- double-check the address. It has to match the one your site owner added.
70
+ <div class="mt-6 flex w-full items-start gap-2.5 rounded-[var(--radius-field)] bg-base-content/[0.04] p-3.5 text-left">
71
+ <InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
72
+ <p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
73
+ No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
74
+ address may not match the one your site owner added.
68
75
  </p>
69
- <button
70
- type="button"
71
- class="btn btn-ghost btn-sm mt-3 -ml-2 text-primary"
72
- onclick={() => (dismissed = true)}
73
- >
74
- Use a different email
75
- </button>
76
76
  </div>
77
+ <button
78
+ type="button"
79
+ class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
80
+ onclick={() => (dismissed = true)}
81
+ >
82
+ Use a different email
83
+ </button>
77
84
  </div>
78
85
  {:else}
79
- <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>
86
+ <div class="mb-6 flex justify-center">{@render brand()}</div>
87
+ <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
88
+ <p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
80
89
  {#if data.error}
81
90
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
82
91
  {/if}
@@ -11,6 +11,7 @@
11
11
  --tw-skew-y: initial;
12
12
  --tw-space-y-reverse: 0;
13
13
  --tw-border-style: solid;
14
+ --tw-leading: initial;
14
15
  --tw-font-weight: initial;
15
16
  --tw-tracking: initial;
16
17
  --tw-ordinal: initial;
@@ -79,6 +80,7 @@
79
80
  --font-weight-bold: 700;
80
81
  --tracking-tight: -.025em;
81
82
  --tracking-wide: .025em;
83
+ --leading-relaxed: 1.625;
82
84
  --radius-md: .375rem;
83
85
  --radius-xl: .75rem;
84
86
  --animate-spin: spin 1s linear infinite;
@@ -3357,6 +3359,10 @@
3357
3359
  margin-top: auto;
3358
3360
  }
3359
3361
 
3362
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-px {
3363
+ margin-top: 1px;
3364
+ }
3365
+
3360
3366
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mr-1 {
3361
3367
  margin-right: calc(var(--spacing) * 1);
3362
3368
  }
@@ -3398,8 +3404,8 @@
3398
3404
  margin-bottom: calc(var(--spacing) * 6);
3399
3405
  }
3400
3406
 
3401
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .-ml-2 {
3402
- margin-left: calc(var(--spacing) * -2);
3407
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mb-7 {
3408
+ margin-bottom: calc(var(--spacing) * 7);
3403
3409
  }
3404
3410
 
3405
3411
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ml-1 {
@@ -3806,10 +3812,6 @@
3806
3812
  height: calc(var(--spacing) * 8);
3807
3813
  }
3808
3814
 
3809
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-11 {
3810
- height: calc(var(--spacing) * 11);
3811
- }
3812
-
3813
3815
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-12 {
3814
3816
  height: calc(var(--spacing) * 12);
3815
3817
  }
@@ -3884,10 +3886,6 @@
3884
3886
  width: calc(var(--spacing) * 9);
3885
3887
  }
3886
3888
 
3887
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-11 {
3888
- width: calc(var(--spacing) * 11);
3889
- }
3890
-
3891
3889
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-12 {
3892
3890
  width: calc(var(--spacing) * 12);
3893
3891
  }
@@ -4038,6 +4036,10 @@
4038
4036
  cursor: pointer;
4039
4037
  }
4040
4038
 
4039
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .appearance-none {
4040
+ appearance: none;
4041
+ }
4042
+
4041
4043
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .flex-col {
4042
4044
  flex-direction: column;
4043
4045
  }
@@ -4170,6 +4172,10 @@
4170
4172
  border-radius: .25rem;
4171
4173
  }
4172
4174
 
4175
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-\[var\(--radius-field\)\] {
4176
+ border-radius: var(--radius-field);
4177
+ }
4178
+
4173
4179
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-box {
4174
4180
  border-radius: var(--radius-box);
4175
4181
  border-radius: var(--radius-box);
@@ -4212,6 +4218,11 @@
4212
4218
  border-bottom-width: 1px;
4213
4219
  }
4214
4220
 
4221
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-none {
4222
+ --tw-border-style: none;
4223
+ border-style: none;
4224
+ }
4225
+
4215
4226
  @layer daisyui.l1.l2 {
4216
4227
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .badge-ghost {
4217
4228
  border-color: var(--color-base-200);
@@ -4329,6 +4340,10 @@
4329
4340
  padding: calc(var(--spacing) * 3);
4330
4341
  }
4331
4342
 
4343
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-3\.5 {
4344
+ padding: calc(var(--spacing) * 3.5);
4345
+ }
4346
+
4332
4347
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-4 {
4333
4348
  padding: calc(var(--spacing) * 4);
4334
4349
  }
@@ -4444,10 +4459,6 @@
4444
4459
  padding-top: calc(var(--spacing) * 3);
4445
4460
  }
4446
4461
 
4447
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pt-4 {
4448
- padding-top: calc(var(--spacing) * 4);
4449
- }
4450
-
4451
4462
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pr-3 {
4452
4463
  padding-right: calc(var(--spacing) * 3);
4453
4464
  }
@@ -4521,6 +4532,15 @@
4521
4532
  font-size: .6875rem;
4522
4533
  }
4523
4534
 
4535
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.8125rem\] {
4536
+ font-size: .8125rem;
4537
+ }
4538
+
4539
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .leading-relaxed {
4540
+ --tw-leading: var(--leading-relaxed);
4541
+ line-height: var(--leading-relaxed);
4542
+ }
4543
+
4524
4544
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .font-bold {
4525
4545
  --tw-font-weight: var(--font-weight-bold);
4526
4546
  font-weight: var(--font-weight-bold);
@@ -5364,6 +5384,11 @@
5364
5384
  initial-value: solid;
5365
5385
  }
5366
5386
 
5387
+ @property --tw-leading {
5388
+ syntax: "*";
5389
+ inherits: false
5390
+ }
5391
+
5367
5392
  @property --tw-font-weight {
5368
5393
  syntax: "*";
5369
5394
  inherits: false
@@ -0,0 +1,21 @@
1
+ import type { CairnLogEvent } from '../log/index.js';
2
+ export type ConditionSeverity = 'blocker' | 'warning';
3
+ export interface CairnCondition {
4
+ /** Stable, greppable id, e.g. 'edge.https-not-forced'. */
5
+ id: string;
6
+ severity: ConditionSeverity;
7
+ /** Short human label. */
8
+ title: string;
9
+ /** One or two sentences on why the condition bites. */
10
+ why: string;
11
+ /** The fix, often a command. */
12
+ remediation: string;
13
+ /** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
14
+ docsAnchor?: string;
15
+ /** The log vocabulary event this condition correlates with, if any. */
16
+ logEvent?: CairnLogEvent;
17
+ }
18
+ /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
19
+ export declare function condition(id: string): CairnCondition;
20
+ /** Every registered condition, for the checklist generator and coverage tests. */
21
+ export declare function allConditions(): CairnCondition[];
@@ -0,0 +1,37 @@
1
+ const REGISTRY = {
2
+ 'edge.https-not-forced': {
3
+ id: 'edge.https-not-forced',
4
+ severity: 'blocker',
5
+ title: 'Always Use HTTPS is off',
6
+ why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
7
+ remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
8
+ logEvent: 'guard.rejected',
9
+ },
10
+ 'auth.csrf-token-invalid': {
11
+ id: 'auth.csrf-token-invalid',
12
+ severity: 'blocker',
13
+ title: 'Admin CSRF token check failed',
14
+ why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
15
+ remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
16
+ logEvent: 'guard.rejected',
17
+ },
18
+ 'auth.csrf-origin-mismatch': {
19
+ id: 'auth.csrf-origin-mismatch',
20
+ severity: 'blocker',
21
+ title: 'Non-admin form Origin rejected',
22
+ why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
23
+ remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
24
+ logEvent: 'guard.rejected',
25
+ },
26
+ };
27
+ /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
28
+ export function condition(id) {
29
+ const found = REGISTRY[id];
30
+ if (!found)
31
+ throw new Error(`unknown cairn condition: ${id}`);
32
+ return found;
33
+ }
34
+ /** Every registered condition, for the checklist generator and coverage tests. */
35
+ export function allConditions() {
36
+ return Object.values(REGISTRY);
37
+ }
@@ -0,0 +1,9 @@
1
+ import { type CairnCondition } from './conditions.js';
2
+ export declare class CairnError extends Error {
3
+ readonly conditionId: string;
4
+ readonly condition: CairnCondition;
5
+ constructor(conditionId: string, options?: {
6
+ cause?: unknown;
7
+ message?: string;
8
+ });
9
+ }
@@ -0,0 +1,15 @@
1
+ // CairnError: a thrown failure that names a known condition. A catch site narrows on it, logs from
2
+ // the condition, and renders the condition's message in place of an opaque string. Its first
3
+ // throw-site is Pass 2 (the email send mapping); Pass 1 lands and tests the primitive.
4
+ import { condition } from './conditions.js';
5
+ export class CairnError extends Error {
6
+ conditionId;
7
+ condition;
8
+ constructor(conditionId, options) {
9
+ const resolved = condition(conditionId);
10
+ super(options?.message ?? resolved.title, options?.cause !== undefined ? { cause: options.cause } : undefined);
11
+ this.name = 'CairnError';
12
+ this.conditionId = conditionId;
13
+ this.condition = resolved;
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ export { condition, allConditions } from './conditions.js';
2
+ export type { CairnCondition, ConditionSeverity } from './conditions.js';
3
+ export { CairnError } from './error.js';
@@ -0,0 +1,3 @@
1
+ // Internal barrel for the diagnostics model. Not re-exported from any public package subpath.
2
+ export { condition, allConditions } from './conditions.js';
3
+ export { CairnError } from './error.js';
@@ -1,4 +1,3 @@
1
- // src/lib/github/repo.ts
2
1
  // cairn-cms: repo reads and the commit, over the GitHub REST API. Listing a concept
3
2
  // directory uses the Git Trees API (the contents API silently truncates at 1,000 entries,
4
3
  // spec §7.3); a single-file read uses the contents API. An optional token lifts reads to
@@ -1,4 +1,3 @@
1
- // src/lib/github/types.ts
2
1
  // cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
3
2
  // reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
4
3
  // `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
@@ -47,7 +47,7 @@ export function serializeComponent(def, values) {
47
47
  if (!content)
48
48
  continue;
49
49
  if (lines.length > 1)
50
- lines.push(''); // blank line before this block
50
+ lines.push('');
51
51
  lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
52
52
  }
53
53
  lines.push(fence);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
3
+ * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
4
+ */
5
+ export declare function applySecurityHeaders(headers: Headers): void;
6
+ /** A branded full-document admin page, hardened with the baseline headers and never cached. */
7
+ export declare function brandedAdminPage(status: number, body: string): Response;
@@ -0,0 +1,21 @@
1
+ // Shared response helpers for cairn's admin pages: the baseline security headers and a branded
2
+ // full-document response. Extracted from guard.ts so the guard's resolve path and the condition
3
+ // renderer share one definition.
4
+ /**
5
+ * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
6
+ * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
7
+ */
8
+ export function applySecurityHeaders(headers) {
9
+ headers.set('X-Content-Type-Options', 'nosniff');
10
+ headers.set('X-Frame-Options', 'DENY');
11
+ headers.set('Content-Security-Policy', "frame-ancestors 'none'");
12
+ headers.set('Referrer-Policy', 'no-referrer');
13
+ headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
14
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
15
+ }
16
+ /** A branded full-document admin page, hardened with the baseline headers and never cached. */
17
+ export function brandedAdminPage(status, body) {
18
+ const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
19
+ applySecurityHeaders(headers);
20
+ return new Response(body, { status, headers });
21
+ }
@@ -0,0 +1,11 @@
1
+ /** The guard.rejected reasons, each mapped to its registered condition id. */
2
+ export declare const REASON_CONDITION: {
3
+ readonly https: "edge.https-not-forced";
4
+ readonly csrf: "auth.csrf-token-invalid";
5
+ readonly origin: "auth.csrf-origin-mismatch";
6
+ };
7
+ export type GuardReason = keyof typeof REASON_CONDITION;
8
+ /** Render the Response the guard serves for a rejection, by its condition id. */
9
+ export declare function renderConditionResponse(id: string, ctx?: {
10
+ url?: URL;
11
+ }): Response;
@@ -0,0 +1,34 @@
1
+ // The runtime renderer leg of the diagnostics model: map a condition to the Response the guard
2
+ // serves. Re-homes the three rejection responses guard.ts built inline, keyed by condition id, so
3
+ // the guard's reason, the registered condition, and the served page stay in step.
4
+ import { brandedAdminPage } from './admin-response.js';
5
+ import { httpsRequiredPage } from './https-required-page.js';
6
+ import { csrfRequiredPage } from './csrf-required-page.js';
7
+ import { condition } from '../diagnostics/index.js';
8
+ /** The guard.rejected reasons, each mapped to its registered condition id. */
9
+ export const REASON_CONDITION = {
10
+ https: 'edge.https-not-forced',
11
+ csrf: 'auth.csrf-token-invalid',
12
+ origin: 'auth.csrf-origin-mismatch',
13
+ };
14
+ /** Render the Response the guard serves for a rejection, by its condition id. */
15
+ export function renderConditionResponse(id, ctx = {}) {
16
+ // Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
17
+ condition(id);
18
+ switch (id) {
19
+ case REASON_CONDITION.https: {
20
+ const httpsUrl = new URL(ctx.url);
21
+ httpsUrl.protocol = 'https:';
22
+ return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
23
+ }
24
+ case REASON_CONDITION.csrf:
25
+ return brandedAdminPage(403, csrfRequiredPage());
26
+ case REASON_CONDITION.origin:
27
+ return new Response('Cross-site POST form submissions are forbidden', {
28
+ status: 403,
29
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
30
+ });
31
+ default:
32
+ throw new Error(`no runtime renderer for condition: ${id}`);
33
+ }
34
+ }
@@ -4,9 +4,9 @@
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
- import { httpsRequiredPage } from './https-required-page.js';
8
7
  import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
9
- import { csrfRequiredPage } from './csrf-required-page.js';
8
+ import { applySecurityHeaders } from './admin-response.js';
9
+ import { renderConditionResponse } from './condition-response.js';
10
10
  import { log } from '../log/index.js';
11
11
  /** The login page and the auth endpoints are public; everything else under /admin is gated. */
12
12
  function isPublicAdminPath(pathname) {
@@ -28,41 +28,6 @@ function isLocalHost(hostname) {
28
28
  hostname === '[::1]' ||
29
29
  hostname.endsWith('.localhost'));
30
30
  }
31
- /**
32
- * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
33
- * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
34
- */
35
- function applySecurityHeaders(headers) {
36
- headers.set('X-Content-Type-Options', 'nosniff');
37
- headers.set('X-Frame-Options', 'DENY');
38
- headers.set('Content-Security-Policy', "frame-ancestors 'none'");
39
- headers.set('Referrer-Policy', 'no-referrer');
40
- headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
41
- headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
42
- }
43
- /** A branded full-document admin page, hardened with the baseline headers and never cached. */
44
- function brandedAdminPage(status, body) {
45
- const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
46
- applySecurityHeaders(headers);
47
- return new Response(body, { status, headers });
48
- }
49
- /** The hardened 400 help page for a deployed admin request that arrived over http. */
50
- function httpsRequiredResponse(url) {
51
- const httpsUrl = new URL(url);
52
- httpsUrl.protocol = 'https:';
53
- return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
54
- }
55
- /** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
56
- function csrfForbidden() {
57
- return new Response('Cross-site POST form submissions are forbidden', {
58
- status: 403,
59
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
60
- });
61
- }
62
- /** The branded 403 for a failed admin double-submit token check. */
63
- function csrfRequiredResponse() {
64
- return brandedAdminPage(403, csrfRequiredPage());
65
- }
66
31
  /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
67
32
  export function createAuthGuard() {
68
33
  return async function handle({ event, resolve }) {
@@ -72,7 +37,7 @@ export function createAuthGuard() {
72
37
  if (!isAdminPath(pathname)) {
73
38
  if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
74
39
  log.warn('guard.rejected', { reason: 'origin', path: pathname });
75
- return csrfForbidden();
40
+ return renderConditionResponse('auth.csrf-origin-mismatch');
76
41
  }
77
42
  return resolve(event);
78
43
  }
@@ -82,13 +47,13 @@ export function createAuthGuard() {
82
47
  // posts. Local http (wrangler dev) is exempt.
83
48
  if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
84
49
  log.warn('guard.rejected', { reason: 'https', path: pathname });
85
- return httpsRequiredResponse(event.url);
50
+ return renderConditionResponse('edge.https-not-forced', { url: event.url });
86
51
  }
87
52
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
88
53
  // 403 before resolve() runs. This covers the public login/auth posts too.
89
54
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
90
55
  log.warn('guard.rejected', { reason: 'csrf', path: pathname });
91
- return csrfRequiredResponse();
56
+ return renderConditionResponse('auth.csrf-token-invalid');
92
57
  }
93
58
  if (!isPublicAdminPath(pathname)) {
94
59
  const env = event.platform?.env ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -8,6 +8,7 @@ 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 MailCheckIcon from '@lucide/svelte/icons/mail-check';
11
+ import InfoIcon from '@lucide/svelte/icons/info';
11
12
  import CairnLogo from './CairnLogo.svelte';
12
13
  import CsrfField from './CsrfField.svelte';
13
14
  import { cairnFaviconHref } from './cairn-favicon.js';
@@ -37,46 +38,54 @@ the allowlist, so the page never leaks membership (spec §7.1).
37
38
  <meta name="robots" content="noindex, nofollow" />
38
39
  </svelte:head>
39
40
 
41
+ <!-- The brand mark renders in both states; the parent container sets its alignment (left for the
42
+ form, centered for the confirmation), so one snippet covers both. -->
43
+ {#snippet brand()}
44
+ <div class="flex items-center gap-2">
45
+ <CairnLogo class="h-8 w-8 text-primary" />
46
+ <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
47
+ </div>
48
+ {/snippet}
49
+
40
50
  <!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
41
51
  level in (a class on the theme element itself would not match). -->
42
52
  <div data-theme="cairn-admin" bind:this={rootEl}>
43
53
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
44
54
  <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
45
- <div class="mb-6 flex items-center gap-2">
46
- <CairnLogo class="h-8 w-8 text-primary" />
47
- <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
48
- </div>
49
-
50
- <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
51
-
52
55
  {#if form?.sent && !dismissed}
53
- <div role="status" class="mt-5 flex flex-col items-center text-center">
56
+ <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
57
+ instruction. The fallback help sits in a gentle inset note below. -->
58
+ <div role="status" class="flex flex-col items-center text-center">
59
+ <div class="mb-7">{@render brand()}</div>
54
60
  <div
55
- class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl text-[var(--color-success)]"
56
- style="background-color: color-mix(in oklch, var(--color-success) 16%, transparent);"
61
+ class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
62
+ style="background-color: color-mix(in oklch, var(--color-success) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-success) 22%, transparent);"
57
63
  >
58
64
  <MailCheckIcon class="h-6 w-6" />
59
65
  </div>
60
- <h2 class="text-lg font-semibold">Check your email</h2>
61
- <p class="mt-1 text-sm text-[var(--color-muted)]">
66
+ <h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
67
+ <p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
62
68
  We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
63
69
  </p>
64
- <div class="mt-5 w-full border-t border-[var(--cairn-card-border)] pt-4 text-left">
65
- <p class="text-sm text-[var(--color-muted)]">
66
- No link after a minute or two? Check your spam folder first. If it still hasn't arrived,
67
- double-check the address. It has to match the one your site owner added.
70
+ <div class="mt-6 flex w-full items-start gap-2.5 rounded-[var(--radius-field)] bg-base-content/[0.04] p-3.5 text-left">
71
+ <InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
72
+ <p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
73
+ No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
74
+ address may not match the one your site owner added.
68
75
  </p>
69
- <button
70
- type="button"
71
- class="btn btn-ghost btn-sm mt-3 -ml-2 text-primary"
72
- onclick={() => (dismissed = true)}
73
- >
74
- Use a different email
75
- </button>
76
76
  </div>
77
+ <button
78
+ type="button"
79
+ class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
80
+ onclick={() => (dismissed = true)}
81
+ >
82
+ Use a different email
83
+ </button>
77
84
  </div>
78
85
  {:else}
79
- <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>
86
+ <div class="mb-6 flex justify-center">{@render brand()}</div>
87
+ <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
88
+ <p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
80
89
  {#if data.error}
81
90
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
82
91
  {/if}
@@ -0,0 +1,64 @@
1
+ // The cairn condition registry: one entry per known environment or operational failure mode. It is
2
+ // the shared identity the readiness checklist, the doctor probe, and the runtime renderer all draw
3
+ // from, so the three surfaces agree (the 1:1:1). Internal: exported from no public package subpath,
4
+ // so the shape stays free to grow, the same stance as src/lib/log/. Renaming an id is a breaking
5
+ // change to the observable contract. See
6
+ // docs/superpowers/specs/2026-06-08-cairn-diagnostics-initiative-design.md.
7
+ import type { CairnLogEvent } from '../log/index.js';
8
+
9
+ export type ConditionSeverity = 'blocker' | 'warning';
10
+
11
+ export interface CairnCondition {
12
+ /** Stable, greppable id, e.g. 'edge.https-not-forced'. */
13
+ id: string;
14
+ severity: ConditionSeverity;
15
+ /** Short human label. */
16
+ title: string;
17
+ /** One or two sentences on why the condition bites. */
18
+ why: string;
19
+ /** The fix, often a command. */
20
+ remediation: string;
21
+ /** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
22
+ docsAnchor?: string;
23
+ /** The log vocabulary event this condition correlates with, if any. */
24
+ logEvent?: CairnLogEvent;
25
+ }
26
+
27
+ const REGISTRY: Record<string, CairnCondition> = {
28
+ 'edge.https-not-forced': {
29
+ id: 'edge.https-not-forced',
30
+ severity: 'blocker',
31
+ title: 'Always Use HTTPS is off',
32
+ why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
33
+ remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
34
+ logEvent: 'guard.rejected',
35
+ },
36
+ 'auth.csrf-token-invalid': {
37
+ id: 'auth.csrf-token-invalid',
38
+ severity: 'blocker',
39
+ title: 'Admin CSRF token check failed',
40
+ why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
41
+ remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
42
+ logEvent: 'guard.rejected',
43
+ },
44
+ 'auth.csrf-origin-mismatch': {
45
+ id: 'auth.csrf-origin-mismatch',
46
+ severity: 'blocker',
47
+ title: 'Non-admin form Origin rejected',
48
+ why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
49
+ remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
50
+ logEvent: 'guard.rejected',
51
+ },
52
+ };
53
+
54
+ /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
55
+ export function condition(id: string): CairnCondition {
56
+ const found = REGISTRY[id];
57
+ if (!found) throw new Error(`unknown cairn condition: ${id}`);
58
+ return found;
59
+ }
60
+
61
+ /** Every registered condition, for the checklist generator and coverage tests. */
62
+ export function allConditions(): CairnCondition[] {
63
+ return Object.values(REGISTRY);
64
+ }
@@ -0,0 +1,20 @@
1
+ // CairnError: a thrown failure that names a known condition. A catch site narrows on it, logs from
2
+ // the condition, and renders the condition's message in place of an opaque string. Its first
3
+ // throw-site is Pass 2 (the email send mapping); Pass 1 lands and tests the primitive.
4
+ import { condition, type CairnCondition } from './conditions.js';
5
+
6
+ export class CairnError extends Error {
7
+ readonly conditionId: string;
8
+ readonly condition: CairnCondition;
9
+
10
+ constructor(conditionId: string, options?: { cause?: unknown; message?: string }) {
11
+ const resolved = condition(conditionId);
12
+ super(
13
+ options?.message ?? resolved.title,
14
+ options?.cause !== undefined ? { cause: options.cause } : undefined
15
+ );
16
+ this.name = 'CairnError';
17
+ this.conditionId = conditionId;
18
+ this.condition = resolved;
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ // Internal barrel for the diagnostics model. Not re-exported from any public package subpath.
2
+ export { condition, allConditions } from './conditions.js';
3
+ export type { CairnCondition, ConditionSeverity } from './conditions.js';
4
+ export { CairnError } from './error.js';
@@ -1,4 +1,3 @@
1
- // src/lib/github/credentials.ts
2
1
  // cairn-cms: the bridge from the adapter's backend config and the Worker's secret to the
3
2
  // App signer's input. One tested place owns the join and the missing-secret failure, so the
4
3
  // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
@@ -1,4 +1,3 @@
1
- // src/lib/github/repo.ts
2
1
  // cairn-cms: repo reads and the commit, over the GitHub REST API. Listing a concept
3
2
  // directory uses the Git Trees API (the contents API silently truncates at 1,000 entries,
4
3
  // spec §7.3); a single-file read uses the contents API. An optional token lifts reads to
@@ -1,4 +1,3 @@
1
- // src/lib/github/signing.ts
2
1
  // cairn-cms: the GitHub App auth path. Mint an RS256 App JWT signed in-Worker with Web
3
2
  // Crypto, exchange it for a short-lived installation access token, and self-test the
4
3
  // brittle key conversion. GitHub issues PKCS#1 private keys and Web Crypto's importKey
@@ -1,4 +1,3 @@
1
- // src/lib/github/types.ts
2
1
  // cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
3
2
  // reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
4
3
  // `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
@@ -54,7 +54,7 @@ export function serializeComponent(def: ComponentDef, values: ComponentValues):
54
54
  ? (Array.isArray(raw) ? raw : []).filter((i) => i !== '').map((i) => `- ${i}`).join('\n')
55
55
  : ((raw as string | undefined) ?? '');
56
56
  if (!content) continue;
57
- if (lines.length > 1) lines.push(''); // blank line before this block
57
+ if (lines.length > 1) lines.push('');
58
58
  lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
59
59
  }
60
60
 
@@ -0,0 +1,23 @@
1
+ // Shared response helpers for cairn's admin pages: the baseline security headers and a branded
2
+ // full-document response. Extracted from guard.ts so the guard's resolve path and the condition
3
+ // renderer share one definition.
4
+
5
+ /**
6
+ * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
7
+ * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
8
+ */
9
+ export function applySecurityHeaders(headers: Headers): void {
10
+ headers.set('X-Content-Type-Options', 'nosniff');
11
+ headers.set('X-Frame-Options', 'DENY');
12
+ headers.set('Content-Security-Policy', "frame-ancestors 'none'");
13
+ headers.set('Referrer-Policy', 'no-referrer');
14
+ headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
15
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
16
+ }
17
+
18
+ /** A branded full-document admin page, hardened with the baseline headers and never cached. */
19
+ export function brandedAdminPage(status: number, body: string): Response {
20
+ const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
21
+ applySecurityHeaders(headers);
22
+ return new Response(body, { status, headers });
23
+ }
@@ -0,0 +1,38 @@
1
+ // The runtime renderer leg of the diagnostics model: map a condition to the Response the guard
2
+ // serves. Re-homes the three rejection responses guard.ts built inline, keyed by condition id, so
3
+ // the guard's reason, the registered condition, and the served page stay in step.
4
+ import { brandedAdminPage } from './admin-response.js';
5
+ import { httpsRequiredPage } from './https-required-page.js';
6
+ import { csrfRequiredPage } from './csrf-required-page.js';
7
+ import { condition } from '../diagnostics/index.js';
8
+
9
+ /** The guard.rejected reasons, each mapped to its registered condition id. */
10
+ export const REASON_CONDITION = {
11
+ https: 'edge.https-not-forced',
12
+ csrf: 'auth.csrf-token-invalid',
13
+ origin: 'auth.csrf-origin-mismatch',
14
+ } as const;
15
+
16
+ export type GuardReason = keyof typeof REASON_CONDITION;
17
+
18
+ /** Render the Response the guard serves for a rejection, by its condition id. */
19
+ export function renderConditionResponse(id: string, ctx: { url?: URL } = {}): Response {
20
+ // Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
21
+ condition(id);
22
+ switch (id) {
23
+ case REASON_CONDITION.https: {
24
+ const httpsUrl = new URL(ctx.url!);
25
+ httpsUrl.protocol = 'https:';
26
+ return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
27
+ }
28
+ case REASON_CONDITION.csrf:
29
+ return brandedAdminPage(403, csrfRequiredPage());
30
+ case REASON_CONDITION.origin:
31
+ return new Response('Cross-site POST form submissions are forbidden', {
32
+ status: 403,
33
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
34
+ });
35
+ default:
36
+ throw new Error(`no runtime renderer for condition: ${id}`);
37
+ }
38
+ }
@@ -4,9 +4,9 @@
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
- import { httpsRequiredPage } from './https-required-page.js';
8
7
  import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
9
- import { csrfRequiredPage } from './csrf-required-page.js';
8
+ import { applySecurityHeaders } from './admin-response.js';
9
+ import { renderConditionResponse } from './condition-response.js';
10
10
  import { log } from '../log/index.js';
11
11
  import type { Editor } from '../auth/types.js';
12
12
  import type { HandleInput, RequestContext } from './types.js';
@@ -36,46 +36,6 @@ function isLocalHost(hostname: string): boolean {
36
36
  );
37
37
  }
38
38
 
39
- /**
40
- * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
41
- * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
42
- */
43
- function applySecurityHeaders(headers: Headers): void {
44
- headers.set('X-Content-Type-Options', 'nosniff');
45
- headers.set('X-Frame-Options', 'DENY');
46
- headers.set('Content-Security-Policy', "frame-ancestors 'none'");
47
- headers.set('Referrer-Policy', 'no-referrer');
48
- headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
49
- headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
50
- }
51
-
52
- /** A branded full-document admin page, hardened with the baseline headers and never cached. */
53
- function brandedAdminPage(status: number, body: string): Response {
54
- const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
55
- applySecurityHeaders(headers);
56
- return new Response(body, { status, headers });
57
- }
58
-
59
- /** The hardened 400 help page for a deployed admin request that arrived over http. */
60
- function httpsRequiredResponse(url: URL): Response {
61
- const httpsUrl = new URL(url);
62
- httpsUrl.protocol = 'https:';
63
- return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
64
- }
65
-
66
- /** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
67
- function csrfForbidden(): Response {
68
- return new Response('Cross-site POST form submissions are forbidden', {
69
- status: 403,
70
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
71
- });
72
- }
73
-
74
- /** The branded 403 for a failed admin double-submit token check. */
75
- function csrfRequiredResponse(): Response {
76
- return brandedAdminPage(403, csrfRequiredPage());
77
- }
78
-
79
39
  /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
80
40
  export function createAuthGuard() {
81
41
  return async function handle({ event, resolve }: HandleInput): Promise<Response> {
@@ -86,7 +46,7 @@ export function createAuthGuard() {
86
46
  if (!isAdminPath(pathname)) {
87
47
  if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
88
48
  log.warn('guard.rejected', { reason: 'origin', path: pathname });
89
- return csrfForbidden();
49
+ return renderConditionResponse('auth.csrf-origin-mismatch');
90
50
  }
91
51
  return resolve(event);
92
52
  }
@@ -97,14 +57,14 @@ export function createAuthGuard() {
97
57
  // posts. Local http (wrangler dev) is exempt.
98
58
  if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
99
59
  log.warn('guard.rejected', { reason: 'https', path: pathname });
100
- return httpsRequiredResponse(event.url);
60
+ return renderConditionResponse('edge.https-not-forced', { url: event.url });
101
61
  }
102
62
 
103
63
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
104
64
  // 403 before resolve() runs. This covers the public login/auth posts too.
105
65
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
106
66
  log.warn('guard.rejected', { reason: 'csrf', path: pathname });
107
- return csrfRequiredResponse();
67
+ return renderConditionResponse('auth.csrf-token-invalid');
108
68
  }
109
69
 
110
70
  if (!isPublicAdminPath(pathname)) {