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