@glw907/cairn-cms 0.40.0 → 0.41.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 +34 -0
- package/README.md +1 -1
- package/dist/components/ConceptList.svelte +14 -5
- package/dist/components/EditPage.svelte +34 -10
- package/dist/components/EditorToolbar.svelte +4 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.js +19 -11
- package/package.json +6 -4
- package/src/lib/components/ConceptList.svelte +14 -5
- package/src/lib/components/EditPage.svelte +34 -10
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/log/events.ts +1 -0
- package/src/lib/sveltekit/content-routes.ts +19 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are recorded here, most recent first.
|
|
4
4
|
|
|
5
|
+
## 0.41.0
|
|
6
|
+
|
|
7
|
+
`cairn-doctor` ships as a second bin: a setup preflight that runs nine checks over the local config
|
|
8
|
+
files (the wrangler bindings, observability, the CSRF handoff, the site config), the Cloudflare
|
|
9
|
+
account (the onboarded sending domain, Always Use HTTPS, HSTS, the D1 auth store with its schema
|
|
10
|
+
and an owner row), and the GitHub App's full reachability chain. Every check reports into one
|
|
11
|
+
plain-text report, a failure prints its condition's why and remediation from the diagnostics
|
|
12
|
+
registry, and the exit code is 1 on any failure, so the command slots into a deploy script as a
|
|
13
|
+
gate. A missing credential makes the affected checks skip rather than fail, and
|
|
14
|
+
`--send-test <address>` opts into one real email through the Email Sending API. The new
|
|
15
|
+
[Cloudflare readiness guide](docs/guides/cloudflare-readiness.md) walks the same conditions
|
|
16
|
+
manually, a `check:readiness` gate pins that guide to the condition registry, and
|
|
17
|
+
[the doctor reference](docs/reference/doctor.md) covers the flags, the checks, and the CI wiring.
|
|
18
|
+
|
|
19
|
+
The admin layout's GitHub degrade gains a signal. When the pending-entries read fails, the layout
|
|
20
|
+
logs a warn-level `github.unreachable` record and the topbar's Publish site button hides instead
|
|
21
|
+
of showing a count it cannot know.
|
|
22
|
+
|
|
23
|
+
Consumers may: run `npx cairn-doctor --from <address> --repo <owner/name>` as a pre-launch gate,
|
|
24
|
+
work through the readiness guide when standing up a fresh account, and filter Workers Logs on
|
|
25
|
+
`github.unreachable` when the publish button goes missing.
|
|
26
|
+
|
|
27
|
+
A debt batch rides along. The editor's link autocomplete no longer
|
|
28
|
+
pulls CodeMirror into the server bundle, the edit page's load reads its GitHub probes in parallel,
|
|
29
|
+
concurrent cold-start token mints coalesce into one, publish-all pluralizes its commit message and
|
|
30
|
+
an empty publish-all explains itself instead of redirecting silently, the unsaved-changes warning
|
|
31
|
+
tracks client-side navigation and no longer double-fires on a full page unload, the toolbar's
|
|
32
|
+
keyboard tab stop holds across Preview round trips, the word count ignores markdown and directive
|
|
33
|
+
syntax, and the list's publish flash announces reliably to screen readers.
|
|
34
|
+
|
|
35
|
+
Consumers must: be on `@sveltejs/kit` 2.12 or later before taking this release. The edit page now
|
|
36
|
+
reads `$app/state`, which shipped in kit 2.12.0, and the peer range says so (`^2.12`); a site on
|
|
37
|
+
an older kit must upgrade kit first.
|
|
38
|
+
|
|
5
39
|
## 0.40.0
|
|
6
40
|
|
|
7
41
|
The edit page is redesigned around the manuscript. A sticky translucent header carries the
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ formal contribution process yet, so this is not an open call for pull requests.
|
|
|
65
65
|
npm install @glw907/cairn-cms
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a
|
|
68
|
+
Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2.12`. A consumer site implements a
|
|
69
69
|
`CairnAdapter` and mounts thin `/admin` route shims around the package subpaths:
|
|
70
70
|
|
|
71
71
|
- `@glw907/cairn-cms`: the core engine and adapter contract.
|
|
@@ -107,6 +107,14 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
107
107
|
// flex layout and a hover affordance on top of this.
|
|
108
108
|
const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
|
|
109
109
|
const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-base-content`;
|
|
110
|
+
|
|
111
|
+
// The publish-all flash. A racing second admin can publish first, leaving this redirect
|
|
112
|
+
// counting zero; say nothing then.
|
|
113
|
+
const publishedAllMessage = $derived(
|
|
114
|
+
data.publishedAll !== null && data.publishedAll > 0
|
|
115
|
+
? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
|
|
116
|
+
: '',
|
|
117
|
+
);
|
|
110
118
|
</script>
|
|
111
119
|
|
|
112
120
|
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
@@ -122,11 +130,12 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
122
130
|
</div>
|
|
123
131
|
</header>
|
|
124
132
|
|
|
125
|
-
<!--
|
|
126
|
-
{#if
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
<!-- One persistent live region announces the publish-all flash (the EditPage pattern): a
|
|
134
|
+
{#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
|
|
135
|
+
below keeps its styling without a role and the message is announced once. -->
|
|
136
|
+
<div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
|
|
137
|
+
{#if publishedAllMessage}
|
|
138
|
+
<div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
|
|
130
139
|
{/if}
|
|
131
140
|
{#if data.formError}
|
|
132
141
|
<div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
@@ -14,6 +14,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
14
14
|
<script lang="ts">
|
|
15
15
|
import { untrack } from 'svelte';
|
|
16
16
|
import { beforeNavigate } from '$app/navigation';
|
|
17
|
+
import { page } from '$app/state';
|
|
17
18
|
import CsrfField from './CsrfField.svelte';
|
|
18
19
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
19
20
|
import EditorToolbar from './EditorToolbar.svelte';
|
|
@@ -25,7 +26,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
25
26
|
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
26
27
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
27
28
|
import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
|
|
28
|
-
import { directiveLineKind } from './markdown-directives.js';
|
|
29
|
+
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
29
30
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
30
31
|
import type { IconSet } from '../render/glyph.js';
|
|
31
32
|
import type { EditData } from '../sveltekit/content-routes.js';
|
|
@@ -101,6 +102,14 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
101
102
|
// navigation passes through because busy flips before it starts, and a non-edit POST's because
|
|
102
103
|
// leaving does.
|
|
103
104
|
beforeNavigate((navigation) => {
|
|
105
|
+
// A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
|
|
106
|
+
// on a leave navigation is what asks the browser for its native dialog, so no confirm()
|
|
107
|
+
// here or two prompts would stack. The beforeunload listener below is deliberate
|
|
108
|
+
// belt-and-braces, not the dialog's source.
|
|
109
|
+
if (navigation.willUnload) {
|
|
110
|
+
if (dirty && !busy && !leaving) navigation.cancel();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
104
113
|
if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
|
|
105
114
|
navigation.cancel();
|
|
106
115
|
});
|
|
@@ -240,14 +249,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
240
249
|
});
|
|
241
250
|
});
|
|
242
251
|
|
|
243
|
-
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>.
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
$
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const drafts = new URLSearchParams(search).get('drafts');
|
|
250
|
-
draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
252
|
+
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
|
|
253
|
+
// is reactive kit state, so a client-side navigation that swaps the search string re-derives
|
|
254
|
+
// this, and the read is SSR-safe.
|
|
255
|
+
const draftWarning = $derived.by(() => {
|
|
256
|
+
const drafts = page.url.searchParams.get('drafts');
|
|
257
|
+
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
251
258
|
});
|
|
252
259
|
|
|
253
260
|
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
@@ -284,12 +291,29 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
284
291
|
return '';
|
|
285
292
|
});
|
|
286
293
|
|
|
294
|
+
// One line of body text reduced to its prose: inline directives drop wholesale, then the
|
|
295
|
+
// markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
|
|
296
|
+
// as two words instead of mashing the link text into its destination, so a link counts its
|
|
297
|
+
// text plus its URL and the count never undercounts prose.
|
|
298
|
+
function proseOnly(line: string): string {
|
|
299
|
+
let out = '';
|
|
300
|
+
let cursor = 0;
|
|
301
|
+
for (const { from, to } of findInlineDirectives(line)) {
|
|
302
|
+
out += line.slice(cursor, from);
|
|
303
|
+
cursor = to;
|
|
304
|
+
}
|
|
305
|
+
out += line.slice(cursor);
|
|
306
|
+
return out.replace(/[*_~`[\]()#]/g, ' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
// The editor footer's word count, over the local body so it tracks every keystroke. Directive
|
|
288
|
-
// machinery lines and table rows are dropped first
|
|
310
|
+
// machinery lines and table rows are dropped first and the inline syntax stripped, so the
|
|
311
|
+
// count reads as the author's prose.
|
|
289
312
|
const countedBody = $derived(
|
|
290
313
|
body
|
|
291
314
|
.split('\n')
|
|
292
315
|
.filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
|
|
316
|
+
.map(proseOnly)
|
|
293
317
|
.join('\n'),
|
|
294
318
|
);
|
|
295
319
|
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
@@ -113,6 +113,10 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
113
113
|
const items = rovingControls();
|
|
114
114
|
if (items.length === 0) return;
|
|
115
115
|
const stop = Math.min(roving, items.length - 1);
|
|
116
|
+
// Write the clamp back so the stored stop never drifts from the displayed one across a
|
|
117
|
+
// Preview round trip. The effect reads roving, so the guarded write re-runs it once and
|
|
118
|
+
// converges (the second pass computes the same stop and writes nothing).
|
|
119
|
+
if (stop !== roving) roving = stop;
|
|
116
120
|
for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
|
|
117
121
|
});
|
|
118
122
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { syntaxTree } from '@codemirror/language';
|
|
2
1
|
import { formatCairnToken, escapeLinkText } from '../content/links.js';
|
|
2
|
+
// EditPage imports this module statically, so a static @codemirror value import here would pull
|
|
3
|
+
// CodeMirror into a consumer's server bundle. syntaxTree resolves lazily inside the source
|
|
4
|
+
// instead (a CompletionSource may return a Promise), cached after the first completion.
|
|
5
|
+
let langMod = null;
|
|
3
6
|
/** The known concepts in display order; an unlisted concept sorts after these under its own name. */
|
|
4
7
|
const CONCEPT_SECTIONS = {
|
|
5
8
|
pages: { name: 'Pages', rank: 0 },
|
|
@@ -30,7 +33,7 @@ export function linkCompletions(targets, query) {
|
|
|
30
33
|
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
31
34
|
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
32
35
|
export function cairnLinkCompletionSource(targets) {
|
|
33
|
-
return (context) => {
|
|
36
|
+
return async (context) => {
|
|
34
37
|
const line = context.state.doc.lineAt(context.pos);
|
|
35
38
|
const before = context.state.sliceDoc(line.from, context.pos);
|
|
36
39
|
const trigger = matchCairnTrigger(before);
|
|
@@ -38,7 +41,11 @@ export function cairnLinkCompletionSource(targets) {
|
|
|
38
41
|
return null;
|
|
39
42
|
// Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
|
|
40
43
|
// the build resolver does not look inside code. The node name carries "Code" for both forms.
|
|
41
|
-
|
|
44
|
+
langMod ??= await import('@codemirror/language');
|
|
45
|
+
// The first completion awaits the import above, so the request may already be stale here.
|
|
46
|
+
if (context.aborted)
|
|
47
|
+
return null;
|
|
48
|
+
const node = langMod.syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
42
49
|
for (let n = node; n; n = n.parent) {
|
|
43
50
|
if (/Code/.test(n.name))
|
|
44
51
|
return null;
|
|
@@ -10,11 +10,18 @@ export interface CairnCondition {
|
|
|
10
10
|
why: string;
|
|
11
11
|
/** The fix, often a command. */
|
|
12
12
|
remediation: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The condition's section in the readiness checklist, written as
|
|
15
|
+
* 'cloudflare-readiness.md#<heading-slug>' so a doc can link it relative to docs/guides/.
|
|
16
|
+
* The check:readiness gate parses the part after '#' and asserts the heading exists; two
|
|
17
|
+
* conditions may share a section. Every entry carries one unless the gate's allowlist
|
|
18
|
+
* excuses it.
|
|
19
|
+
*/
|
|
14
20
|
docsAnchor?: string;
|
|
15
21
|
/** The log vocabulary event this condition correlates with, if any. */
|
|
16
22
|
logEvent?: CairnLogEvent;
|
|
17
23
|
}
|
|
24
|
+
export declare const REGISTRY: Record<string, CairnCondition>;
|
|
18
25
|
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
19
26
|
export declare function condition(id: string): CairnCondition;
|
|
20
27
|
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Exported for the freeze test only; resolve entries through condition() everywhere else.
|
|
2
|
+
export const REGISTRY = {
|
|
2
3
|
'edge.https-not-forced': {
|
|
3
4
|
id: 'edge.https-not-forced',
|
|
4
5
|
severity: 'blocker',
|
|
5
6
|
title: 'Always Use HTTPS is off',
|
|
6
7
|
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
8
|
remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
|
|
9
|
+
docsAnchor: 'cloudflare-readiness.md#force-https-at-the-edge',
|
|
8
10
|
logEvent: 'guard.rejected',
|
|
9
11
|
},
|
|
10
12
|
'auth.csrf-token-invalid': {
|
|
@@ -13,6 +15,7 @@ const REGISTRY = {
|
|
|
13
15
|
title: 'Admin CSRF token check failed',
|
|
14
16
|
why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
|
|
15
17
|
remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
|
|
18
|
+
docsAnchor: 'cloudflare-readiness.md#admin-csrf-token-rejected',
|
|
16
19
|
logEvent: 'guard.rejected',
|
|
17
20
|
},
|
|
18
21
|
'auth.csrf-origin-mismatch': {
|
|
@@ -21,6 +24,7 @@ const REGISTRY = {
|
|
|
21
24
|
title: 'Non-admin form Origin rejected',
|
|
22
25
|
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
26
|
remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
|
|
27
|
+
docsAnchor: 'cloudflare-readiness.md#non-admin-origin-rejected',
|
|
24
28
|
logEvent: 'guard.rejected',
|
|
25
29
|
},
|
|
26
30
|
'email.sender-not-onboarded': {
|
|
@@ -29,6 +33,7 @@ const REGISTRY = {
|
|
|
29
33
|
title: 'Email sending domain is not onboarded',
|
|
30
34
|
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
35
|
remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
|
|
36
|
+
docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
|
|
32
37
|
logEvent: 'auth.link.send_failed',
|
|
33
38
|
},
|
|
34
39
|
'email.send-failed': {
|
|
@@ -37,9 +42,71 @@ const REGISTRY = {
|
|
|
37
42
|
title: 'Magic-link email send failed',
|
|
38
43
|
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
44
|
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.',
|
|
45
|
+
docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
|
|
40
46
|
logEvent: 'auth.link.send_failed',
|
|
41
47
|
},
|
|
48
|
+
'config.bindings-missing': {
|
|
49
|
+
id: 'config.bindings-missing',
|
|
50
|
+
severity: 'blocker',
|
|
51
|
+
title: 'Wrangler bindings are missing',
|
|
52
|
+
why: 'The wrangler config declares no send_email binding named EMAIL or no D1 binding named AUTH_DB, so the magic-link send or the session store has nothing to call and no editor can sign in.',
|
|
53
|
+
remediation: 'Declare the send_email binding as EMAIL and the d1_databases binding as AUTH_DB in wrangler.jsonc (or wrangler.toml), then re-deploy.',
|
|
54
|
+
docsAnchor: 'cloudflare-readiness.md#deploy-the-worker-with-its-bindings',
|
|
55
|
+
},
|
|
56
|
+
'config.observability-off': {
|
|
57
|
+
id: 'config.observability-off',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
title: 'Workers Logs has no sink',
|
|
60
|
+
why: 'observability.enabled is not true in the wrangler config, so the structured log records go nowhere and a runtime failure leaves nothing to read.',
|
|
61
|
+
remediation: 'Set observability.enabled to true in wrangler.jsonc, then re-deploy.',
|
|
62
|
+
docsAnchor: 'cloudflare-readiness.md#turn-on-observability',
|
|
63
|
+
},
|
|
64
|
+
'config.csrf-disable-missing': {
|
|
65
|
+
id: 'config.csrf-disable-missing',
|
|
66
|
+
severity: 'warning',
|
|
67
|
+
title: 'Framework CSRF check is not handed off',
|
|
68
|
+
why: "The CSRF authority is not handed to cairn cleanly. Either svelte.config.js does not carry csrf: { checkOrigin: false }, so SvelteKit's own Origin check runs ahead of cairn's guard and rejects an admin form POST that arrives without an Origin header, or the disable is present with no cairn guard wired in src/hooks.server.ts, which leaves the site with no CSRF protection at all.",
|
|
69
|
+
remediation: "Set csrf: { checkOrigin: false } in svelte.config.js and wire createAuthGuard into src/hooks.server.ts; cairn's guard owns the Origin and double-submit token checks.",
|
|
70
|
+
docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
|
|
71
|
+
},
|
|
72
|
+
'config.site-config-invalid': {
|
|
73
|
+
id: 'config.site-config-invalid',
|
|
74
|
+
severity: 'blocker',
|
|
75
|
+
title: 'Site config does not validate',
|
|
76
|
+
why: 'site.config.yaml fails to parse or fails the URL-policy validation, so the build and the admin cannot resolve the content concepts.',
|
|
77
|
+
remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
|
|
78
|
+
docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
|
|
79
|
+
},
|
|
80
|
+
'edge.hsts-off': {
|
|
81
|
+
id: 'edge.hsts-off',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
title: 'HSTS is off',
|
|
84
|
+
why: 'The zone sends no Strict-Transport-Security header with a meaningful max-age, so browsers do not pin https and a later http visit can still hit the admin guard rejection.',
|
|
85
|
+
remediation: 'Turn on HSTS for the zone under SSL/TLS, Edge Certificates, with a max-age of at least six months.',
|
|
86
|
+
docsAnchor: 'cloudflare-readiness.md#turn-on-hsts',
|
|
87
|
+
},
|
|
88
|
+
'auth.store-unreachable': {
|
|
89
|
+
id: 'auth.store-unreachable',
|
|
90
|
+
severity: 'blocker',
|
|
91
|
+
title: 'Auth store is unreachable',
|
|
92
|
+
why: 'The AUTH_DB D1 database is missing, lacks the auth schema, or holds no owner row, so no magic-link token can be minted and nobody can sign in.',
|
|
93
|
+
remediation: 'Create the database, apply the auth schema with `wrangler d1 execute <db> --remote --file ./migrations/0000_auth.sql`, seed the owner row, and check the AUTH_DB binding id in wrangler.jsonc.',
|
|
94
|
+
docsAnchor: 'cloudflare-readiness.md#provision-the-auth-store',
|
|
95
|
+
},
|
|
96
|
+
'github.app-unreachable': {
|
|
97
|
+
id: 'github.app-unreachable',
|
|
98
|
+
severity: 'blocker',
|
|
99
|
+
title: 'GitHub App is unreachable',
|
|
100
|
+
why: 'The App key fails to parse, the App fails to authenticate, the installation token fails to mint, or the repository refuses a read, so saves and publishes cannot commit.',
|
|
101
|
+
remediation: 'Check GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 against the App settings, and confirm the App is installed on the repository.',
|
|
102
|
+
docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
|
|
103
|
+
logEvent: 'github.unreachable',
|
|
104
|
+
},
|
|
42
105
|
};
|
|
106
|
+
// The registry is shared identity, never working state; freeze every entry and the map itself.
|
|
107
|
+
for (const entry of Object.values(REGISTRY))
|
|
108
|
+
Object.freeze(entry);
|
|
109
|
+
Object.freeze(REGISTRY);
|
|
43
110
|
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
44
111
|
export function condition(id) {
|
|
45
112
|
const found = REGISTRY[id];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cairn-doctor: the environment preflight. A thin shell over index.ts (where the unit tests
|
|
3
|
+
// reach the logic): parse the flags, assemble the context with the real fetch and filesystem,
|
|
4
|
+
// run the default registry plus the opt-in live send, print the report. Bad flags go to
|
|
5
|
+
// stderr with exit 2; a failed check exits 1; a clean or all-skip run exits 0. The codes go
|
|
6
|
+
// through process.exitCode, never process.exit, so a piped stdout flushes the whole report
|
|
7
|
+
// before the process ends.
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { liveSendCheck } from './check-send.js';
|
|
11
|
+
import { contextFromEnv, defaultChecks, formatReport, parseArgs, runDoctor } from './index.js';
|
|
12
|
+
async function main() {
|
|
13
|
+
let args;
|
|
14
|
+
try {
|
|
15
|
+
args = parseArgs(process.argv.slice(2));
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
19
|
+
process.exitCode = 2;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const ctx = {
|
|
24
|
+
...contextFromEnv(process.env, args, cwd),
|
|
25
|
+
fetch: globalThis.fetch,
|
|
26
|
+
readFile: async (relPath) => {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(resolve(cwd, relPath), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === 'ENOENT')
|
|
32
|
+
return null;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const checks = defaultChecks();
|
|
38
|
+
if (args.sendTest)
|
|
39
|
+
checks.push(liveSendCheck(args.sendTest));
|
|
40
|
+
const { results, failed } = await runDoctor(checks, ctx);
|
|
41
|
+
console.log(formatReport(results));
|
|
42
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
await main();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The doctor's opt-in live send (--send-test): one real message through the Email Sending
|
|
2
|
+
// REST API, since the Worker EMAIL binding is unreachable from a CLI. A factory rather than
|
|
3
|
+
// a check constant, so the check exists only when the bin receives an address; no default
|
|
4
|
+
// registry carries it.
|
|
5
|
+
//
|
|
6
|
+
// Endpoint and payload verified against the Cloudflare API reference, 2026-06-11:
|
|
7
|
+
// POST /accounts/{account_id}/email/sending/send with { from, to, subject, text },
|
|
8
|
+
// where from and to take a plain address string.
|
|
9
|
+
// https://developers.cloudflare.com/api/resources/email_sending/
|
|
10
|
+
import { fail, pass } from './types.js';
|
|
11
|
+
import { cfPost, NO_ACCOUNT, NO_FROM } from './cloudflare-api.js';
|
|
12
|
+
// Enough of an error body to act on without flooding the one-line report.
|
|
13
|
+
const EXCERPT_MAX = 200;
|
|
14
|
+
/** Build the live-send check for one recipient address. */
|
|
15
|
+
export function liveSendCheck(to) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'email.live-send',
|
|
18
|
+
conditionId: 'email.send-failed',
|
|
19
|
+
title: 'Live test send',
|
|
20
|
+
async run(ctx) {
|
|
21
|
+
if (!ctx.cfToken || !ctx.cfAccountId)
|
|
22
|
+
return NO_ACCOUNT;
|
|
23
|
+
if (!ctx.from)
|
|
24
|
+
return NO_FROM;
|
|
25
|
+
try {
|
|
26
|
+
const res = await cfPost(ctx, `/accounts/${ctx.cfAccountId}/email/sending/send`, {
|
|
27
|
+
from: ctx.from,
|
|
28
|
+
to,
|
|
29
|
+
subject: 'cairn doctor test send',
|
|
30
|
+
text: 'This is a cairn doctor test send. Receiving it proves the sending path.',
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const excerpt = (await res.text()).slice(0, EXCERPT_MAX);
|
|
34
|
+
return fail(`send returned ${res.status}: ${excerpt}`);
|
|
35
|
+
}
|
|
36
|
+
return pass(`sent to ${to}; check the inbox`);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return fail(String(err));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The doctor's Cloudflare API checks: the onboarded sending domain, the zone HTTPS posture,
|
|
2
|
+
// and the D1 auth store, over the shared cloudflare-api plumbing.
|
|
3
|
+
//
|
|
4
|
+
// Endpoints verified against the Cloudflare API reference, 2026-06-11:
|
|
5
|
+
// - Email Sending subdomains (zone-scoped; this is the namespace `wrangler email sending
|
|
6
|
+
// enable` feeds, and the listing carries `{ result: [{ name, enabled, tag }] }`):
|
|
7
|
+
// GET /zones/{zone_id}/email/sending/subdomains
|
|
8
|
+
// https://developers.cloudflare.com/api/resources/email_sending/
|
|
9
|
+
// - Zone lookup, `{ result: [{ id }] }`: GET /zones?name=<domain>
|
|
10
|
+
// https://developers.cloudflare.com/api/resources/zones/
|
|
11
|
+
// - Zone settings, `{ result: { value } }` where always_use_https carries 'on' | 'off' and
|
|
12
|
+
// security_header nests `value.strict_transport_security.{enabled,max_age}`:
|
|
13
|
+
// GET /zones/{zone_id}/settings/{setting_id}
|
|
14
|
+
// https://developers.cloudflare.com/api/resources/zones/subresources/settings/
|
|
15
|
+
// - D1 query, `{ sql }` in, `{ result: [{ results: [...] }] }` out:
|
|
16
|
+
// POST /accounts/{account_id}/d1/database/{database_id}/query
|
|
17
|
+
// https://developers.cloudflare.com/api/resources/d1/subresources/database/
|
|
18
|
+
import { fail, pass, skip } from './types.js';
|
|
19
|
+
import { cfGet, cfPost, NO_ACCOUNT, NO_FROM, NO_TOKEN } from './cloudflare-api.js';
|
|
20
|
+
import { readWranglerConfig } from './wrangler-config.js';
|
|
21
|
+
// 30 days. The production zones run two years; anything under a month is a trivial pin.
|
|
22
|
+
const MIN_HSTS_MAX_AGE = 2592000;
|
|
23
|
+
function fromDomain(from) {
|
|
24
|
+
return from.slice(from.indexOf('@') + 1);
|
|
25
|
+
}
|
|
26
|
+
// The registrable domain is taken as the last two labels of the from-domain. A deliberate
|
|
27
|
+
// simplification: correct for the single-label public suffixes cairn targets (.ski, .life),
|
|
28
|
+
// wrong for multi-part suffixes like .co.uk, which would need a public-suffix list the
|
|
29
|
+
// doctor does not carry.
|
|
30
|
+
function registrableDomain(domain) {
|
|
31
|
+
return domain.split('.').slice(-2).join('.');
|
|
32
|
+
}
|
|
33
|
+
// A 401/403 means the token cannot make this read at all, so the product condition's
|
|
34
|
+
// remediation (onboard the domain, fix the binding) would point the operator at the wrong fix.
|
|
35
|
+
// The conditionId stays; the detail carries the scope truth.
|
|
36
|
+
function permissionFail(status, scope) {
|
|
37
|
+
if (status !== 401 && status !== 403)
|
|
38
|
+
return null;
|
|
39
|
+
return fail(`the API token lacks permission for this read (HTTP ${status}); grant the token ${scope} access`);
|
|
40
|
+
}
|
|
41
|
+
async function resolveZoneId(ctx, domain) {
|
|
42
|
+
// The from-domain may be its own Cloudflare zone (mail.example.com registered directly), so
|
|
43
|
+
// the exact name is tried first and the registrable domain is the fallback.
|
|
44
|
+
const apex = registrableDomain(domain);
|
|
45
|
+
const names = domain === apex ? [domain] : [domain, apex];
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const res = await cfGet(ctx, `/zones?name=${encodeURIComponent(name)}`);
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
return { fail: fail(`zone lookup for ${name} returned ${res.status}`) };
|
|
50
|
+
}
|
|
51
|
+
const body = (await res.json());
|
|
52
|
+
const id = body.result?.[0]?.id;
|
|
53
|
+
if (typeof id === 'string')
|
|
54
|
+
return { zoneId: id };
|
|
55
|
+
}
|
|
56
|
+
return { fail: fail(`no zone named ${names.join(' or ')} is visible to this token`) };
|
|
57
|
+
}
|
|
58
|
+
/** Resolve the domain's zone and read one of its settings, returning `result.value`. */
|
|
59
|
+
async function readZoneSetting(ctx, domain, settingId) {
|
|
60
|
+
const zone = await resolveZoneId(ctx, domain);
|
|
61
|
+
if ('fail' in zone)
|
|
62
|
+
return zone;
|
|
63
|
+
const res = await cfGet(ctx, `/zones/${zone.zoneId}/settings/${settingId}`);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
return { fail: fail(`${settingId} read returned ${res.status}`) };
|
|
66
|
+
}
|
|
67
|
+
const body = (await res.json());
|
|
68
|
+
return { value: body.result?.value };
|
|
69
|
+
}
|
|
70
|
+
export const emailSenderOnboarded = {
|
|
71
|
+
id: 'email.sender-onboarded',
|
|
72
|
+
conditionId: 'email.sender-not-onboarded',
|
|
73
|
+
title: 'Email sending domain',
|
|
74
|
+
async run(ctx) {
|
|
75
|
+
if (!ctx.cfToken)
|
|
76
|
+
return NO_TOKEN;
|
|
77
|
+
if (!ctx.from)
|
|
78
|
+
return NO_FROM;
|
|
79
|
+
const domain = fromDomain(ctx.from);
|
|
80
|
+
try {
|
|
81
|
+
const zone = await resolveZoneId(ctx, domain);
|
|
82
|
+
if ('fail' in zone)
|
|
83
|
+
return zone.fail;
|
|
84
|
+
const res = await cfGet(ctx, `/zones/${zone.zoneId}/email/sending/subdomains`);
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
return (permissionFail(res.status, 'Email Sending: Read') ??
|
|
87
|
+
fail(`sending subdomain list returned ${res.status}`));
|
|
88
|
+
}
|
|
89
|
+
const body = (await res.json());
|
|
90
|
+
const entry = body.result?.find((s) => s.name === domain);
|
|
91
|
+
if (entry?.enabled === true) {
|
|
92
|
+
return pass(`${domain} has an enabled sending subdomain`);
|
|
93
|
+
}
|
|
94
|
+
if (entry) {
|
|
95
|
+
return fail(`${domain} is onboarded but sending is disabled`);
|
|
96
|
+
}
|
|
97
|
+
return fail(`${domain} has no sending subdomain on the zone`);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return fail(String(err));
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
export const edgeHttpsForced = {
|
|
105
|
+
id: 'edge.https-forced',
|
|
106
|
+
conditionId: 'edge.https-not-forced',
|
|
107
|
+
title: 'Always Use HTTPS',
|
|
108
|
+
async run(ctx) {
|
|
109
|
+
if (!ctx.cfToken)
|
|
110
|
+
return NO_TOKEN;
|
|
111
|
+
if (!ctx.from)
|
|
112
|
+
return NO_FROM;
|
|
113
|
+
try {
|
|
114
|
+
const setting = await readZoneSetting(ctx, fromDomain(ctx.from), 'always_use_https');
|
|
115
|
+
if ('fail' in setting)
|
|
116
|
+
return setting.fail;
|
|
117
|
+
if (setting.value === 'on') {
|
|
118
|
+
return pass('Always Use HTTPS is on');
|
|
119
|
+
}
|
|
120
|
+
return fail(`always_use_https is ${setting.value ?? 'unreadable'}`);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return fail(String(err));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
export const edgeHsts = {
|
|
128
|
+
id: 'edge.hsts',
|
|
129
|
+
conditionId: 'edge.hsts-off',
|
|
130
|
+
title: 'HSTS',
|
|
131
|
+
async run(ctx) {
|
|
132
|
+
if (!ctx.cfToken)
|
|
133
|
+
return NO_TOKEN;
|
|
134
|
+
if (!ctx.from)
|
|
135
|
+
return NO_FROM;
|
|
136
|
+
try {
|
|
137
|
+
const setting = await readZoneSetting(ctx, fromDomain(ctx.from), 'security_header');
|
|
138
|
+
if ('fail' in setting)
|
|
139
|
+
return setting.fail;
|
|
140
|
+
const sts = setting.value?.strict_transport_security;
|
|
141
|
+
if (sts?.enabled !== true) {
|
|
142
|
+
return fail('HSTS is disabled on the zone');
|
|
143
|
+
}
|
|
144
|
+
const maxAge = sts.max_age ?? 0;
|
|
145
|
+
if (maxAge < MIN_HSTS_MAX_AGE) {
|
|
146
|
+
return fail(`HSTS max-age ${maxAge} is under the ${MIN_HSTS_MAX_AGE} (30 day) floor`);
|
|
147
|
+
}
|
|
148
|
+
return pass(`HSTS enabled with max-age ${maxAge}`);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
return fail(String(err));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const AUTH_TABLES = ['editor', 'magic_token', 'session'];
|
|
156
|
+
async function d1Query(ctx, databaseId, sql) {
|
|
157
|
+
const res = await cfPost(ctx, `/accounts/${ctx.cfAccountId}/d1/database/${encodeURIComponent(databaseId)}/query`, { sql });
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
return {
|
|
160
|
+
fail: permissionFail(res.status, 'D1: Read') ??
|
|
161
|
+
fail(`AUTH_DB is unreachable: the query returned ${res.status}`),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const body = (await res.json());
|
|
165
|
+
return { rows: body.result?.[0]?.results ?? [] };
|
|
166
|
+
}
|
|
167
|
+
export const authStore = {
|
|
168
|
+
id: 'auth.store',
|
|
169
|
+
conditionId: 'auth.store-unreachable',
|
|
170
|
+
title: 'Auth store (D1)',
|
|
171
|
+
async run(ctx) {
|
|
172
|
+
if (!ctx.cfToken || !ctx.cfAccountId)
|
|
173
|
+
return NO_ACCOUNT;
|
|
174
|
+
const facts = await readWranglerConfig(ctx.readFile);
|
|
175
|
+
if (typeof facts?.authDbId !== 'string') {
|
|
176
|
+
return skip('no AUTH_DB database_id in wrangler.jsonc or wrangler.toml');
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const tables = await d1Query(ctx, facts.authDbId, "SELECT name FROM sqlite_master WHERE type='table'");
|
|
180
|
+
if ('fail' in tables)
|
|
181
|
+
return tables.fail;
|
|
182
|
+
const names = new Set(tables.rows.map((row) => row.name));
|
|
183
|
+
const missing = AUTH_TABLES.filter((table) => !names.has(table));
|
|
184
|
+
if (missing.length) {
|
|
185
|
+
return fail(`auth schema is missing: ${missing.join(', ')}`);
|
|
186
|
+
}
|
|
187
|
+
const owners = await d1Query(ctx, facts.authDbId, "SELECT count(*) AS n FROM editor WHERE role='owner'");
|
|
188
|
+
if ('fail' in owners)
|
|
189
|
+
return owners.fail;
|
|
190
|
+
const n = owners.rows[0]?.n;
|
|
191
|
+
if (typeof n === 'number' && n >= 1) {
|
|
192
|
+
return pass(`auth schema present with ${n} owner row(s)`);
|
|
193
|
+
}
|
|
194
|
+
return fail('the editor table holds no owner row');
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return fail(String(err));
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|