@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/components/ConceptList.svelte +14 -5
  4. package/dist/components/EditPage.svelte +34 -10
  5. package/dist/components/EditorToolbar.svelte +4 -0
  6. package/dist/components/link-completion.js +10 -3
  7. package/dist/diagnostics/conditions.d.ts +8 -1
  8. package/dist/diagnostics/conditions.js +68 -1
  9. package/dist/doctor/bin.d.ts +2 -0
  10. package/dist/doctor/bin.js +44 -0
  11. package/dist/doctor/check-send.d.ts +3 -0
  12. package/dist/doctor/check-send.js +43 -0
  13. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  14. package/dist/doctor/checks-cloudflare.js +200 -0
  15. package/dist/doctor/checks-github.d.ts +2 -0
  16. package/dist/doctor/checks-github.js +57 -0
  17. package/dist/doctor/checks-local.d.ts +5 -0
  18. package/dist/doctor/checks-local.js +112 -0
  19. package/dist/doctor/cloudflare-api.d.ts +7 -0
  20. package/dist/doctor/cloudflare-api.js +24 -0
  21. package/dist/doctor/index.d.ts +23 -0
  22. package/dist/doctor/index.js +68 -0
  23. package/dist/doctor/report.d.ts +5 -0
  24. package/dist/doctor/report.js +21 -0
  25. package/dist/doctor/run.d.ts +8 -0
  26. package/dist/doctor/run.js +20 -0
  27. package/dist/doctor/types.d.ts +41 -0
  28. package/dist/doctor/types.js +10 -0
  29. package/dist/doctor/wrangler-config.d.ts +12 -0
  30. package/dist/doctor/wrangler-config.js +125 -0
  31. package/dist/github/signing.d.ts +3 -1
  32. package/dist/github/signing.js +13 -5
  33. package/dist/log/events.d.ts +1 -1
  34. package/dist/sveltekit/content-routes.js +19 -11
  35. package/package.json +6 -4
  36. package/src/lib/components/ConceptList.svelte +14 -5
  37. package/src/lib/components/EditPage.svelte +34 -10
  38. package/src/lib/components/EditorToolbar.svelte +4 -0
  39. package/src/lib/components/link-completion.ts +10 -3
  40. package/src/lib/diagnostics/conditions.ts +75 -2
  41. package/src/lib/doctor/bin.ts +45 -0
  42. package/src/lib/doctor/check-send.ts +43 -0
  43. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  44. package/src/lib/doctor/checks-github.ts +63 -0
  45. package/src/lib/doctor/checks-local.ts +119 -0
  46. package/src/lib/doctor/cloudflare-api.ts +33 -0
  47. package/src/lib/doctor/index.ts +93 -0
  48. package/src/lib/doctor/report.ts +30 -0
  49. package/src/lib/doctor/run.ts +23 -0
  50. package/src/lib/doctor/types.ts +52 -0
  51. package/src/lib/doctor/wrangler-config.ts +142 -0
  52. package/src/lib/github/signing.ts +13 -6
  53. package/src/lib/log/events.ts +1 -0
  54. package/src/lib/sveltekit/content-routes.ts +19 -10
@@ -72,8 +72,9 @@ export function createContentRoutes(runtime, deps = {}) {
72
72
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
73
73
  });
74
74
  }
75
- catch {
75
+ catch (err) {
76
76
  pendingEntries = null;
77
+ log.warn('github.unreachable', { scope: 'layout', error: String(err) });
77
78
  }
78
79
  return {
79
80
  siteName: runtime.siteName,
@@ -216,18 +217,22 @@ export function createContentRoutes(runtime, deps = {}) {
216
217
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
217
218
  const path = `${concept.dir}/${filenameFromId(id)}`;
218
219
  // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
219
- // (link targets and the inbound-link guard) always reads main, the authoritative copy, and a
220
- // pending entry adds a main read of its own path to derive its published state.
220
+ // (link targets and the inbound-link guard) always reads main, the authoritative copy.
221
+ // Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
222
+ // so the probe does not serialize ahead of the other two; stage 2 adds the branch read
223
+ // only when the probe found a branch, with the stage-1 main read serving as the published
224
+ // signal either way.
221
225
  const branch = pendingBranch(concept.id, id);
222
- const pending = (await branchHeadSha(runtime.backend, branch, token)) !== null;
223
- const [raw, manifestRaw, mainRaw] = await Promise.all([
224
- readRaw(pending ? { ...runtime.backend, branch } : runtime.backend, path, token),
226
+ const [headSha, mainRaw, manifestRaw] = await Promise.all([
227
+ branchHeadSha(runtime.backend, branch, token),
228
+ readRaw(runtime.backend, path, token),
225
229
  readRaw(runtime.backend, runtime.manifestPath, token),
226
- pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
227
230
  ]);
231
+ const pending = headSha !== null;
232
+ const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
228
233
  if (raw === null && !isNew)
229
234
  throw error(404, 'Entry not found');
230
- const published = pending ? mainRaw !== null : raw !== null;
235
+ const published = mainRaw !== null;
231
236
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
232
237
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
233
238
  let linkTargets = [];
@@ -450,11 +455,14 @@ export function createContentRoutes(runtime, deps = {}) {
450
455
  next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
451
456
  published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
452
457
  }
453
- if (published.length === 0)
454
- throw redirect(303, listPage);
458
+ if (published.length === 0) {
459
+ const message = 'Nothing to publish. Every entry is already live.';
460
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
461
+ }
455
462
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
463
+ const noun = published.length === 1 ? 'entry' : 'entries';
456
464
  try {
457
- await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} entries`, author: { name: editor.displayName, email: editor.email } }, token);
465
+ await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } }, token);
458
466
  for (const entry of published) {
459
467
  log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
460
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,9 +26,10 @@
26
26
  "markdown"
27
27
  ],
28
28
  "scripts": {
29
- "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js",
29
+ "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
30
30
  "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
31
31
  "check:reference": "npm run package && node scripts/reference-coverage.mjs",
32
+ "check:readiness": "npm run package && node scripts/check-readiness.mjs",
32
33
  "check:docs": "node scripts/docs-links.mjs",
33
34
  "check:prose": "node scripts/check-admin-prose.mjs",
34
35
  "prepare": "npm run package",
@@ -82,7 +83,8 @@
82
83
  "./package.json": "./package.json"
83
84
  },
84
85
  "bin": {
85
- "cairn-manifest": "./dist/vite/bin.js"
86
+ "cairn-manifest": "./dist/vite/bin.js",
87
+ "cairn-doctor": "./dist/doctor/bin.js"
86
88
  },
87
89
  "files": [
88
90
  "dist",
@@ -90,7 +92,7 @@
90
92
  "CHANGELOG.md"
91
93
  ],
92
94
  "peerDependencies": {
93
- "@sveltejs/kit": "^2",
95
+ "@sveltejs/kit": "^2.12",
94
96
  "svelte": "^5.0.0"
95
97
  },
96
98
  "dependencies": {
@@ -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
- <!-- A racing second admin can publish first, leaving this redirect counting zero; say nothing then. -->
126
- {#if data.publishedAll !== null && data.publishedAll > 0}
127
- <div role="status" class="alert alert-success mb-4 text-sm">
128
- Published {data.publishedAll} {data.publishedAll === 1 ? 'entry' : 'entries'}.
129
- </div>
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>. Re-read on
244
- // an entry change too, since a client-side navigation swaps the search string under this effect.
245
- let draftWarning = $state('');
246
- $effect(() => {
247
- void entryKey;
248
- const search = typeof location === 'undefined' ? '' : location.search;
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, so the count reads as the author's prose.
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
 
@@ -3,10 +3,14 @@
3
3
  // to CodeMirror's CompletionSource. The editor wires the source through a generic completionSources
4
4
  // prop, so this stays the only link-aware piece and the seam itself knows nothing about links.
5
5
  import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
6
- import { syntaxTree } from '@codemirror/language';
7
6
  import type { LinkTarget } from '../content/manifest.js';
8
7
  import { formatCairnToken, escapeLinkText } from '../content/links.js';
9
8
 
9
+ // EditPage imports this module statically, so a static @codemirror value import here would pull
10
+ // CodeMirror into a consumer's server bundle. syntaxTree resolves lazily inside the source
11
+ // instead (a CompletionSource may return a Promise), cached after the first completion.
12
+ let langMod: typeof import('@codemirror/language') | null = null;
13
+
10
14
  /** The known concepts in display order; an unlisted concept sorts after these under its own name. */
11
15
  const CONCEPT_SECTIONS: Record<string, { name: string; rank: number }> = {
12
16
  pages: { name: 'Pages', rank: 0 },
@@ -41,14 +45,17 @@ export function linkCompletions(targets: LinkTarget[], query: string): Completio
41
45
  * whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
42
46
  * filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
43
47
  export function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource {
44
- return (context: CompletionContext): CompletionResult | null => {
48
+ return async (context: CompletionContext): Promise<CompletionResult | null> => {
45
49
  const line = context.state.doc.lineAt(context.pos);
46
50
  const before = context.state.sliceDoc(line.from, context.pos);
47
51
  const trigger = matchCairnTrigger(before);
48
52
  if (!trigger) return null;
49
53
  // Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
50
54
  // the build resolver does not look inside code. The node name carries "Code" for both forms.
51
- const node = syntaxTree(context.state).resolveInner(context.pos, -1);
55
+ langMod ??= await import('@codemirror/language');
56
+ // The first completion awaits the import above, so the request may already be stale here.
57
+ if (context.aborted) return null;
58
+ const node = langMod.syntaxTree(context.state).resolveInner(context.pos, -1);
52
59
  for (let n: typeof node | null = node; n; n = n.parent) {
53
60
  if (/Code/.test(n.name)) return null;
54
61
  }
@@ -18,19 +18,27 @@ export interface CairnCondition {
18
18
  why: string;
19
19
  /** The fix, often a command. */
20
20
  remediation: string;
21
- /** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
21
+ /**
22
+ * The condition's section in the readiness checklist, written as
23
+ * 'cloudflare-readiness.md#<heading-slug>' so a doc can link it relative to docs/guides/.
24
+ * The check:readiness gate parses the part after '#' and asserts the heading exists; two
25
+ * conditions may share a section. Every entry carries one unless the gate's allowlist
26
+ * excuses it.
27
+ */
22
28
  docsAnchor?: string;
23
29
  /** The log vocabulary event this condition correlates with, if any. */
24
30
  logEvent?: CairnLogEvent;
25
31
  }
26
32
 
27
- const REGISTRY: Record<string, CairnCondition> = {
33
+ // Exported for the freeze test only; resolve entries through condition() everywhere else.
34
+ export const REGISTRY: Record<string, CairnCondition> = {
28
35
  'edge.https-not-forced': {
29
36
  id: 'edge.https-not-forced',
30
37
  severity: 'blocker',
31
38
  title: 'Always Use HTTPS is off',
32
39
  why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
33
40
  remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
41
+ docsAnchor: 'cloudflare-readiness.md#force-https-at-the-edge',
34
42
  logEvent: 'guard.rejected',
35
43
  },
36
44
  'auth.csrf-token-invalid': {
@@ -39,6 +47,7 @@ const REGISTRY: Record<string, CairnCondition> = {
39
47
  title: 'Admin CSRF token check failed',
40
48
  why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
41
49
  remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
50
+ docsAnchor: 'cloudflare-readiness.md#admin-csrf-token-rejected',
42
51
  logEvent: 'guard.rejected',
43
52
  },
44
53
  'auth.csrf-origin-mismatch': {
@@ -47,6 +56,7 @@ const REGISTRY: Record<string, CairnCondition> = {
47
56
  title: 'Non-admin form Origin rejected',
48
57
  why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
49
58
  remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
59
+ docsAnchor: 'cloudflare-readiness.md#non-admin-origin-rejected',
50
60
  logEvent: 'guard.rejected',
51
61
  },
52
62
  'email.sender-not-onboarded': {
@@ -55,6 +65,7 @@ const REGISTRY: Record<string, CairnCondition> = {
55
65
  title: 'Email sending domain is not onboarded',
56
66
  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.',
57
67
  remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
68
+ docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
58
69
  logEvent: 'auth.link.send_failed',
59
70
  },
60
71
  'email.send-failed': {
@@ -63,10 +74,72 @@ const REGISTRY: Record<string, CairnCondition> = {
63
74
  title: 'Magic-link email send failed',
64
75
  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.',
65
76
  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.',
77
+ docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
66
78
  logEvent: 'auth.link.send_failed',
67
79
  },
80
+ 'config.bindings-missing': {
81
+ id: 'config.bindings-missing',
82
+ severity: 'blocker',
83
+ title: 'Wrangler bindings are missing',
84
+ 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.',
85
+ 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.',
86
+ docsAnchor: 'cloudflare-readiness.md#deploy-the-worker-with-its-bindings',
87
+ },
88
+ 'config.observability-off': {
89
+ id: 'config.observability-off',
90
+ severity: 'warning',
91
+ title: 'Workers Logs has no sink',
92
+ 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.',
93
+ remediation: 'Set observability.enabled to true in wrangler.jsonc, then re-deploy.',
94
+ docsAnchor: 'cloudflare-readiness.md#turn-on-observability',
95
+ },
96
+ 'config.csrf-disable-missing': {
97
+ id: 'config.csrf-disable-missing',
98
+ severity: 'warning',
99
+ title: 'Framework CSRF check is not handed off',
100
+ 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.",
101
+ 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.",
102
+ docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
103
+ },
104
+ 'config.site-config-invalid': {
105
+ id: 'config.site-config-invalid',
106
+ severity: 'blocker',
107
+ title: 'Site config does not validate',
108
+ why: 'site.config.yaml fails to parse or fails the URL-policy validation, so the build and the admin cannot resolve the content concepts.',
109
+ remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
110
+ docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
111
+ },
112
+ 'edge.hsts-off': {
113
+ id: 'edge.hsts-off',
114
+ severity: 'warning',
115
+ title: 'HSTS is off',
116
+ 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.',
117
+ remediation: 'Turn on HSTS for the zone under SSL/TLS, Edge Certificates, with a max-age of at least six months.',
118
+ docsAnchor: 'cloudflare-readiness.md#turn-on-hsts',
119
+ },
120
+ 'auth.store-unreachable': {
121
+ id: 'auth.store-unreachable',
122
+ severity: 'blocker',
123
+ title: 'Auth store is unreachable',
124
+ 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.',
125
+ 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.',
126
+ docsAnchor: 'cloudflare-readiness.md#provision-the-auth-store',
127
+ },
128
+ 'github.app-unreachable': {
129
+ id: 'github.app-unreachable',
130
+ severity: 'blocker',
131
+ title: 'GitHub App is unreachable',
132
+ 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.',
133
+ 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.',
134
+ docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
135
+ logEvent: 'github.unreachable',
136
+ },
68
137
  };
69
138
 
139
+ // The registry is shared identity, never working state; freeze every entry and the map itself.
140
+ for (const entry of Object.values(REGISTRY)) Object.freeze(entry);
141
+ Object.freeze(REGISTRY);
142
+
70
143
  /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
71
144
  export function condition(id: string): CairnCondition {
72
145
  const found = REGISTRY[id];
@@ -0,0 +1,45 @@
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
+
13
+ async function main(): Promise<void> {
14
+ let args: ReturnType<typeof parseArgs>;
15
+ try {
16
+ args = parseArgs(process.argv.slice(2));
17
+ } catch (err) {
18
+ console.error(err instanceof Error ? err.message : String(err));
19
+ process.exitCode = 2;
20
+ return;
21
+ }
22
+
23
+ const cwd = process.cwd();
24
+ const ctx = {
25
+ ...contextFromEnv(process.env, args, cwd),
26
+ fetch: globalThis.fetch,
27
+ readFile: async (relPath: string): Promise<string | null> => {
28
+ try {
29
+ return await readFile(resolve(cwd, relPath), 'utf8');
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
32
+ throw err;
33
+ }
34
+ },
35
+ };
36
+
37
+ const checks = defaultChecks();
38
+ if (args.sendTest) checks.push(liveSendCheck(args.sendTest));
39
+
40
+ const { results, failed } = await runDoctor(checks, ctx);
41
+ console.log(formatReport(results));
42
+ process.exitCode = failed > 0 ? 1 : 0;
43
+ }
44
+
45
+ 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 type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
12
+ import { cfPost, NO_ACCOUNT, NO_FROM } from './cloudflare-api.js';
13
+
14
+ // Enough of an error body to act on without flooding the one-line report.
15
+ const EXCERPT_MAX = 200;
16
+
17
+ /** Build the live-send check for one recipient address. */
18
+ export function liveSendCheck(to: string): DoctorCheck {
19
+ return {
20
+ id: 'email.live-send',
21
+ conditionId: 'email.send-failed',
22
+ title: 'Live test send',
23
+ async run(ctx: DoctorContext): Promise<CheckResult> {
24
+ if (!ctx.cfToken || !ctx.cfAccountId) return NO_ACCOUNT;
25
+ if (!ctx.from) return NO_FROM;
26
+ try {
27
+ const res = await cfPost(ctx, `/accounts/${ctx.cfAccountId}/email/sending/send`, {
28
+ from: ctx.from,
29
+ to,
30
+ subject: 'cairn doctor test send',
31
+ text: 'This is a cairn doctor test send. Receiving it proves the sending path.',
32
+ });
33
+ if (!res.ok) {
34
+ const excerpt = (await res.text()).slice(0, EXCERPT_MAX);
35
+ return fail(`send returned ${res.status}: ${excerpt}`);
36
+ }
37
+ return pass(`sent to ${to}; check the inbox`);
38
+ } catch (err) {
39
+ return fail(String(err));
40
+ }
41
+ },
42
+ };
43
+ }