@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.
- package/CHANGELOG.md +30 -1
- package/README.md +69 -48
- package/dist/components/LoginPage.svelte +49 -28
- package/dist/components/LoginPage.svelte.d.ts +3 -1
- package/dist/components/cairn-admin.css +39 -14
- package/dist/diagnostics/conditions.d.ts +21 -0
- package/dist/diagnostics/conditions.js +53 -0
- package/dist/diagnostics/error.d.ts +9 -0
- package/dist/diagnostics/error.js +15 -0
- package/dist/diagnostics/index.d.ts +3 -0
- package/dist/diagnostics/index.js +3 -0
- package/dist/email.d.ts +20 -1
- package/dist/email.js +25 -0
- package/dist/github/repo.js +0 -1
- package/dist/github/types.js +0 -1
- package/dist/render/component-grammar.js +1 -1
- package/dist/sveltekit/admin-response.d.ts +7 -0
- package/dist/sveltekit/admin-response.js +21 -0
- package/dist/sveltekit/auth-routes.d.ts +16 -3
- package/dist/sveltekit/auth-routes.js +47 -28
- package/dist/sveltekit/condition-response.d.ts +11 -0
- package/dist/sveltekit/condition-response.js +34 -0
- package/dist/sveltekit/guard.js +5 -40
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/LoginPage.svelte +49 -28
- package/src/lib/diagnostics/conditions.ts +80 -0
- package/src/lib/diagnostics/error.ts +20 -0
- package/src/lib/diagnostics/index.ts +4 -0
- package/src/lib/email.ts +31 -1
- package/src/lib/github/credentials.ts +0 -1
- package/src/lib/github/repo.ts +0 -1
- package/src/lib/github/signing.ts +0 -1
- package/src/lib/github/types.ts +0 -1
- package/src/lib/render/component-grammar.ts +1 -1
- package/src/lib/sveltekit/admin-response.ts +23 -0
- package/src/lib/sveltekit/auth-routes.ts +59 -29
- package/src/lib/sveltekit/condition-response.ts +38 -0
- package/src/lib/sveltekit/guard.ts +5 -45
- 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.
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
64
|
-
version range. The library's own development proves changes against `examples/showcase`,
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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="
|
|
56
|
-
style="background-color: color-mix(in oklch, var(--color-success)
|
|
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
|
-
<
|
|
61
|
-
<p class="mt-
|
|
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-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
<
|
|
80
|
-
{
|
|
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
|
|
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'])
|
|
3402
|
-
margin-
|
|
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
|
+
}
|
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
|
+
}
|
package/dist/github/repo.js
CHANGED
|
@@ -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
|
package/dist/github/types.js
CHANGED
|
@@ -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
|