@glw907/cairn-cms 0.41.0 → 0.51.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 (162) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +2 -2
  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 +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +107 -25
  12. package/dist/components/EditPage.svelte.d.ts +8 -10
  13. package/dist/components/EditorToolbar.svelte +79 -8
  14. package/dist/components/EditorToolbar.svelte.d.ts +10 -2
  15. package/dist/components/LoginPage.svelte +2 -2
  16. package/dist/components/LoginPage.svelte.d.ts +1 -1
  17. package/dist/components/ManageEditors.svelte +4 -3
  18. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  19. package/dist/components/MarkdownEditor.svelte +20 -2
  20. package/dist/components/cairn-admin.css +57 -9
  21. package/dist/components/editor-highlight.d.ts +1 -0
  22. package/dist/components/editor-highlight.js +31 -8
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/markdown-directives.d.ts +10 -0
  26. package/dist/components/markdown-directives.js +54 -1
  27. package/dist/components/markdown-format.d.ts +0 -8
  28. package/dist/components/markdown-format.js +0 -28
  29. package/dist/components/preview-doc.d.ts +27 -0
  30. package/dist/components/preview-doc.js +64 -0
  31. package/dist/content/compose.js +1 -0
  32. package/dist/content/links.d.ts +8 -0
  33. package/dist/content/links.js +28 -0
  34. package/dist/content/types.d.ts +35 -2
  35. package/dist/delivery/data.d.ts +3 -5
  36. package/dist/delivery/data.js +2 -3
  37. package/dist/delivery/feeds.js +1 -7
  38. package/dist/delivery/index.d.ts +2 -2
  39. package/dist/delivery/index.js +1 -1
  40. package/dist/delivery/manifest.d.ts +0 -5
  41. package/dist/delivery/manifest.js +5 -16
  42. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  43. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  44. package/dist/delivery/site-indexes.d.ts +3 -3
  45. package/dist/delivery/site-indexes.js +3 -3
  46. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  47. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  48. package/dist/delivery/sitemap.js +1 -3
  49. package/dist/delivery/xml.d.ts +2 -0
  50. package/dist/delivery/xml.js +11 -0
  51. package/dist/diagnostics/conditions.js +24 -0
  52. package/dist/doctor/bin.js +30 -12
  53. package/dist/doctor/check-floors.d.ts +15 -0
  54. package/dist/doctor/check-floors.js +107 -0
  55. package/dist/doctor/check-probe.d.ts +3 -0
  56. package/dist/doctor/check-probe.js +123 -0
  57. package/dist/doctor/checks-github.js +1 -1
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +28 -2
  60. package/dist/doctor/cloudflare-api.js +2 -2
  61. package/dist/doctor/index.d.ts +28 -3
  62. package/dist/doctor/index.js +47 -6
  63. package/dist/doctor/types.d.ts +2 -0
  64. package/dist/doctor/wrangler-config.d.ts +4 -0
  65. package/dist/doctor/wrangler-config.js +11 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +3 -2
  68. package/dist/env.js +12 -6
  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/types.d.ts +2 -0
  74. package/dist/github/types.js +4 -0
  75. package/dist/index.d.ts +1 -1
  76. package/dist/log/events.d.ts +1 -1
  77. package/dist/nav/site-config.d.ts +2 -0
  78. package/dist/nav/site-config.js +2 -0
  79. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  80. package/dist/sveltekit/admin-dispatch.js +62 -0
  81. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  82. package/dist/sveltekit/cairn-admin.js +126 -0
  83. package/dist/sveltekit/condition-response.d.ts +1 -0
  84. package/dist/sveltekit/condition-response.js +25 -0
  85. package/dist/sveltekit/content-routes.d.ts +39 -15
  86. package/dist/sveltekit/content-routes.js +84 -50
  87. package/dist/sveltekit/guard.d.ts +8 -2
  88. package/dist/sveltekit/guard.js +18 -4
  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 +22 -19
  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/dist/vite/index.d.ts +16 -0
  98. package/dist/vite/index.js +57 -13
  99. package/package.json +6 -2
  100. package/src/lib/ambient.ts +19 -0
  101. package/src/lib/components/AdminLayout.svelte +6 -8
  102. package/src/lib/components/CairnAdmin.svelte +67 -0
  103. package/src/lib/components/ConceptList.svelte +4 -5
  104. package/src/lib/components/ConfirmPage.svelte +1 -1
  105. package/src/lib/components/EditPage.svelte +107 -25
  106. package/src/lib/components/EditorToolbar.svelte +79 -8
  107. package/src/lib/components/LoginPage.svelte +2 -2
  108. package/src/lib/components/ManageEditors.svelte +4 -3
  109. package/src/lib/components/MarkdownEditor.svelte +20 -2
  110. package/src/lib/components/cairn-admin.css +59 -0
  111. package/src/lib/components/editor-highlight.ts +32 -7
  112. package/src/lib/components/index.ts +1 -0
  113. package/src/lib/components/markdown-directives.ts +51 -1
  114. package/src/lib/components/markdown-format.ts +0 -27
  115. package/src/lib/components/preview-doc.ts +82 -0
  116. package/src/lib/content/compose.ts +1 -0
  117. package/src/lib/content/links.ts +28 -0
  118. package/src/lib/content/types.ts +34 -2
  119. package/src/lib/delivery/data.ts +3 -5
  120. package/src/lib/delivery/feeds.ts +1 -8
  121. package/src/lib/delivery/index.ts +2 -2
  122. package/src/lib/delivery/manifest.ts +5 -18
  123. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  124. package/src/lib/delivery/site-indexes.ts +6 -6
  125. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  126. package/src/lib/delivery/sitemap.ts +1 -4
  127. package/src/lib/delivery/xml.ts +12 -0
  128. package/src/lib/diagnostics/conditions.ts +24 -0
  129. package/src/lib/doctor/bin.ts +35 -10
  130. package/src/lib/doctor/check-floors.ts +124 -0
  131. package/src/lib/doctor/check-probe.ts +138 -0
  132. package/src/lib/doctor/checks-github.ts +3 -1
  133. package/src/lib/doctor/checks-local.ts +28 -2
  134. package/src/lib/doctor/cloudflare-api.ts +4 -2
  135. package/src/lib/doctor/index.ts +67 -6
  136. package/src/lib/doctor/types.ts +2 -0
  137. package/src/lib/doctor/wrangler-config.ts +11 -0
  138. package/src/lib/email.ts +4 -11
  139. package/src/lib/env.ts +12 -6
  140. package/src/lib/escape.ts +12 -0
  141. package/src/lib/github/credentials.ts +6 -2
  142. package/src/lib/github/types.ts +5 -0
  143. package/src/lib/index.ts +2 -0
  144. package/src/lib/log/events.ts +1 -0
  145. package/src/lib/nav/site-config.ts +3 -0
  146. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  147. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  148. package/src/lib/sveltekit/condition-response.ts +27 -1
  149. package/src/lib/sveltekit/content-routes.ts +131 -62
  150. package/src/lib/sveltekit/guard.ts +20 -5
  151. package/src/lib/sveltekit/https-required-page.ts +2 -1
  152. package/src/lib/sveltekit/index.ts +6 -0
  153. package/src/lib/sveltekit/nav-routes.ts +24 -21
  154. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  155. package/src/lib/sveltekit/types.ts +16 -7
  156. package/src/lib/vite/index.ts +71 -17
  157. package/dist/delivery/paginate.d.ts +0 -12
  158. package/dist/delivery/paginate.js +0 -20
  159. package/dist/render/index.d.ts +0 -5
  160. package/dist/render/index.js +0 -8
  161. package/src/lib/delivery/paginate.ts +0 -32
  162. package/src/lib/render/index.ts +0 -8
@@ -0,0 +1,138 @@
1
+ // The doctor's opt-in live probe (--probe): one GET and one POST against a deployed admin,
2
+ // asserting the envelope a working sign-in presents. Zero side effects by construction: the
3
+ // POST submits a random non-editor address, and the engine's non-leak design answers a
4
+ // non-editor with the identical sent body while sending no email and minting no token, so the
5
+ // probe leaves nothing behind on the site. A factory rather than a check constant, the same
6
+ // shape as the live send: the check exists only when the bin receives --probe.
7
+ import { fail, pass, skip } from './types.js';
8
+ import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
9
+ import { csrfCookieName } from '../auth/crypto.js';
10
+ import { readWranglerConfig } from './wrangler-config.js';
11
+
12
+ const NO_URL: CheckResult = skip(
13
+ 'pass --probe <url>, set PUBLIC_ORIGIN in the wrangler vars, or set PUBLIC_ORIGIN in the environment'
14
+ );
15
+
16
+ /** Build the live-probe check. A missing url falls back to the PUBLIC_ORIGIN input at run time. */
17
+ export function liveProbeCheck(url?: string): DoctorCheck {
18
+ return {
19
+ id: 'admin.login-probe',
20
+ conditionId: 'admin.login-probe-failed',
21
+ title: 'Live admin login probe',
22
+ async run(ctx: DoctorContext): Promise<CheckResult> {
23
+ // The wrangler vars hold the value the deployed Worker reads, so they beat the local
24
+ // environment, the same precedence the public-origin check applies.
25
+ const base =
26
+ url ?? (await readWranglerConfig(ctx.readFile))?.publicOrigin ?? ctx.publicOrigin;
27
+ if (base === undefined) return NO_URL;
28
+ let origin: URL;
29
+ try {
30
+ origin = new URL(base);
31
+ } catch {
32
+ return fail(`probe URL does not parse: ${base}`);
33
+ }
34
+ try {
35
+ return await probe(ctx, origin);
36
+ } catch (err) {
37
+ return fail(String(err));
38
+ }
39
+ },
40
+ };
41
+ }
42
+
43
+ /** GET /admin/login and assert the sign-in envelope, then hand the harvested token pair on. */
44
+ async function probe(ctx: DoctorContext, origin: URL): Promise<CheckResult> {
45
+ const res = await ctx.fetch(String(new URL('/admin/login', origin)));
46
+ if (res.status !== 200) {
47
+ return fail(`GET /admin/login returned ${res.status}, expected 200`);
48
+ }
49
+ const cookieName = csrfCookieName(origin.protocol === 'https:');
50
+ const cookieValue = setCookieValue(res.headers.getSetCookie(), cookieName);
51
+ if (cookieValue === undefined) {
52
+ return fail(`GET /admin/login set no ${cookieName} cookie`);
53
+ }
54
+ const html = await res.text();
55
+ const field = csrfFieldValue(html);
56
+ if (field === undefined) {
57
+ return fail('the login page carries no name="csrf" hidden field with a value');
58
+ }
59
+ if (!/<form[^>]*action="[^"]*\?\/request"/.test(html)) {
60
+ return fail('the login page carries no form posting the ?/request action');
61
+ }
62
+ return postRequestAction(ctx, origin, `${cookieName}=${cookieValue}`, field);
63
+ }
64
+
65
+ /** The named cookie's value from the Set-Cookie lines, or undefined when no line names it. */
66
+ function setCookieValue(lines: string[], name: string): string | undefined {
67
+ for (const line of lines) {
68
+ const eq = line.indexOf('=');
69
+ if (eq === -1 || line.slice(0, eq).trim() !== name) continue;
70
+ const rest = line.slice(eq + 1);
71
+ const semi = rest.indexOf(';');
72
+ return semi === -1 ? rest : rest.slice(0, semi);
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /** The csrf hidden field's value, tolerant of attribute order, or undefined when absent or empty. */
78
+ function csrfFieldValue(html: string): string | undefined {
79
+ const input = (html.match(/<input[^>]*>/g) ?? []).find((tag) => /name="csrf"/.test(tag));
80
+ if (input === undefined) return undefined;
81
+ return /value="([^"]+)"/.exec(input)?.[1];
82
+ }
83
+
84
+ /**
85
+ * POST the request action and read its serialized result. The address is random and non-editor
86
+ * at the reserved example.invalid domain, so even a delivery bug could send nothing anywhere,
87
+ * and the engine's non-leak design makes the response indistinguishable from a real send.
88
+ */
89
+ async function postRequestAction(
90
+ ctx: DoctorContext,
91
+ origin: URL,
92
+ cookie: string,
93
+ csrf: string
94
+ ): Promise<CheckResult> {
95
+ const email = `cairn-doctor-probe-${Math.random().toString(36).slice(2, 10)}@example.invalid`;
96
+ const res = await ctx.fetch(String(new URL('/admin/login?/request', origin)), {
97
+ method: 'POST',
98
+ headers: {
99
+ 'content-type': 'application/x-www-form-urlencoded',
100
+ cookie,
101
+ },
102
+ body: new URLSearchParams({ email, csrf }).toString(),
103
+ });
104
+ if (res.status !== 200) {
105
+ return fail(`POST ?/request returned ${res.status}, expected 200`);
106
+ }
107
+ // A no-Accept action POST answers with SvelteKit's serialized form-action JSON, shaped
108
+ // {"type":"success","status":200,"data":"<devalue array string>"}. The data field is a
109
+ // devalue encoding the probe reads by containment for the status literals, tolerant of
110
+ // encoding details it does not own, instead of pulling in a devalue parser.
111
+ let envelope: { type?: unknown; data?: unknown };
112
+ try {
113
+ envelope = (await res.json()) as { type?: unknown; data?: unknown };
114
+ } catch {
115
+ return fail('POST ?/request did not answer with the serialized action JSON');
116
+ }
117
+ if (envelope.type !== 'success') {
118
+ return fail(`POST ?/request answered type ${String(envelope.type)}, expected success`);
119
+ }
120
+ const data = typeof envelope.data === 'string' ? envelope.data : '';
121
+ if (data.includes('"send_error"')) {
122
+ return fail(
123
+ 'the request action answered send_error; the magic-link send path is failing (see the email checks and the auth.link.send_failed log records)'
124
+ );
125
+ }
126
+ // Every payload carries the "sent" field name, so the distinct status spellings go first.
127
+ if (data.includes('"throttled"')) {
128
+ return pass(
129
+ `sign-in envelope verified at ${origin.origin}; the request action answered throttled (a real cooldown window is active), which still proves the path`
130
+ );
131
+ }
132
+ if (data.includes('"sent"')) {
133
+ return pass(
134
+ `sign-in envelope verified at ${origin.origin}; the request action answered sent for a non-editor probe address`
135
+ );
136
+ }
137
+ return fail('POST ?/request answered success with an unrecognized payload');
138
+ }
@@ -22,7 +22,9 @@ export const githubApp: DoctorCheck = {
22
22
  );
23
23
  }
24
24
  if (!ctx.repo) {
25
- return skip('pass --repo or set GITHUB_REPO to run this check');
25
+ return skip(
26
+ 'pass --repo, set GITHUB_REPO, or configure the cairnManifest plugin so the doctor can read the adapter'
27
+ );
26
28
  }
27
29
  const creds = appCredentials(
28
30
  { appId: ctx.github.appId, installationId: ctx.github.installationId },
@@ -1,9 +1,10 @@
1
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.
2
+ // svelte.config CSRF handoff, the site-config validation, and the public origin. Every read
3
+ // goes through the injected ctx.readFile, so the tests pass fixtures and the bin passes node:fs.
4
4
  import { fail, pass, skip } from './types.js';
5
5
  import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
6
6
  import { readWranglerConfig } from './wrangler-config.js';
7
+ import { requireOrigin } from '../env.js';
7
8
  import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
8
9
  import { normalizeConcepts } from '../content/concepts.js';
9
10
  import { defineFields } from '../content/schema.js';
@@ -85,6 +86,31 @@ export const configCsrfDisable: DoctorCheck = {
85
86
  },
86
87
  };
87
88
 
89
+ export const configPublicOrigin: DoctorCheck = {
90
+ id: 'config.public-origin',
91
+ conditionId: 'config.public-origin-invalid',
92
+ title: 'Public origin',
93
+ async run(ctx: DoctorContext): Promise<CheckResult> {
94
+ // The wrangler vars hold the value the deployed Worker reads, so they beat the local
95
+ // environment; the env fallback covers a dashboard-set var the file never carries.
96
+ const facts = await readWranglerConfig(ctx.readFile);
97
+ const fromVars = facts?.publicOrigin;
98
+ const origin = fromVars ?? ctx.publicOrigin;
99
+ if (facts === null && origin === undefined) {
100
+ return skip('no wrangler config found and PUBLIC_ORIGIN is not in the environment');
101
+ }
102
+ // requireOrigin is the runtime rule (unset, not a URL, http off localhost); reusing it
103
+ // keeps the doctor and the Worker on one judgment.
104
+ try {
105
+ requireOrigin({ PUBLIC_ORIGIN: origin });
106
+ } catch (err) {
107
+ return fail(err instanceof Error ? err.message : String(err));
108
+ }
109
+ const source = fromVars !== undefined ? 'wrangler vars' : 'environment';
110
+ return pass(`PUBLIC_ORIGIN is ${origin} (${source})`);
111
+ },
112
+ };
113
+
88
114
  // Where sites keep site.config.yaml. The adapter's configPath is TypeScript the CLI cannot
89
115
  // evaluate, so the check probes the conventional spots instead (the repo root and the two
90
116
  // src locations the production sites use).
@@ -9,10 +9,12 @@ export const CF_API = 'https://api.cloudflare.com/client/v4';
9
9
 
10
10
  export const NO_TOKEN: CheckResult = skip('set CLOUDFLARE_API_TOKEN to run this check');
11
11
 
12
- export const NO_FROM: CheckResult = skip('pass --from or set CAIRN_FROM to run this check');
12
+ export const NO_FROM: CheckResult = skip(
13
+ 'pass --from, set CAIRN_FROM, or configure the cairnManifest plugin so the doctor can read the adapter'
14
+ );
13
15
 
14
16
  export const NO_ACCOUNT: CheckResult = skip(
15
- 'set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to run this check'
17
+ 'set CLOUDFLARE_API_TOKEN, and CLOUDFLARE_ACCOUNT_ID or a wrangler account_id, to run this check'
16
18
  );
17
19
 
18
20
  export function cfGet(ctx: DoctorContext, path: string): Promise<Response> {
@@ -7,22 +7,28 @@ import {
7
7
  configObservability,
8
8
  configCsrfDisable,
9
9
  configSiteConfig,
10
+ configPublicOrigin,
10
11
  } from './checks-local.js';
12
+ import { configDependencyFloors } from './check-floors.js';
11
13
  import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
12
14
  import { githubApp } from './checks-github.js';
13
15
 
14
16
  export { runDoctor } from './run.js';
15
17
  export { formatReport } from './report.js';
16
18
 
17
- const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>]';
19
+ const USAGE =
20
+ 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>] [--probe [url]]';
18
21
 
19
22
  export interface DoctorArgs {
20
23
  from?: string;
21
24
  repo?: string;
22
25
  sendTest?: string;
26
+ /** The live admin probe: a URL when --probe carried one, true for the bare flag (probe the
27
+ * PUBLIC_ORIGIN input), absent when the flag never appeared (the probe does not run). */
28
+ probe?: string | true;
23
29
  }
24
30
 
25
- const FLAGS: Record<string, keyof DoctorArgs> = {
31
+ const FLAGS: Record<string, 'from' | 'repo' | 'sendTest'> = {
26
32
  '--from': 'from',
27
33
  '--repo': 'repo',
28
34
  '--send-test': 'sendTest',
@@ -31,8 +37,16 @@ const FLAGS: Record<string, keyof DoctorArgs> = {
31
37
  /** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
32
38
  export function parseArgs(argv: string[]): DoctorArgs {
33
39
  const args: DoctorArgs = {};
34
- for (let i = 0; i < argv.length; i += 2) {
40
+ for (let i = 0; i < argv.length; ) {
35
41
  const flag = argv[i];
42
+ // --probe alone is meaningful (probe the PUBLIC_ORIGIN input), so its value is optional.
43
+ if (flag === '--probe') {
44
+ const value = argv[i + 1];
45
+ const bare = value === undefined || value.startsWith('--');
46
+ args.probe = bare ? true : value;
47
+ i += bare ? 1 : 2;
48
+ continue;
49
+ }
36
50
  const key = FLAGS[flag];
37
51
  if (!key) throw new Error(`unknown argument ${flag}\n${USAGE}`);
38
52
  const value = argv[i + 1];
@@ -40,6 +54,7 @@ export function parseArgs(argv: string[]): DoctorArgs {
40
54
  throw new Error(`${flag} needs a value\n${USAGE}`);
41
55
  }
42
56
  args[key] = value;
57
+ i += 2;
43
58
  }
44
59
  return args;
45
60
  }
@@ -62,6 +77,7 @@ export function contextFromEnv(
62
77
  repo: args.repo ?? env.GITHUB_REPO,
63
78
  cfToken: env.CLOUDFLARE_API_TOKEN,
64
79
  cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
80
+ publicOrigin: env.PUBLIC_ORIGIN,
65
81
  github:
66
82
  GITHUB_APP_ID && GITHUB_APP_INSTALLATION_ID && GITHUB_APP_PRIVATE_KEY_B64
67
83
  ? {
@@ -73,10 +89,53 @@ export function contextFromEnv(
73
89
  };
74
90
  }
75
91
 
92
+ /** The lazy derivation sources the bin wires up: the adapter read through the consumer's own
93
+ * Vite resolution and the wrangler config's account_id. Each runs only when an input it feeds
94
+ * is still missing, so a doctor run with full flags touches neither. */
95
+ export interface DerivationSources {
96
+ /** Returns { owner, repo, from } off the adapter, or null when nothing is derivable. */
97
+ adapterFacts: () => Promise<{ owner?: string; repo?: string; from?: string } | null>;
98
+ /** Returns the wrangler config's account_id, or undefined when none is declared. */
99
+ wranglerAccountId: () => Promise<string | undefined>;
100
+ }
101
+
102
+ /**
103
+ * Fill the context's missing inputs from the repo the doctor runs in: from and repo off the
104
+ * adapter, the account id off the wrangler config. An explicit flag or env value always wins
105
+ * (contextFromEnv already resolved those into ctx), each source runs lazily and only for
106
+ * inputs still missing, and a derivation failure leaves the input absent so its check skips
107
+ * with the usual remediation line instead of the doctor crashing. The API token is never
108
+ * derived; it stays env-only.
109
+ */
110
+ export async function deriveMissingInputs(
111
+ ctx: Omit<DoctorContext, 'fetch' | 'readFile'>,
112
+ sources: DerivationSources
113
+ ): Promise<Omit<DoctorContext, 'fetch' | 'readFile'>> {
114
+ const out = { ...ctx };
115
+ if (out.from === undefined || out.repo === undefined) {
116
+ const facts = await sources.adapterFacts().catch(() => null);
117
+ if (out.from === undefined && typeof facts?.from === 'string') {
118
+ out.from = facts.from;
119
+ }
120
+ if (
121
+ out.repo === undefined &&
122
+ typeof facts?.owner === 'string' &&
123
+ typeof facts?.repo === 'string'
124
+ ) {
125
+ out.repo = `${facts.owner}/${facts.repo}`;
126
+ }
127
+ }
128
+ if (out.cfAccountId === undefined) {
129
+ const accountId = await sources.wranglerAccountId().catch(() => undefined);
130
+ if (typeof accountId === 'string') out.cfAccountId = accountId;
131
+ }
132
+ return out;
133
+ }
134
+
76
135
  /**
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.
136
+ * The default registry: the six local checks, the four Cloudflare checks, and the GitHub App
137
+ * chain. The live send is opt-in (--send-test) and never sits here; the bin appends it. A
138
+ * fresh array per call, so that append mutates nothing shared.
80
139
  */
81
140
  export function defaultChecks(): DoctorCheck[] {
82
141
  return [
@@ -84,6 +143,8 @@ export function defaultChecks(): DoctorCheck[] {
84
143
  configObservability,
85
144
  configCsrfDisable,
86
145
  configSiteConfig,
146
+ configPublicOrigin,
147
+ configDependencyFloors,
87
148
  emailSenderOnboarded,
88
149
  edgeHttpsForced,
89
150
  edgeHsts,
@@ -43,6 +43,8 @@ export interface DoctorContext {
43
43
  cfToken?: string;
44
44
  /** CLOUDFLARE_ACCOUNT_ID. */
45
45
  cfAccountId?: string;
46
+ /** PUBLIC_ORIGIN, the env fallback when the wrangler vars carry none. */
47
+ publicOrigin?: string;
46
48
  /** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
47
49
  github?: { appId: string; installationId: string; privateKeyB64: string };
48
50
  /** Injected fetch for tests; defaults to global fetch. */
@@ -12,6 +12,10 @@ export interface WranglerFacts {
12
12
  authDbId?: string;
13
13
  /** observability.enabled is true. */
14
14
  observabilityEnabled: boolean;
15
+ /** vars.PUBLIC_ORIGIN, when declared; the public-origin check validates it. */
16
+ publicOrigin?: string;
17
+ /** The top-level account_id, when declared; a fallback for CLOUDFLARE_ACCOUNT_ID. */
18
+ accountId?: string;
15
19
  }
16
20
 
17
21
  export async function readWranglerConfig(
@@ -91,6 +95,9 @@ function factsFromJsonc(text: string): WranglerFacts {
91
95
  observabilityEnabled: observability?.enabled === true,
92
96
  };
93
97
  if (typeof authDb?.database_id === 'string') facts.authDbId = authDb.database_id;
98
+ const vars = config.vars as { PUBLIC_ORIGIN?: unknown } | undefined;
99
+ if (typeof vars?.PUBLIC_ORIGIN === 'string') facts.publicOrigin = vars.PUBLIC_ORIGIN;
100
+ if (typeof config.account_id === 'string') facts.accountId = config.account_id;
94
101
  return facts;
95
102
  }
96
103
 
@@ -135,6 +142,10 @@ function factsFromToml(text: string): WranglerFacts {
135
142
  if (key === 'database_id') d1Id = str;
136
143
  } else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
137
144
  facts.observabilityEnabled = true;
145
+ } else if (section === '[vars]' && key === 'PUBLIC_ORIGIN' && str !== undefined) {
146
+ facts.publicOrigin = str;
147
+ } else if (section === '' && key === 'account_id' && str !== undefined) {
148
+ facts.accountId = str;
138
149
  }
139
150
  }
140
151
  flushD1();
package/src/lib/email.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // (Cloudflare Email Sending, arbitrary recipients).
4
4
  import type { AuthEnv } from './auth/types.js';
5
5
  import { CairnError } from './diagnostics/index.js';
6
+ import { escapeHtml } from './escape.js';
6
7
 
7
8
  export type { AuthEnv };
8
9
 
@@ -27,16 +28,6 @@ export interface AuthBranding {
27
28
  * the message body or the magic link in what it throws. */
28
29
  export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
29
30
 
30
- /** Escape the five HTML-significant characters. */
31
- function escapeHtml(value: string): string {
32
- return value
33
- .replaceAll('&', '&amp;')
34
- .replaceAll('<', '&lt;')
35
- .replaceAll('>', '&gt;')
36
- .replaceAll('"', '&quot;')
37
- .replaceAll("'", '&#39;');
38
- }
39
-
40
31
  /** Build the confirmation email. The link is the only action; the copy stays plain. */
41
32
  export function buildMagicLinkMessage(input: {
42
33
  to: string;
@@ -54,7 +45,9 @@ export function buildMagicLinkMessage(input: {
54
45
 
55
46
  /** The production send: Cloudflare Email Sending through the EMAIL binding. */
56
47
  export const cloudflareSend: SendMagicLink = async (env, message) => {
57
- if (!env.EMAIL) throw new Error('EMAIL binding is not configured');
48
+ if (!env.EMAIL) {
49
+ throw new CairnError('config.bindings-missing', { message: 'EMAIL binding is not configured' });
50
+ }
58
51
  await env.EMAIL.send(message);
59
52
  };
60
53
 
package/src/lib/env.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { D1Database } from '@cloudflare/workers-types';
2
+ import { CairnError } from './diagnostics/index.js';
2
3
 
3
4
  /**
4
5
  * Returns the site's public origin from configuration.
@@ -6,25 +7,30 @@ import type { D1Database } from '@cloudflare/workers-types';
6
7
  * The origin is always config-derived, never read from a request header, so a
7
8
  * forged Host header cannot redirect a magic link (spec 7.1, risk H3).
8
9
  *
9
- * @throws Error when `PUBLIC_ORIGIN` is unset or empty.
10
+ * @throws CairnError (`config.public-origin-invalid`) when `PUBLIC_ORIGIN` is unset or
11
+ * empty, fails to parse as a URL, or uses http on a non-local host.
10
12
  */
11
13
  export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
12
14
  const origin = env.PUBLIC_ORIGIN;
13
15
  if (!origin) {
14
- throw new Error('PUBLIC_ORIGIN is not configured');
16
+ throw new CairnError('config.public-origin-invalid', { message: 'PUBLIC_ORIGIN is not configured' });
15
17
  }
16
18
  let hostname: string;
17
19
  try {
18
20
  hostname = new URL(origin).hostname;
19
21
  } catch {
20
- throw new Error(`PUBLIC_ORIGIN is not a valid URL, got ${origin}`);
22
+ throw new CairnError('config.public-origin-invalid', {
23
+ message: `PUBLIC_ORIGIN is not a valid URL, got ${origin}`,
24
+ });
21
25
  }
22
26
  // The magic-link origin must be https in production so the link and the __Host- cookie are
23
27
  // origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
24
28
  // a lookalike host like localhost.example.com cannot skip the https requirement.
25
29
  const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
26
30
  if (!origin.startsWith('https://') && !isLocal) {
27
- throw new Error(`PUBLIC_ORIGIN must be https in production, got ${origin}`);
31
+ throw new CairnError('config.public-origin-invalid', {
32
+ message: `PUBLIC_ORIGIN must be https in production, got ${origin}`,
33
+ });
28
34
  }
29
35
  return origin;
30
36
  }
@@ -35,11 +41,11 @@ export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
35
41
  * The handlers read D1 off `event.platform.env`; without this a misconfigured binding
36
42
  * surfaces as a raw `TypeError` deep in a store call. This gives the failure a name.
37
43
  *
38
- * @throws Error when `AUTH_DB` is missing.
44
+ * @throws CairnError (`config.bindings-missing`) when `AUTH_DB` is missing.
39
45
  */
40
46
  export function requireDb(env: { AUTH_DB?: D1Database }): D1Database {
41
47
  if (!env.AUTH_DB) {
42
- throw new Error('AUTH_DB binding is not configured');
48
+ throw new CairnError('config.bindings-missing', { message: 'AUTH_DB binding is not configured' });
43
49
  }
44
50
  return env.AUTH_DB;
45
51
  }
@@ -0,0 +1,12 @@
1
+ // cairn-cms: the one HTML text escape. A leaf module with no imports, so the email builder and
2
+ // the edge-served admin pages share it without either arm reaching into the other.
3
+
4
+ /** Escape the five HTML-significant characters for text and quoted attribute values. */
5
+ export function escapeHtml(value: string): string {
6
+ return value
7
+ .replaceAll('&', '&amp;')
8
+ .replaceAll('<', '&lt;')
9
+ .replaceAll('>', '&gt;')
10
+ .replaceAll('"', '&quot;')
11
+ .replaceAll("'", '&#39;');
12
+ }
@@ -2,6 +2,7 @@
2
2
  // App signer's input. One tested place owns the join and the missing-secret failure, so the
3
3
  // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
4
4
  // TypeError. Mirrors requireDb/requireOrigin in env.ts.
5
+ import { CairnError } from '../diagnostics/index.js';
5
6
  import type { BackendConfig } from '../content/types.js';
6
7
  import type { AppCredentials } from './types.js';
7
8
 
@@ -12,7 +13,8 @@ export interface GithubKeyEnv {
12
13
 
13
14
  /**
14
15
  * Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
15
- * installation) and the Worker's private-key secret. Throws when the secret is unset.
16
+ * installation) and the Worker's private-key secret. Throws a CairnError naming
17
+ * `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
16
18
  */
17
19
  export function appCredentials(
18
20
  backend: Pick<BackendConfig, 'appId' | 'installationId'>,
@@ -20,7 +22,9 @@ export function appCredentials(
20
22
  ): AppCredentials {
21
23
  const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
22
24
  if (!privateKeyB64) {
23
- throw new Error('GITHUB_APP_PRIVATE_KEY_B64 is not configured');
25
+ throw new CairnError('github.app-unreachable', {
26
+ message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
27
+ });
24
28
  }
25
29
  return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
26
30
  }
@@ -43,3 +43,8 @@ export class CommitConflictError extends Error {
43
43
  this.name = 'CommitConflictError';
44
44
  }
45
45
  }
46
+
47
+ /** Match a commit conflict by class and by name (bundling can alias the class identity). */
48
+ export function isConflict(err: unknown): boolean {
49
+ return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
50
+ }
package/src/lib/index.ts CHANGED
@@ -20,6 +20,8 @@ export type {
20
20
  BackendConfig,
21
21
  SenderConfig,
22
22
  NavMenuConfig,
23
+ PreviewConfig,
24
+ ResolvedPreview,
23
25
  AssetConfig,
24
26
  RoutingRule,
25
27
  ConceptDescriptor,
@@ -10,6 +10,7 @@ export type CairnLogEvent =
10
10
  | 'auth.session.destroyed'
11
11
  | 'commit.succeeded'
12
12
  | 'commit.failed'
13
+ | 'config.invalid'
13
14
  | 'entry.published'
14
15
  | 'entry.discarded'
15
16
  | 'publish.failed'
@@ -85,6 +85,9 @@ export interface SiteConfig {
85
85
  }
86
86
 
87
87
  export class SiteConfigError extends Error {
88
+ /** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
89
+ readonly conditionId = 'config.site-config-invalid';
90
+
88
91
  constructor(message: string) {
89
92
  super(message);
90
93
  this.name = 'SiteConfigError';
@@ -0,0 +1,75 @@
1
+ // cairn-cms: the single path authority for the single-mount admin dispatcher. The dispatcher
2
+ // mounts one catch-all route under /admin and asks this parser which view a raw pathname
3
+ // names; every admin URL shape is decided here and nowhere else. The parser is pure: it
4
+ // returns a discriminated AdminView, or null for any shape it does not recognize, and the
5
+ // caller maps null to a 404.
6
+ import type { ConceptDescriptor } from '../content/types.js';
7
+ import { findConcept } from '../content/concepts.js';
8
+ import { isValidId } from '../content/ids.js';
9
+
10
+ /** The views the single-mount admin can render, discriminated for the dispatcher's switch. */
11
+ export type AdminView =
12
+ | { view: 'index' }
13
+ | { view: 'login' }
14
+ | { view: 'confirm' }
15
+ | { view: 'list'; concept: ConceptDescriptor }
16
+ | { view: 'edit'; concept: ConceptDescriptor; id: string }
17
+ | { view: 'editors' }
18
+ | { view: 'nav' };
19
+
20
+ /**
21
+ * Fixed first segments that never resolve as concepts. The engine only allows posts and pages
22
+ * today, so no collision is possible, but the parser does not depend on that: a reserved
23
+ * segment wins before concept lookup. `settings` has no view yet; AdminLayout already links
24
+ * the sidebar to /admin/settings, so the URL is spoken for.
25
+ */
26
+ const RESERVED_SEGMENTS = new Set(['login', 'auth', 'editors', 'nav', 'settings']);
27
+
28
+ /**
29
+ * Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
30
+ * param) into the admin view it names. A single trailing slash is tolerated everywhere; empty
31
+ * internal segments are not. Each segment is percent-decoded individually, so an encoded slash
32
+ * stays inside its segment, where it can never match a concept id or pass `isValidId` and so
33
+ * falls through to null.
34
+ */
35
+ export function parseAdminPath(
36
+ pathname: string,
37
+ concepts: ConceptDescriptor[],
38
+ ): AdminView | null {
39
+ if (pathname !== '/admin' && !pathname.startsWith('/admin/')) return null;
40
+ let rest = pathname.slice('/admin'.length);
41
+ // Tolerate exactly one trailing slash; a doubled one leaves an empty segment behind.
42
+ if (rest.endsWith('/')) rest = rest.slice(0, -1);
43
+ if (rest === '') return { view: 'index' };
44
+
45
+ const rawSegments = rest.slice(1).split('/');
46
+ if (rawSegments.includes('')) return null;
47
+ let segments: string[];
48
+ try {
49
+ segments = rawSegments.map((segment) => decodeURIComponent(segment));
50
+ } catch {
51
+ // Malformed percent encoding is an unrecognized shape, not a server error.
52
+ return null;
53
+ }
54
+
55
+ if (segments.length === 1) {
56
+ const [head] = segments;
57
+ if (head === 'login') return { view: 'login' };
58
+ if (head === 'editors') return { view: 'editors' };
59
+ if (head === 'nav') return { view: 'nav' };
60
+ if (RESERVED_SEGMENTS.has(head)) return null;
61
+ const concept = findConcept(concepts, head);
62
+ return concept ? { view: 'list', concept } : null;
63
+ }
64
+
65
+ if (segments.length === 2) {
66
+ const [head, tail] = segments;
67
+ if (head === 'auth') return tail === 'confirm' ? { view: 'confirm' } : null;
68
+ if (RESERVED_SEGMENTS.has(head)) return null;
69
+ const concept = findConcept(concepts, head);
70
+ if (!concept || !isValidId(tail)) return null;
71
+ return { view: 'edit', concept, id: tail };
72
+ }
73
+
74
+ return null;
75
+ }