@glw907/cairn-cms 0.40.0 → 0.50.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 (156) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -3
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +18 -10
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +47 -19
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/EditorToolbar.svelte +4 -0
  14. package/dist/components/LoginPage.svelte +2 -2
  15. package/dist/components/LoginPage.svelte.d.ts +1 -1
  16. package/dist/components/ManageEditors.svelte +4 -3
  17. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +1 -0
  20. package/dist/components/link-completion.js +10 -3
  21. package/dist/components/markdown-format.d.ts +0 -8
  22. package/dist/components/markdown-format.js +0 -28
  23. package/dist/content/links.d.ts +8 -0
  24. package/dist/content/links.js +28 -0
  25. package/dist/content/types.d.ts +2 -2
  26. package/dist/delivery/data.d.ts +3 -5
  27. package/dist/delivery/data.js +2 -3
  28. package/dist/delivery/feeds.js +1 -7
  29. package/dist/delivery/index.d.ts +2 -2
  30. package/dist/delivery/index.js +1 -1
  31. package/dist/delivery/manifest.d.ts +0 -5
  32. package/dist/delivery/manifest.js +5 -16
  33. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  34. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  35. package/dist/delivery/site-indexes.d.ts +3 -3
  36. package/dist/delivery/site-indexes.js +3 -3
  37. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  38. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  39. package/dist/delivery/sitemap.js +1 -3
  40. package/dist/delivery/xml.d.ts +2 -0
  41. package/dist/delivery/xml.js +11 -0
  42. package/dist/diagnostics/conditions.d.ts +8 -1
  43. package/dist/diagnostics/conditions.js +68 -1
  44. package/dist/doctor/bin.d.ts +2 -0
  45. package/dist/doctor/bin.js +44 -0
  46. package/dist/doctor/check-send.d.ts +3 -0
  47. package/dist/doctor/check-send.js +43 -0
  48. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  49. package/dist/doctor/checks-cloudflare.js +200 -0
  50. package/dist/doctor/checks-github.d.ts +2 -0
  51. package/dist/doctor/checks-github.js +57 -0
  52. package/dist/doctor/checks-local.d.ts +5 -0
  53. package/dist/doctor/checks-local.js +112 -0
  54. package/dist/doctor/cloudflare-api.d.ts +7 -0
  55. package/dist/doctor/cloudflare-api.js +24 -0
  56. package/dist/doctor/index.d.ts +23 -0
  57. package/dist/doctor/index.js +68 -0
  58. package/dist/doctor/report.d.ts +5 -0
  59. package/dist/doctor/report.js +21 -0
  60. package/dist/doctor/run.d.ts +8 -0
  61. package/dist/doctor/run.js +20 -0
  62. package/dist/doctor/types.d.ts +41 -0
  63. package/dist/doctor/types.js +10 -0
  64. package/dist/doctor/wrangler-config.d.ts +12 -0
  65. package/dist/doctor/wrangler-config.js +125 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +1 -1
  68. package/dist/env.js +3 -2
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/signing.d.ts +3 -1
  74. package/dist/github/signing.js +13 -5
  75. package/dist/github/types.d.ts +2 -0
  76. package/dist/github/types.js +4 -0
  77. package/dist/log/events.d.ts +1 -1
  78. package/dist/nav/site-config.d.ts +2 -0
  79. package/dist/nav/site-config.js +2 -0
  80. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  81. package/dist/sveltekit/admin-dispatch.js +62 -0
  82. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  83. package/dist/sveltekit/cairn-admin.js +126 -0
  84. package/dist/sveltekit/condition-response.d.ts +1 -0
  85. package/dist/sveltekit/condition-response.js +25 -0
  86. package/dist/sveltekit/content-routes.d.ts +34 -14
  87. package/dist/sveltekit/content-routes.js +78 -44
  88. package/dist/sveltekit/guard.js +15 -3
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +19 -10
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/package.json +10 -4
  98. package/src/lib/ambient.ts +19 -0
  99. package/src/lib/components/AdminLayout.svelte +6 -8
  100. package/src/lib/components/CairnAdmin.svelte +67 -0
  101. package/src/lib/components/ConceptList.svelte +18 -10
  102. package/src/lib/components/ConfirmPage.svelte +1 -1
  103. package/src/lib/components/EditPage.svelte +47 -19
  104. package/src/lib/components/EditorToolbar.svelte +4 -0
  105. package/src/lib/components/LoginPage.svelte +2 -2
  106. package/src/lib/components/ManageEditors.svelte +4 -3
  107. package/src/lib/components/index.ts +1 -0
  108. package/src/lib/components/link-completion.ts +10 -3
  109. package/src/lib/components/markdown-format.ts +0 -27
  110. package/src/lib/content/links.ts +28 -0
  111. package/src/lib/content/types.ts +2 -2
  112. package/src/lib/delivery/data.ts +3 -5
  113. package/src/lib/delivery/feeds.ts +1 -8
  114. package/src/lib/delivery/index.ts +2 -2
  115. package/src/lib/delivery/manifest.ts +5 -18
  116. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  117. package/src/lib/delivery/site-indexes.ts +6 -6
  118. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  119. package/src/lib/delivery/sitemap.ts +1 -4
  120. package/src/lib/delivery/xml.ts +12 -0
  121. package/src/lib/diagnostics/conditions.ts +75 -2
  122. package/src/lib/doctor/bin.ts +45 -0
  123. package/src/lib/doctor/check-send.ts +43 -0
  124. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  125. package/src/lib/doctor/checks-github.ts +63 -0
  126. package/src/lib/doctor/checks-local.ts +119 -0
  127. package/src/lib/doctor/cloudflare-api.ts +33 -0
  128. package/src/lib/doctor/index.ts +93 -0
  129. package/src/lib/doctor/report.ts +30 -0
  130. package/src/lib/doctor/run.ts +23 -0
  131. package/src/lib/doctor/types.ts +52 -0
  132. package/src/lib/doctor/wrangler-config.ts +142 -0
  133. package/src/lib/email.ts +4 -11
  134. package/src/lib/env.ts +3 -2
  135. package/src/lib/escape.ts +12 -0
  136. package/src/lib/github/credentials.ts +6 -2
  137. package/src/lib/github/signing.ts +13 -6
  138. package/src/lib/github/types.ts +5 -0
  139. package/src/lib/log/events.ts +2 -0
  140. package/src/lib/nav/site-config.ts +3 -0
  141. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  142. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  143. package/src/lib/sveltekit/condition-response.ts +27 -1
  144. package/src/lib/sveltekit/content-routes.ts +121 -55
  145. package/src/lib/sveltekit/guard.ts +16 -3
  146. package/src/lib/sveltekit/https-required-page.ts +2 -1
  147. package/src/lib/sveltekit/index.ts +6 -0
  148. package/src/lib/sveltekit/nav-routes.ts +21 -11
  149. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  150. package/src/lib/sveltekit/types.ts +16 -7
  151. package/dist/delivery/paginate.d.ts +0 -12
  152. package/dist/delivery/paginate.js +0 -20
  153. package/dist/render/index.d.ts +0 -5
  154. package/dist/render/index.js +0 -8
  155. package/src/lib/delivery/paginate.ts +0 -32
  156. package/src/lib/render/index.ts +0 -8
@@ -1,20 +1,20 @@
1
- // cairn-cms: public route loaders (dated-slug design). The factory closes over the site-level
2
- // index, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
1
+ // cairn-cms: public route loaders (dated-slug design). The factory closes over the site
2
+ // resolver, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
3
3
  // `[...path]` route resolves any concept by request path through `byPermalink`. The archive, tag,
4
- // and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
5
- // from globs, so it stays in the prerender graph and out of the runtime Worker.
4
+ // and tag-index loaders stay concept-scoped, keyed by concept id. The resolver is built in site
5
+ // code from globs, so it stays in the prerender graph and out of the runtime Worker.
6
6
  import { error } from '@sveltejs/kit';
7
- import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
8
- import type { SiteIndex } from '../delivery/site-index.js';
9
- import { buildSeoMeta } from '../delivery/seo.js';
10
- import type { SeoMeta } from '../delivery/seo.js';
11
- import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
12
- import { buildLinkResolver } from '../delivery/manifest.js';
7
+ import type { ContentSummary, ContentEntry } from './content-index.js';
8
+ import type { SiteResolver } from './site-resolver.js';
9
+ import { buildSeoMeta } from './seo.js';
10
+ import type { SeoMeta } from './seo.js';
11
+ import { readSeoFields, resolveImageUrl } from './seo-fields.js';
12
+ import { buildLinkResolver } from './site-resolver.js';
13
13
  import type { LinkResolve } from '../content/links.js';
14
14
 
15
15
  /** Injected dependencies for the public loaders. */
16
16
  export interface PublicRoutesDeps {
17
- site: SiteIndex;
17
+ site: SiteResolver;
18
18
  render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
19
19
  origin: string;
20
20
  /** Site name for og:site_name and the SEO head. */
@@ -1,7 +1,7 @@
1
1
  // cairn-cms: the full-auto typed site index (schema-source-of-truth design). It maps over a
2
2
  // defineAdapter-typed adapter to give one typed per-concept index, with frontmatter typed as the
3
3
  // concept's inferred schema type, plus a site resolver for the catch-all route. It is the typed
4
- // convenience over createContentIndex and createSiteIndex, not a replacement: both stay the
4
+ // convenience over createContentIndex and createSiteResolver, not a replacement: both stay the
5
5
  // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
6
  // bundle stays backend-free.
7
7
  import type { CairnAdapter, ConceptConfig } from '../content/types.js';
@@ -9,9 +9,9 @@ import type { Infer } from '../content/schema.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
10
  import { siteDescriptors } from './site-descriptors.js';
11
11
  import { createContentIndex, fromGlob } from './content-index.js';
12
- import { createSiteIndex } from './site-index.js';
12
+ import { createSiteResolver } from './site-resolver.js';
13
13
  import type { ContentIndex } from './content-index.js';
14
- import type { ConceptIndex, SiteIndex } from './site-index.js';
14
+ import type { ConceptIndex, SiteResolver } from './site-resolver.js';
15
15
 
16
16
  /** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
17
17
  export type SiteGlobs<A extends CairnAdapter> = {
@@ -24,13 +24,13 @@ export type SiteIndexes<A extends CairnAdapter> = {
24
24
  [K in keyof A['content']]: ContentIndex<
25
25
  NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
26
26
  >;
27
- } & { readonly site: SiteIndex };
27
+ } & { readonly site: SiteResolver };
28
28
 
29
29
  /**
30
30
  * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
31
31
  * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
32
32
  * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
33
- * of the build gate, exactly as on `createSiteIndex`.
33
+ * of the build gate, exactly as on `createSiteResolver`.
34
34
  */
35
35
  export function createSiteIndexes<const A extends CairnAdapter>(
36
36
  adapter: A,
@@ -59,6 +59,6 @@ export function createSiteIndexes<const A extends CairnAdapter>(
59
59
  byConcept[descriptor.id] = index;
60
60
  conceptIndexes.push({ descriptor, index });
61
61
  }
62
- const site = createSiteIndex(conceptIndexes, opts);
62
+ const site = createSiteResolver(conceptIndexes, opts);
63
63
  return { ...byConcept, site } as SiteIndexes<A>;
64
64
  }
@@ -1,9 +1,11 @@
1
- // cairn-cms: the site-level content index (dated-slug design). It unions every concept's
2
- // per-concept index into one cross-concept resolver: a single byPermalink map a catch-all route
3
- // matches a request path against, one entries() list the prerenderer walks, and the per-concept
4
- // indexes for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
1
+ // cairn-cms: the cross-concept site resolver (dated-slug design). It unions every concept's
2
+ // per-concept index into one resolver: a single byPermalink map a catch-all route matches a
3
+ // request path against, one entries() list the prerenderer walks, and the per-concept indexes
4
+ // for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
5
+ // buildLinkResolver lives here too, since it closes over the resolver.
5
6
  import type { ConceptDescriptor } from '../content/types.js';
6
7
  import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
8
+ import type { LinkResolve } from '../content/links.js';
7
9
 
8
10
  /** One concept's descriptor paired with its built index. */
9
11
  export interface ConceptIndex {
@@ -12,7 +14,7 @@ export interface ConceptIndex {
12
14
  }
13
15
 
14
16
  /** The cross-concept query surface a catch-all route and the sitemap read. */
15
- export interface SiteIndex {
17
+ export interface SiteResolver {
16
18
  /** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
17
19
  byPermalink(path: string): ContentEntry | undefined;
18
20
  /** Newer/older neighbors within the entry's own concept, for prev/next links. */
@@ -49,11 +51,11 @@ function siteProblems(concepts: ConceptIndex[]): string[] {
49
51
  * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
50
52
  * validator, so malformed content fails the build instead of shipping.
51
53
  */
52
- export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteIndex {
54
+ export function createSiteResolver(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteResolver {
53
55
  if (opts.validate !== false) {
54
56
  const problems = siteProblems(concepts);
55
57
  if (problems.length > 0) {
56
- throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
58
+ throw new Error(`site resolver: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
57
59
  }
58
60
  }
59
61
  const byPath = new Map<string, { index: ContentIndex; id: string }>();
@@ -64,7 +66,7 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
64
66
  const existing = byPath.get(summary.permalink);
65
67
  if (existing) {
66
68
  throw new Error(
67
- `site index: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
69
+ `site resolver: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
68
70
  );
69
71
  }
70
72
  byPath.set(summary.permalink, { index, id: summary.id });
@@ -90,3 +92,13 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
90
92
  },
91
93
  };
92
94
  }
95
+
96
+ /** A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
97
+ * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
98
+ export function buildLinkResolver(site: SiteResolver): LinkResolve {
99
+ return (ref) => {
100
+ const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
101
+ if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
102
+ return url;
103
+ };
104
+ }
@@ -1,5 +1,6 @@
1
1
  // cairn-cms: sitemap builder (public-delivery design). Pure over a URL list; the caller
2
2
  // derives the list from the content index and the routable concepts.
3
+ import { escapeXml } from './xml.js';
3
4
 
4
5
  /** One sitemap URL. `lastmod` is a YYYY-MM-DD date. */
5
6
  export interface SitemapUrl {
@@ -7,10 +8,6 @@ export interface SitemapUrl {
7
8
  lastmod?: string;
8
9
  }
9
10
 
10
- function escapeXml(value: string): string {
11
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
12
- }
13
-
14
11
  /** Build a sitemap XML document from a list of URLs. */
15
12
  export function buildSitemap(urls: SitemapUrl[]): string {
16
13
  const entries = urls
@@ -0,0 +1,12 @@
1
+ // cairn-cms: the one XML text escape the feed and sitemap builders share. The strongest of the
2
+ // two copies it replaced (the old sitemap copy skipped quotes), so both documents stay safe in
3
+ // element text and double-quoted attributes.
4
+
5
+ /** Escape the XML-significant characters for element text and double-quoted attribute values. */
6
+ export function escapeXml(value: string): string {
7
+ return value
8
+ .replaceAll('&', '&amp;')
9
+ .replaceAll('<', '&lt;')
10
+ .replaceAll('>', '&gt;')
11
+ .replaceAll('"', '&quot;');
12
+ }
@@ -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
+ }
@@ -0,0 +1,222 @@
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 type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
20
+ import { cfGet, cfPost, NO_ACCOUNT, NO_FROM, NO_TOKEN } from './cloudflare-api.js';
21
+ import { readWranglerConfig } from './wrangler-config.js';
22
+
23
+ // 30 days. The production zones run two years; anything under a month is a trivial pin.
24
+ const MIN_HSTS_MAX_AGE = 2592000;
25
+
26
+ function fromDomain(from: string): string {
27
+ return from.slice(from.indexOf('@') + 1);
28
+ }
29
+
30
+ // The registrable domain is taken as the last two labels of the from-domain. A deliberate
31
+ // simplification: correct for the single-label public suffixes cairn targets (.ski, .life),
32
+ // wrong for multi-part suffixes like .co.uk, which would need a public-suffix list the
33
+ // doctor does not carry.
34
+ function registrableDomain(domain: string): string {
35
+ return domain.split('.').slice(-2).join('.');
36
+ }
37
+
38
+ // A 401/403 means the token cannot make this read at all, so the product condition's
39
+ // remediation (onboard the domain, fix the binding) would point the operator at the wrong fix.
40
+ // The conditionId stays; the detail carries the scope truth.
41
+ function permissionFail(status: number, scope: string): CheckResult | null {
42
+ if (status !== 401 && status !== 403) return null;
43
+ return fail(
44
+ `the API token lacks permission for this read (HTTP ${status}); grant the token ${scope} access`
45
+ );
46
+ }
47
+
48
+ async function resolveZoneId(
49
+ ctx: DoctorContext,
50
+ domain: string
51
+ ): Promise<{ zoneId: string } | { fail: CheckResult }> {
52
+ // The from-domain may be its own Cloudflare zone (mail.example.com registered directly), so
53
+ // the exact name is tried first and the registrable domain is the fallback.
54
+ const apex = registrableDomain(domain);
55
+ const names = domain === apex ? [domain] : [domain, apex];
56
+ for (const name of names) {
57
+ const res = await cfGet(ctx, `/zones?name=${encodeURIComponent(name)}`);
58
+ if (!res.ok) {
59
+ return { fail: fail(`zone lookup for ${name} returned ${res.status}`) };
60
+ }
61
+ const body = (await res.json()) as { result?: { id?: string }[] };
62
+ const id = body.result?.[0]?.id;
63
+ if (typeof id === 'string') return { zoneId: id };
64
+ }
65
+ return { fail: fail(`no zone named ${names.join(' or ')} is visible to this token`) };
66
+ }
67
+
68
+ /** Resolve the domain's zone and read one of its settings, returning `result.value`. */
69
+ async function readZoneSetting<T>(
70
+ ctx: DoctorContext,
71
+ domain: string,
72
+ settingId: string
73
+ ): Promise<{ value: T | undefined } | { fail: CheckResult }> {
74
+ const zone = await resolveZoneId(ctx, domain);
75
+ if ('fail' in zone) return zone;
76
+ const res = await cfGet(ctx, `/zones/${zone.zoneId}/settings/${settingId}`);
77
+ if (!res.ok) {
78
+ return { fail: fail(`${settingId} read returned ${res.status}`) };
79
+ }
80
+ const body = (await res.json()) as { result?: { value?: T } };
81
+ return { value: body.result?.value };
82
+ }
83
+
84
+ export const emailSenderOnboarded: DoctorCheck = {
85
+ id: 'email.sender-onboarded',
86
+ conditionId: 'email.sender-not-onboarded',
87
+ title: 'Email sending domain',
88
+ async run(ctx: DoctorContext): Promise<CheckResult> {
89
+ if (!ctx.cfToken) return NO_TOKEN;
90
+ if (!ctx.from) return NO_FROM;
91
+ const domain = fromDomain(ctx.from);
92
+ try {
93
+ const zone = await resolveZoneId(ctx, domain);
94
+ if ('fail' in zone) return zone.fail;
95
+ const res = await cfGet(ctx, `/zones/${zone.zoneId}/email/sending/subdomains`);
96
+ if (!res.ok) {
97
+ return (
98
+ permissionFail(res.status, 'Email Sending: Read') ??
99
+ fail(`sending subdomain list returned ${res.status}`)
100
+ );
101
+ }
102
+ const body = (await res.json()) as { result?: { name?: string; enabled?: boolean }[] };
103
+ const entry = body.result?.find((s) => s.name === domain);
104
+ if (entry?.enabled === true) {
105
+ return pass(`${domain} has an enabled sending subdomain`);
106
+ }
107
+ if (entry) {
108
+ return fail(`${domain} is onboarded but sending is disabled`);
109
+ }
110
+ return fail(`${domain} has no sending subdomain on the zone`);
111
+ } catch (err) {
112
+ return fail(String(err));
113
+ }
114
+ },
115
+ };
116
+
117
+ export const edgeHttpsForced: DoctorCheck = {
118
+ id: 'edge.https-forced',
119
+ conditionId: 'edge.https-not-forced',
120
+ title: 'Always Use HTTPS',
121
+ async run(ctx: DoctorContext): Promise<CheckResult> {
122
+ if (!ctx.cfToken) return NO_TOKEN;
123
+ if (!ctx.from) return NO_FROM;
124
+ try {
125
+ const setting = await readZoneSetting<string>(ctx, fromDomain(ctx.from), 'always_use_https');
126
+ if ('fail' in setting) return setting.fail;
127
+ if (setting.value === 'on') {
128
+ return pass('Always Use HTTPS is on');
129
+ }
130
+ return fail(`always_use_https is ${setting.value ?? 'unreadable'}`);
131
+ } catch (err) {
132
+ return fail(String(err));
133
+ }
134
+ },
135
+ };
136
+
137
+ interface SecurityHeaderValue {
138
+ strict_transport_security?: { enabled?: boolean; max_age?: number };
139
+ }
140
+
141
+ export const edgeHsts: DoctorCheck = {
142
+ id: 'edge.hsts',
143
+ conditionId: 'edge.hsts-off',
144
+ title: 'HSTS',
145
+ async run(ctx: DoctorContext): Promise<CheckResult> {
146
+ if (!ctx.cfToken) return NO_TOKEN;
147
+ if (!ctx.from) return NO_FROM;
148
+ try {
149
+ const setting = await readZoneSetting<SecurityHeaderValue>(
150
+ ctx,
151
+ fromDomain(ctx.from),
152
+ 'security_header'
153
+ );
154
+ if ('fail' in setting) return setting.fail;
155
+ const sts = setting.value?.strict_transport_security;
156
+ if (sts?.enabled !== true) {
157
+ return fail('HSTS is disabled on the zone');
158
+ }
159
+ const maxAge = sts.max_age ?? 0;
160
+ if (maxAge < MIN_HSTS_MAX_AGE) {
161
+ return fail(`HSTS max-age ${maxAge} is under the ${MIN_HSTS_MAX_AGE} (30 day) floor`);
162
+ }
163
+ return pass(`HSTS enabled with max-age ${maxAge}`);
164
+ } catch (err) {
165
+ return fail(String(err));
166
+ }
167
+ },
168
+ };
169
+
170
+ const AUTH_TABLES = ['editor', 'magic_token', 'session'];
171
+
172
+ async function d1Query(
173
+ ctx: DoctorContext,
174
+ databaseId: string,
175
+ sql: string
176
+ ): Promise<{ rows: Record<string, unknown>[] } | { fail: CheckResult }> {
177
+ const res = await cfPost(
178
+ ctx,
179
+ `/accounts/${ctx.cfAccountId}/d1/database/${encodeURIComponent(databaseId)}/query`,
180
+ { sql }
181
+ );
182
+ if (!res.ok) {
183
+ return {
184
+ fail:
185
+ permissionFail(res.status, 'D1: Read') ??
186
+ fail(`AUTH_DB is unreachable: the query returned ${res.status}`),
187
+ };
188
+ }
189
+ const body = (await res.json()) as { result?: { results?: Record<string, unknown>[] }[] };
190
+ return { rows: body.result?.[0]?.results ?? [] };
191
+ }
192
+
193
+ export const authStore: DoctorCheck = {
194
+ id: 'auth.store',
195
+ conditionId: 'auth.store-unreachable',
196
+ title: 'Auth store (D1)',
197
+ async run(ctx: DoctorContext): Promise<CheckResult> {
198
+ if (!ctx.cfToken || !ctx.cfAccountId) return NO_ACCOUNT;
199
+ const facts = await readWranglerConfig(ctx.readFile);
200
+ if (typeof facts?.authDbId !== 'string') {
201
+ return skip('no AUTH_DB database_id in wrangler.jsonc or wrangler.toml');
202
+ }
203
+ try {
204
+ const tables = await d1Query(ctx, facts.authDbId, "SELECT name FROM sqlite_master WHERE type='table'");
205
+ if ('fail' in tables) return tables.fail;
206
+ const names = new Set(tables.rows.map((row) => row.name));
207
+ const missing = AUTH_TABLES.filter((table) => !names.has(table));
208
+ if (missing.length) {
209
+ return fail(`auth schema is missing: ${missing.join(', ')}`);
210
+ }
211
+ const owners = await d1Query(ctx, facts.authDbId, "SELECT count(*) AS n FROM editor WHERE role='owner'");
212
+ if ('fail' in owners) return owners.fail;
213
+ const n = owners.rows[0]?.n;
214
+ if (typeof n === 'number' && n >= 1) {
215
+ return pass(`auth schema present with ${n} owner row(s)`);
216
+ }
217
+ return fail('the editor table holds no owner row');
218
+ } catch (err) {
219
+ return fail(String(err));
220
+ }
221
+ },
222
+ };