@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
@@ -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;
@@ -0,0 +1,57 @@
1
+ // The doctor's GitHub App check: the full reachability chain (the key parses and signs, the
2
+ // installation token mints, the repository answers a read), built from the engine's own
3
+ // credential and signing path so the doctor proves the exact code the save action runs. The
4
+ // signing chain is Web Crypto plus atob/btoa, all Node 20+ globals, so it runs in a CLI
5
+ // unchanged. One wrinkle the tests mirror: installationToken fetches through the global
6
+ // fetch, so only the repo read routes through ctx.fetch.
7
+ import { appCredentials } from '../github/credentials.js';
8
+ import { installationToken, signingSelfTest } from '../github/signing.js';
9
+ import { fail, pass, skip } from './types.js';
10
+ const API = 'https://api.github.com';
11
+ export const githubApp = {
12
+ id: 'github.app',
13
+ conditionId: 'github.app-unreachable',
14
+ title: 'GitHub App',
15
+ async run(ctx) {
16
+ if (!ctx.github) {
17
+ return skip('set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 to run this check');
18
+ }
19
+ if (!ctx.repo) {
20
+ return skip('pass --repo or set GITHUB_REPO to run this check');
21
+ }
22
+ const creds = appCredentials({ appId: ctx.github.appId, installationId: ctx.github.installationId }, { GITHUB_APP_PRIVATE_KEY_B64: ctx.github.privateKeyB64 });
23
+ // Stage 1: the key parse and sign, through the deploy-time self-test. Its detail is a
24
+ // fixed classifier, so a bad key can never echo key bytes into the report.
25
+ const signed = await signingSelfTest(creds.appId, creds.privateKeyB64);
26
+ if (!signed.ok) {
27
+ return fail(`the App key failed to parse or sign: ${signed.detail}`);
28
+ }
29
+ // Stage 2: the token mint, through the uncached primitive. A one-shot CLI gains nothing
30
+ // from the Worker-lifecycle cache, and the probe should reach GitHub for real.
31
+ let token;
32
+ try {
33
+ token = await installationToken(creds);
34
+ }
35
+ catch (err) {
36
+ return fail(`App authentication failed: ${String(err)}`);
37
+ }
38
+ // Stage 3: the repo read, with the engine's standard GitHub headers.
39
+ try {
40
+ const res = await ctx.fetch(`${API}/repos/${ctx.repo}`, {
41
+ headers: {
42
+ Accept: 'application/vnd.github+json',
43
+ Authorization: `Bearer ${token}`,
44
+ 'User-Agent': 'cairn-cms',
45
+ 'X-GitHub-Api-Version': '2022-11-28',
46
+ },
47
+ });
48
+ if (!res.ok) {
49
+ return fail(`repo ${ctx.repo} returned ${res.status}`);
50
+ }
51
+ return pass(`the App reads ${ctx.repo}`);
52
+ }
53
+ catch (err) {
54
+ return fail(String(err));
55
+ }
56
+ },
57
+ };
@@ -0,0 +1,5 @@
1
+ import type { DoctorCheck } from './types.js';
2
+ export declare const configBindings: DoctorCheck;
3
+ export declare const configObservability: DoctorCheck;
4
+ export declare const configCsrfDisable: DoctorCheck;
5
+ export declare const configSiteConfig: DoctorCheck;
@@ -0,0 +1,112 @@
1
+ // The doctor's local-config checks: the wrangler bindings, the observability sink, the
2
+ // svelte.config CSRF handoff, and the site-config validation. Every read goes through the
3
+ // injected ctx.readFile, so the tests pass fixtures and the bin passes node:fs.
4
+ import { fail, pass, skip } from './types.js';
5
+ import { readWranglerConfig } from './wrangler-config.js';
6
+ import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
7
+ import { normalizeConcepts } from '../content/concepts.js';
8
+ import { defineFields } from '../content/schema.js';
9
+ const NO_WRANGLER = skip('no wrangler.jsonc or wrangler.toml found');
10
+ export const configBindings = {
11
+ id: 'config.bindings',
12
+ conditionId: 'config.bindings-missing',
13
+ title: 'Wrangler bindings',
14
+ async run(ctx) {
15
+ const facts = await readWranglerConfig(ctx.readFile);
16
+ if (facts === null)
17
+ return NO_WRANGLER;
18
+ const missing = [];
19
+ if (!facts.hasEmailBinding)
20
+ missing.push('EMAIL (send_email)');
21
+ if (!facts.hasAuthDb)
22
+ missing.push('AUTH_DB (d1_databases)');
23
+ if (missing.length)
24
+ return fail(`missing ${missing.join(' and ')}`);
25
+ return pass('EMAIL and AUTH_DB are declared');
26
+ },
27
+ };
28
+ export const configObservability = {
29
+ id: 'config.observability',
30
+ conditionId: 'config.observability-off',
31
+ title: 'Workers Logs sink',
32
+ async run(ctx) {
33
+ const facts = await readWranglerConfig(ctx.readFile);
34
+ if (facts === null)
35
+ return NO_WRANGLER;
36
+ if (!facts.observabilityEnabled) {
37
+ return fail('observability.enabled is not true');
38
+ }
39
+ return pass('observability.enabled is true');
40
+ },
41
+ };
42
+ // A line whose trimmed start is a comment marker cannot disable anything, so a commented-out
43
+ // checkOrigin: false never green-lights the handoff.
44
+ function hasUncommentedDisable(text) {
45
+ return text.split('\n').some((line) => {
46
+ const trimmed = line.trimStart();
47
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
48
+ return false;
49
+ }
50
+ return /checkOrigin\s*:\s*false/.test(trimmed);
51
+ });
52
+ }
53
+ // The guard-wiring heuristic. The tutorial's hooks file imports createAuthGuard from
54
+ // @glw907/cairn-cms/sveltekit and hands it the exported handle, which the first clause matches
55
+ // directly; a site that wraps the guard in its own module still mentions cairn beside a handle.
56
+ function wiresCairnGuard(text) {
57
+ if (text.includes('@glw907/cairn-cms'))
58
+ return true;
59
+ return /cairn/i.test(text) && /handle/.test(text);
60
+ }
61
+ export const configCsrfDisable = {
62
+ id: 'config.csrf-disable',
63
+ conditionId: 'config.csrf-disable-missing',
64
+ title: 'Framework CSRF handoff',
65
+ async run(ctx) {
66
+ const text = await ctx.readFile('svelte.config.js');
67
+ if (text === null)
68
+ return skip('svelte.config.js not found');
69
+ if (!hasUncommentedDisable(text)) {
70
+ return fail('no checkOrigin: false found (heuristic text read)');
71
+ }
72
+ // The disable alone proves nothing: with the framework check off and no cairn guard in
73
+ // the hooks, the admin form POSTs have no CSRF protection at all. The pair is the check.
74
+ const hooks = (await ctx.readFile('src/hooks.server.ts')) ?? (await ctx.readFile('src/hooks.server.js'));
75
+ if (hooks === null || !wiresCairnGuard(hooks)) {
76
+ return fail('checkOrigin is off but no cairn guard found in src/hooks.server.ts; the site may have no CSRF protection');
77
+ }
78
+ return pass('checkOrigin: false found and the hooks file wires the cairn guard (heuristic text read)');
79
+ },
80
+ };
81
+ // Where sites keep site.config.yaml. The adapter's configPath is TypeScript the CLI cannot
82
+ // evaluate, so the check probes the conventional spots instead (the repo root and the two
83
+ // src locations the production sites use).
84
+ const SITE_CONFIG_PATHS = ['site.config.yaml', 'src/lib/site.config.yaml', 'src/site.config.yaml'];
85
+ export const configSiteConfig = {
86
+ id: 'config.site-config',
87
+ conditionId: 'config.site-config-invalid',
88
+ title: 'Site config',
89
+ async run(ctx) {
90
+ let text = null;
91
+ for (const path of SITE_CONFIG_PATHS) {
92
+ text = await ctx.readFile(path);
93
+ if (text !== null)
94
+ break;
95
+ }
96
+ if (text === null)
97
+ return skip(`no site.config.yaml found (looked in ${SITE_CONFIG_PATHS.join(', ')})`);
98
+ try {
99
+ const policy = urlPolicyFrom(parseSiteConfig(text));
100
+ // Run the engine's own URL-policy validation by declaring a synthetic empty concept
101
+ // per policy key. Routing is concept-fixed in the engine (CONCEPT_ROUTING, never the
102
+ // adapter), so the dated rules apply faithfully here. What a CLI cannot check without
103
+ // evaluating the adapter is whether each policy key names a concept the site declares.
104
+ const synthetic = Object.fromEntries(Object.keys(policy).map((id) => [id, { dir: '', schema: defineFields([]) }]));
105
+ normalizeConcepts(synthetic, policy);
106
+ return pass('parsed and URL policy validated (the adapter concept set is not checkable from the CLI)');
107
+ }
108
+ catch (err) {
109
+ return fail(err instanceof Error ? err.message : String(err));
110
+ }
111
+ },
112
+ };
@@ -0,0 +1,7 @@
1
+ import type { CheckResult, DoctorContext } from './types.js';
2
+ export declare const CF_API = "https://api.cloudflare.com/client/v4";
3
+ export declare const NO_TOKEN: CheckResult;
4
+ export declare const NO_FROM: CheckResult;
5
+ export declare const NO_ACCOUNT: CheckResult;
6
+ export declare function cfGet(ctx: DoctorContext, path: string): Promise<Response>;
7
+ export declare function cfPost(ctx: DoctorContext, path: string, body: unknown): Promise<Response>;
@@ -0,0 +1,24 @@
1
+ // Shared plumbing for the doctor's Cloudflare API probes: the API base, the bearer-token
2
+ // request helpers, and the skip results for the credentials the checks share. Every request
3
+ // goes through ctx.fetch with the operator's CLOUDFLARE_API_TOKEN, so the tests script the
4
+ // API and the bin passes global fetch.
5
+ import { skip } from './types.js';
6
+ export const CF_API = 'https://api.cloudflare.com/client/v4';
7
+ export const NO_TOKEN = skip('set CLOUDFLARE_API_TOKEN to run this check');
8
+ export const NO_FROM = skip('pass --from or set CAIRN_FROM to run this check');
9
+ export const NO_ACCOUNT = skip('set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to run this check');
10
+ export function cfGet(ctx, path) {
11
+ return ctx.fetch(`${CF_API}${path}`, {
12
+ headers: { authorization: `Bearer ${ctx.cfToken}` },
13
+ });
14
+ }
15
+ export function cfPost(ctx, path, body) {
16
+ return ctx.fetch(`${CF_API}${path}`, {
17
+ method: 'POST',
18
+ headers: {
19
+ authorization: `Bearer ${ctx.cfToken}`,
20
+ 'content-type': 'application/json',
21
+ },
22
+ body: JSON.stringify(body),
23
+ });
24
+ }
@@ -0,0 +1,23 @@
1
+ import type { DoctorCheck, DoctorContext } from './types.js';
2
+ export { runDoctor } from './run.js';
3
+ export { formatReport } from './report.js';
4
+ export interface DoctorArgs {
5
+ from?: string;
6
+ repo?: string;
7
+ sendTest?: string;
8
+ }
9
+ /** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
10
+ export declare function parseArgs(argv: string[]): DoctorArgs;
11
+ /**
12
+ * Build the doctor's context from the environment and the parsed flags. A flag beats its env
13
+ * variable, and github assembles only when the whole credential trio is present, so the GitHub
14
+ * check skips with one remediation line instead of failing on a partial setup. fetch and
15
+ * readFile stay with the bin, which injects the real ones.
16
+ */
17
+ export declare function contextFromEnv(env: Record<string, string | undefined>, args: DoctorArgs, cwd: string): Omit<DoctorContext, 'fetch' | 'readFile'>;
18
+ /**
19
+ * The default registry: the four local-config checks, the four Cloudflare checks, and the
20
+ * GitHub App chain. The live send is opt-in (--send-test) and never sits here; the bin appends
21
+ * it. A fresh array per call, so that append mutates nothing shared.
22
+ */
23
+ export declare function defaultChecks(): DoctorCheck[];
@@ -0,0 +1,68 @@
1
+ import { configBindings, configObservability, configCsrfDisable, configSiteConfig, } from './checks-local.js';
2
+ import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
3
+ import { githubApp } from './checks-github.js';
4
+ export { runDoctor } from './run.js';
5
+ export { formatReport } from './report.js';
6
+ const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>]';
7
+ const FLAGS = {
8
+ '--from': 'from',
9
+ '--repo': 'repo',
10
+ '--send-test': 'sendTest',
11
+ };
12
+ /** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
13
+ export function parseArgs(argv) {
14
+ const args = {};
15
+ for (let i = 0; i < argv.length; i += 2) {
16
+ const flag = argv[i];
17
+ const key = FLAGS[flag];
18
+ if (!key)
19
+ throw new Error(`unknown argument ${flag}\n${USAGE}`);
20
+ const value = argv[i + 1];
21
+ if (value === undefined || value.startsWith('--')) {
22
+ throw new Error(`${flag} needs a value\n${USAGE}`);
23
+ }
24
+ args[key] = value;
25
+ }
26
+ return args;
27
+ }
28
+ /**
29
+ * Build the doctor's context from the environment and the parsed flags. A flag beats its env
30
+ * variable, and github assembles only when the whole credential trio is present, so the GitHub
31
+ * check skips with one remediation line instead of failing on a partial setup. fetch and
32
+ * readFile stay with the bin, which injects the real ones.
33
+ */
34
+ export function contextFromEnv(env, args, cwd) {
35
+ const { GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY_B64 } = env;
36
+ return {
37
+ cwd,
38
+ from: args.from ?? env.CAIRN_FROM,
39
+ repo: args.repo ?? env.GITHUB_REPO,
40
+ cfToken: env.CLOUDFLARE_API_TOKEN,
41
+ cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
42
+ github: GITHUB_APP_ID && GITHUB_APP_INSTALLATION_ID && GITHUB_APP_PRIVATE_KEY_B64
43
+ ? {
44
+ appId: GITHUB_APP_ID,
45
+ installationId: GITHUB_APP_INSTALLATION_ID,
46
+ privateKeyB64: GITHUB_APP_PRIVATE_KEY_B64,
47
+ }
48
+ : undefined,
49
+ };
50
+ }
51
+ /**
52
+ * The default registry: the four local-config checks, the four Cloudflare checks, and the
53
+ * GitHub App chain. The live send is opt-in (--send-test) and never sits here; the bin appends
54
+ * it. A fresh array per call, so that append mutates nothing shared.
55
+ */
56
+ export function defaultChecks() {
57
+ return [
58
+ configBindings,
59
+ configObservability,
60
+ configCsrfDisable,
61
+ configSiteConfig,
62
+ emailSenderOnboarded,
63
+ edgeHttpsForced,
64
+ edgeHsts,
65
+ authStore,
66
+ githubApp,
67
+ ];
68
+ }
@@ -0,0 +1,5 @@
1
+ import type { CheckResult, DoctorCheck } from './types.js';
2
+ export declare function formatReport(results: {
3
+ check: DoctorCheck;
4
+ result: CheckResult;
5
+ }[]): string;
@@ -0,0 +1,21 @@
1
+ // The doctor's report: one aligned line per check, then a why/remediation block per failure
2
+ // resolved from the condition registry, then a count summary. Plain text, no ANSI color, so the
3
+ // output reads the same in a terminal and a CI log. An unknown conditionId is a programming
4
+ // error; condition() throws and the report does not paper over it.
5
+ import { condition } from '../diagnostics/index.js';
6
+ const TAG = {
7
+ pass: 'PASS',
8
+ fail: 'FAIL',
9
+ skip: 'SKIP',
10
+ };
11
+ export function formatReport(results) {
12
+ const lines = results.map(({ check, result }) => `${TAG[result.status]} ${check.title}: ${result.detail}`);
13
+ const failures = results.filter(({ result }) => result.status === 'fail');
14
+ for (const { check } of failures) {
15
+ const entry = condition(check.conditionId);
16
+ lines.push('', `${check.title} failed.`, ` Why: ${entry.why}`, ` Fix: ${entry.remediation}`);
17
+ }
18
+ const count = (status) => results.filter(({ result }) => result.status === status).length;
19
+ lines.push('', `${count('pass')} passed, ${count('fail')} failed, ${count('skip')} skipped`);
20
+ return lines.join('\n');
21
+ }
@@ -0,0 +1,8 @@
1
+ import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
2
+ export declare function runDoctor(checks: DoctorCheck[], ctx: DoctorContext): Promise<{
3
+ results: {
4
+ check: DoctorCheck;
5
+ result: CheckResult;
6
+ }[];
7
+ failed: number;
8
+ }>;
@@ -0,0 +1,20 @@
1
+ // The doctor's runner: every check executes, every result lands in the table. A throwing check
2
+ // records a fail and the run continues, so one broken probe never hides the rest of the picture.
3
+ import { fail } from './types.js';
4
+ export async function runDoctor(checks, ctx) {
5
+ const results = [];
6
+ let failed = 0;
7
+ for (const check of checks) {
8
+ let result;
9
+ try {
10
+ result = await check.run(ctx);
11
+ }
12
+ catch (err) {
13
+ result = fail(String(err));
14
+ }
15
+ if (result.status === 'fail')
16
+ failed += 1;
17
+ results.push({ check, result });
18
+ }
19
+ return { results, failed };
20
+ }
@@ -0,0 +1,41 @@
1
+ export type CheckStatus = 'pass' | 'fail' | 'skip';
2
+ export interface CheckResult {
3
+ status: CheckStatus;
4
+ /** One line of evidence ("sending subdomain enabled", "wrangler.jsonc not found"). */
5
+ detail: string;
6
+ }
7
+ /** Result constructors, so a check body reads one outcome per line instead of object literals. */
8
+ export declare function pass(detail: string): CheckResult;
9
+ export declare function fail(detail: string): CheckResult;
10
+ export declare function skip(detail: string): CheckResult;
11
+ export interface DoctorCheck {
12
+ /** Stable id, e.g. 'email.sender-onboarded'. */
13
+ id: string;
14
+ /** The registry condition this check probes; the report prints its remediation on failure. */
15
+ conditionId: string;
16
+ title: string;
17
+ run: (ctx: DoctorContext) => Promise<CheckResult>;
18
+ }
19
+ /** Everything a check may read, resolved once by the bin. Absent fields make checks skip. */
20
+ export interface DoctorContext {
21
+ /** The site directory the doctor runs in. */
22
+ cwd: string;
23
+ /** The from-address (--from / CAIRN_FROM). */
24
+ from?: string;
25
+ /** owner/name (--repo / GITHUB_REPO). */
26
+ repo?: string;
27
+ /** CLOUDFLARE_API_TOKEN. */
28
+ cfToken?: string;
29
+ /** CLOUDFLARE_ACCOUNT_ID. */
30
+ cfAccountId?: string;
31
+ /** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
32
+ github?: {
33
+ appId: string;
34
+ installationId: string;
35
+ privateKeyB64: string;
36
+ };
37
+ /** Injected fetch for tests; defaults to global fetch. */
38
+ fetch: typeof fetch;
39
+ /** Read a file under cwd, or null when absent. Injected for tests. */
40
+ readFile: (relPath: string) => Promise<string | null>;
41
+ }