@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
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
- <!-- 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
 
@@ -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
- const node = syntaxTree(context.state).resolveInner(context.pos, -1);
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
- /** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
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
- const REGISTRY = {
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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,3 @@
1
+ import type { DoctorCheck } from './types.js';
2
+ /** Build the live-send check for one recipient address. */
3
+ export declare function liveSendCheck(to: string): DoctorCheck;
@@ -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,5 @@
1
+ import type { DoctorCheck } from './types.js';
2
+ export declare const emailSenderOnboarded: DoctorCheck;
3
+ export declare const edgeHttpsForced: DoctorCheck;
4
+ export declare const edgeHsts: DoctorCheck;
5
+ export declare const authStore: DoctorCheck;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { DoctorCheck } from './types.js';
2
+ export declare const githubApp: DoctorCheck;