@glw907/cairn-cms 0.37.0 → 0.38.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 (40) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +69 -48
  3. package/dist/components/LoginPage.svelte +49 -28
  4. package/dist/components/LoginPage.svelte.d.ts +3 -1
  5. package/dist/components/cairn-admin.css +39 -14
  6. package/dist/diagnostics/conditions.d.ts +21 -0
  7. package/dist/diagnostics/conditions.js +53 -0
  8. package/dist/diagnostics/error.d.ts +9 -0
  9. package/dist/diagnostics/error.js +15 -0
  10. package/dist/diagnostics/index.d.ts +3 -0
  11. package/dist/diagnostics/index.js +3 -0
  12. package/dist/email.d.ts +20 -1
  13. package/dist/email.js +25 -0
  14. package/dist/github/repo.js +0 -1
  15. package/dist/github/types.js +0 -1
  16. package/dist/render/component-grammar.js +1 -1
  17. package/dist/sveltekit/admin-response.d.ts +7 -0
  18. package/dist/sveltekit/admin-response.js +21 -0
  19. package/dist/sveltekit/auth-routes.d.ts +16 -3
  20. package/dist/sveltekit/auth-routes.js +47 -28
  21. package/dist/sveltekit/condition-response.d.ts +11 -0
  22. package/dist/sveltekit/condition-response.js +34 -0
  23. package/dist/sveltekit/guard.js +5 -40
  24. package/dist/sveltekit/index.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/lib/components/LoginPage.svelte +49 -28
  27. package/src/lib/diagnostics/conditions.ts +80 -0
  28. package/src/lib/diagnostics/error.ts +20 -0
  29. package/src/lib/diagnostics/index.ts +4 -0
  30. package/src/lib/email.ts +31 -1
  31. package/src/lib/github/credentials.ts +0 -1
  32. package/src/lib/github/repo.ts +0 -1
  33. package/src/lib/github/signing.ts +0 -1
  34. package/src/lib/github/types.ts +0 -1
  35. package/src/lib/render/component-grammar.ts +1 -1
  36. package/src/lib/sveltekit/admin-response.ts +23 -0
  37. package/src/lib/sveltekit/auth-routes.ts +59 -29
  38. package/src/lib/sveltekit/condition-response.ts +38 -0
  39. package/src/lib/sveltekit/guard.ts +5 -45
  40. package/src/lib/sveltekit/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,7 +2,36 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
- ## 0.37.0
5
+ ## 0.38.0
6
+
7
+ The magic-link send is now awaited rather than fire-and-forget, so a delivery failure reaches the
8
+ login response instead of being swallowed. `requestAction` returns a `status` discriminant
9
+ (`sent` | `send_error` | `throttled`) alongside the existing `sent` boolean, and `LoginPage` renders
10
+ a send-error and a throttled state. The `auth.link.send_failed` log record gains a `code` (the
11
+ Cloudflare binding error code) and a `conditionId` (the mapped diagnostic condition).
12
+
13
+ Consumers may: read `form.status` to render the new states. A site rendering against `form.sent` is
14
+ unaffected, since `sent` is unchanged.
15
+
16
+ ## 0.37.1
17
+
18
+ Internal groundwork and a docs overhaul; nothing in the public surface or runtime behavior
19
+ changes, and no consumer action is needed.
20
+
21
+ The diagnostics foundation lands as an internal module: a condition registry
22
+ (`CairnCondition`), a `CairnError` throw primitive, and a shared condition-response renderer
23
+ that the admin guard's three rejection responses (the two CSRF reasons and the HTTPS check) now
24
+ route through. Those responses are unchanged and regression-pinned, and the module exports from
25
+ no package subpath. This is Pass 1 of the diagnostics initiative, the base the upcoming
26
+ `cairn doctor` and readiness checks build on.
27
+
28
+ Docs are reorganized and rewritten. A new README front door tells the save-flow story, says
29
+ what cairn is not, names the chosen stack, and then opens three doors: the tutorial, the
30
+ showcase, and the docs map. Stray top-level pages joined their Diátaxis arms (the admin route
31
+ contract is `docs/reference/admin-routes.md`, the sanitize floor is
32
+ `docs/explanation/render-safety.md`, key rotation is
33
+ `docs/guides/rotate-the-github-app-key.md`), and every adopter-facing page is rewritten in a
34
+ second-person, example-first voice with its technical content intact.
6
35
 
7
36
  The magic-link sign-in confirmation is now a branded panel in place of the flat success bar. After an
8
37
  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';
@@ -16,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
16
17
  interface Props {
17
18
  /** The login load's data: the site name, an optional error, and the CSRF token. */
18
19
  data: { siteName: string; error: string | null; csrf: string };
19
- /** The action result: `sent` is true once a request was accepted. */
20
- form: { sent?: boolean } | null;
20
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
21
+ * neutral, send-error, and throttled outcomes. */
22
+ form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
21
23
  }
22
24
 
23
25
  let { data, form }: Props = $props();
@@ -37,47 +39,66 @@ the allowlist, so the page never leaks membership (spec §7.1).
37
39
  <meta name="robots" content="noindex, nofollow" />
38
40
  </svelte:head>
39
41
 
42
+ <!-- The brand mark renders in both states; the parent container sets its alignment (left for the
43
+ form, centered for the confirmation), so one snippet covers both. -->
44
+ {#snippet brand()}
45
+ <div class="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
+ {/snippet}
50
+
40
51
  <!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
41
52
  level in (a class on the theme element itself would not match). -->
42
53
  <div data-theme="cairn-admin" bind:this={rootEl}>
43
54
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
44
55
  <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
- {#if form?.sent && !dismissed}
53
- <div role="status" class="mt-5 flex flex-col items-center text-center">
56
+ {#if (form?.status === 'sent' || form?.sent) && !dismissed}
57
+ <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
58
+ instruction. The fallback help sits in a gentle inset note below. -->
59
+ <div role="status" class="flex flex-col items-center text-center">
60
+ <div class="mb-7">{@render brand()}</div>
54
61
  <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);"
62
+ class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
63
+ 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
64
  >
58
65
  <MailCheckIcon class="h-6 w-6" />
59
66
  </div>
60
- <h2 class="text-lg font-semibold">Check your email</h2>
61
- <p class="mt-1 text-sm text-[var(--color-muted)]">
67
+ <h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
68
+ <p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
62
69
  We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
63
70
  </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.
71
+ <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">
72
+ <InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
73
+ <p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
74
+ No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
75
+ address may not match the one your site owner added.
68
76
  </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
77
  </div>
78
+ <button
79
+ type="button"
80
+ class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
81
+ onclick={() => (dismissed = true)}
82
+ >
83
+ Use a different email
84
+ </button>
77
85
  </div>
78
86
  {: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>
80
- {#if data.error}
87
+ <div class="mb-6 flex justify-center">{@render brand()}</div>
88
+ <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
89
+ <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>
90
+ {#if form?.status === 'send_error'}
91
+ <div role="alert" class="alert alert-warning mb-3 text-sm">
92
+ We're having trouble sending sign-in links right now. Please contact the site owner.
93
+ </div>
94
+ {:else if form?.status === 'throttled'}
95
+ <div role="status" class="alert mb-3 text-sm">
96
+ You requested a link recently. Check your inbox, or wait a minute and try again.
97
+ </div>
98
+ {/if}
99
+ <!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
100
+ send failure never shows the stale expired-link alert alongside the new state. -->
101
+ {#if data.error && !form?.status}
81
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
82
103
  {/if}
83
104
  <form method="POST" class="flex flex-col gap-3">
@@ -6,9 +6,11 @@ interface Props {
6
6
  error: string | null;
7
7
  csrf: string;
8
8
  };
9
- /** The action result: `sent` is true once a request was accepted. */
9
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
10
+ * neutral, send-error, and throttled outcomes. */
10
11
  form: {
11
12
  sent?: boolean;
13
+ status?: 'sent' | 'send_error' | 'throttled';
12
14
  } | null;
13
15
  }
14
16
  /**
@@ -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,53 @@
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
+ 'email.sender-not-onboarded': {
27
+ id: 'email.sender-not-onboarded',
28
+ severity: 'blocker',
29
+ title: 'Email sending domain is not onboarded',
30
+ why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
31
+ remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
32
+ logEvent: 'auth.link.send_failed',
33
+ },
34
+ 'email.send-failed': {
35
+ id: 'email.send-failed',
36
+ severity: 'blocker',
37
+ title: 'Magic-link email send failed',
38
+ why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
39
+ remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
40
+ logEvent: 'auth.link.send_failed',
41
+ },
42
+ };
43
+ /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
44
+ export function condition(id) {
45
+ const found = REGISTRY[id];
46
+ if (!found)
47
+ throw new Error(`unknown cairn condition: ${id}`);
48
+ return found;
49
+ }
50
+ /** Every registered condition, for the checklist generator and coverage tests. */
51
+ export function allConditions() {
52
+ return Object.values(REGISTRY);
53
+ }
@@ -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';
package/dist/email.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AuthEnv } from './auth/types.js';
2
+ import { CairnError } from './diagnostics/index.js';
2
3
  export type { AuthEnv };
3
4
  /** The message a built magic-link email carries. */
4
5
  export interface MagicLinkMessage {
@@ -14,7 +15,9 @@ export interface AuthBranding {
14
15
  from: string;
15
16
  replyTo?: string;
16
17
  }
17
- /** The injected send. Production uses `cloudflareSend`; tests pass a sink. */
18
+ /** The injected send. Production uses `cloudflareSend`; tests pass a sink. A thrown error's
19
+ * text reaches the structured log (scrubbed and truncated), so a custom sender must not embed
20
+ * the message body or the magic link in what it throws. */
18
21
  export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
19
22
  /** Build the confirmation email. The link is the only action; the copy stays plain. */
20
23
  export declare function buildMagicLinkMessage(input: {
@@ -24,3 +27,19 @@ export declare function buildMagicLinkMessage(input: {
24
27
  }): MagicLinkMessage;
25
28
  /** The production send: Cloudflare Email Sending through the EMAIL binding. */
26
29
  export declare const cloudflareSend: SendMagicLink;
30
+ /**
31
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
32
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
33
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
34
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
35
+ * undefined and the record still logs cleanly.
36
+ */
37
+ export declare function errorCode(err: unknown): string | undefined;
38
+ /**
39
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
40
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
41
+ * also been observed throwing the bare "not a verified address" string with no code, so that
42
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
43
+ * the conditionId and code, and returns a send_error status.
44
+ */
45
+ export declare function emailSendFailure(err: unknown): CairnError;
package/dist/email.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CairnError } from './diagnostics/index.js';
1
2
  /** Escape the five HTML-significant characters. */
2
3
  function escapeHtml(value) {
3
4
  return value
@@ -23,3 +24,27 @@ export const cloudflareSend = async (env, message) => {
23
24
  throw new Error('EMAIL binding is not configured');
24
25
  await env.EMAIL.send(message);
25
26
  };
27
+ /**
28
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
29
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
30
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
31
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
32
+ * undefined and the record still logs cleanly.
33
+ */
34
+ export function errorCode(err) {
35
+ if (typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string') {
36
+ return err.code;
37
+ }
38
+ return String(err).match(/\bE_[A-Z][A-Z_]*\b/)?.[0];
39
+ }
40
+ /**
41
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
42
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
43
+ * also been observed throwing the bare "not a verified address" string with no code, so that
44
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
45
+ * the conditionId and code, and returns a send_error status.
46
+ */
47
+ export function emailSendFailure(err) {
48
+ const onboarding = errorCode(err) === 'E_SENDER_NOT_VERIFIED' || String(err).includes('not a verified address');
49
+ return new CairnError(onboarding ? 'email.sender-not-onboarded' : 'email.send-failed', { cause: err });
50
+ }
@@ -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