@glw907/cairn-cms 0.38.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.
- package/CHANGELOG.md +94 -0
- package/README.md +7 -6
- package/dist/components/AdminLayout.svelte +53 -0
- package/dist/components/ComponentInsertDialog.svelte +27 -13
- package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
- package/dist/components/ConceptList.svelte +22 -3
- package/dist/components/DeleteDialog.svelte +18 -7
- package/dist/components/DeleteDialog.svelte.d.ts +11 -1
- package/dist/components/EditPage.svelte +604 -75
- package/dist/components/EditPage.svelte.d.ts +8 -1
- package/dist/components/EditorToolbar.svelte +206 -29
- package/dist/components/EditorToolbar.svelte.d.ts +12 -4
- package/dist/components/LinkPicker.svelte +14 -6
- package/dist/components/LinkPicker.svelte.d.ts +9 -2
- package/dist/components/MarkdownEditor.svelte +80 -34
- package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
- package/dist/components/MarkdownHelpDialog.svelte +58 -0
- package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
- package/dist/components/RenameDialog.svelte +13 -4
- package/dist/components/RenameDialog.svelte.d.ts +9 -1
- package/dist/components/WebLinkDialog.svelte +89 -0
- package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +353 -4
- package/dist/components/editor-highlight.d.ts +9 -0
- package/dist/components/editor-highlight.js +62 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +22 -0
- package/dist/components/markdown-format.d.ts +1 -1
- package/dist/components/markdown-format.js +91 -12
- package/dist/content/pending.d.ts +9 -0
- package/dist/content/pending.js +24 -0
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/github/branches.d.ts +11 -0
- package/dist/github/branches.js +75 -0
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +22 -1
- package/dist/sveltekit/content-routes.js +320 -72
- package/package.json +8 -5
- package/src/lib/components/AdminLayout.svelte +53 -0
- package/src/lib/components/ComponentInsertDialog.svelte +27 -13
- package/src/lib/components/ConceptList.svelte +22 -3
- package/src/lib/components/DeleteDialog.svelte +18 -7
- package/src/lib/components/EditPage.svelte +604 -75
- package/src/lib/components/EditorToolbar.svelte +206 -29
- package/src/lib/components/LinkPicker.svelte +14 -6
- package/src/lib/components/MarkdownEditor.svelte +80 -34
- package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
- package/src/lib/components/RenameDialog.svelte +13 -4
- package/src/lib/components/WebLinkDialog.svelte +89 -0
- package/src/lib/components/cairn-admin.css +26 -4
- package/src/lib/components/editor-highlight.ts +67 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-directives.ts +23 -0
- package/src/lib/components/markdown-format.ts +118 -13
- package/src/lib/content/pending.ts +24 -0
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/github/branches.ts +83 -0
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/log/events.ts +4 -0
- package/src/lib/sveltekit/content-routes.ts +400 -73
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { syntaxTree } from '@codemirror/language';
|
|
2
1
|
import { formatCairnToken, escapeLinkText } from '../content/links.js';
|
|
2
|
+
// EditPage imports this module statically, so a static @codemirror value import here would pull
|
|
3
|
+
// CodeMirror into a consumer's server bundle. syntaxTree resolves lazily inside the source
|
|
4
|
+
// instead (a CompletionSource may return a Promise), cached after the first completion.
|
|
5
|
+
let langMod = null;
|
|
3
6
|
/** The known concepts in display order; an unlisted concept sorts after these under its own name. */
|
|
4
7
|
const CONCEPT_SECTIONS = {
|
|
5
8
|
pages: { name: 'Pages', rank: 0 },
|
|
@@ -30,7 +33,7 @@ export function linkCompletions(targets, query) {
|
|
|
30
33
|
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
31
34
|
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
32
35
|
export function cairnLinkCompletionSource(targets) {
|
|
33
|
-
return (context) => {
|
|
36
|
+
return async (context) => {
|
|
34
37
|
const line = context.state.doc.lineAt(context.pos);
|
|
35
38
|
const before = context.state.sliceDoc(line.from, context.pos);
|
|
36
39
|
const trigger = matchCairnTrigger(before);
|
|
@@ -38,7 +41,11 @@ export function cairnLinkCompletionSource(targets) {
|
|
|
38
41
|
return null;
|
|
39
42
|
// Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
|
|
40
43
|
// the build resolver does not look inside code. The node name carries "Code" for both forms.
|
|
41
|
-
|
|
44
|
+
langMod ??= await import('@codemirror/language');
|
|
45
|
+
// The first completion awaits the import above, so the request may already be stale here.
|
|
46
|
+
if (context.aborted)
|
|
47
|
+
return null;
|
|
48
|
+
const node = langMod.syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
42
49
|
for (let n = node; n; n = n.parent) {
|
|
43
50
|
if (/Code/.test(n.name))
|
|
44
51
|
return null;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
2
|
+
export declare function directiveLineKind(line: string): 'fence' | 'leaf' | null;
|
|
3
|
+
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
4
|
+
export declare function findInlineDirectives(text: string): {
|
|
5
|
+
from: number;
|
|
6
|
+
to: number;
|
|
7
|
+
}[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Remark-directive detection for the editor's machinery highlighting (spec: directive syntax is
|
|
2
|
+
// styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
|
|
3
|
+
// CodeMirror decoration plugin wraps them.
|
|
4
|
+
const FENCE = /^\s{0,3}:::+\s*[\w-]*\s*(\{[^}]*\})?\s*$/;
|
|
5
|
+
const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
|
|
6
|
+
const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
|
|
7
|
+
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
8
|
+
export function directiveLineKind(line) {
|
|
9
|
+
if (FENCE.test(line))
|
|
10
|
+
return 'fence';
|
|
11
|
+
if (LEAF.test(line))
|
|
12
|
+
return 'leaf';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
16
|
+
export function findInlineDirectives(text) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const m of text.matchAll(INLINE)) {
|
|
19
|
+
out.push({ from: m.index, to: m.index + m[0].length });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FormatKind = 'bold' | 'italic' | 'code' | '
|
|
1
|
+
export type FormatKind = 'bold' | 'italic' | 'code' | 'strike' | 'h2' | 'h3' | 'quote' | 'ul' | 'ol' | 'task' | 'codeblock' | 'hr' | 'table' | 'link';
|
|
2
2
|
export interface FormatResult {
|
|
3
3
|
doc: string;
|
|
4
4
|
from: number;
|
|
@@ -8,13 +8,81 @@ import remarkParse from 'remark-parse';
|
|
|
8
8
|
import remarkGfm from 'remark-gfm';
|
|
9
9
|
import { visit } from 'unist-util-visit';
|
|
10
10
|
import { escapeLinkText } from '../content/links.js';
|
|
11
|
-
const WRAP = { bold: '**', italic: '_', code: '`' };
|
|
12
|
-
|
|
11
|
+
const WRAP = { bold: '**', italic: '_', code: '`', strike: '~~' };
|
|
12
|
+
/**
|
|
13
|
+
* Per-kind line-prefix behavior. `prefix` builds the marker for the line's 0-based index (only ol
|
|
14
|
+
* varies by line). `exact` matches a line already carrying this kind's own marker; when every
|
|
15
|
+
* selected line matches, the format toggles off. `strip` matches a competing marker to replace
|
|
16
|
+
* before prefixing, so h2 on an h3 line swaps the level instead of stacking. Quote and ul keep
|
|
17
|
+
* their original add-only behavior, so they carry neither regex.
|
|
18
|
+
*/
|
|
19
|
+
const LINE = {
|
|
20
|
+
h2: { prefix: () => '## ', exact: /^## /, strip: /^#{1,6} / },
|
|
21
|
+
h3: { prefix: () => '### ', exact: /^### /, strip: /^#{1,6} / },
|
|
22
|
+
quote: { prefix: () => '> ' },
|
|
23
|
+
ul: { prefix: () => '- ' },
|
|
24
|
+
ol: { prefix: (i) => `${i + 1}. `, exact: /^\d+\. /, strip: /^\d+\. / },
|
|
25
|
+
task: { prefix: () => '- [ ] ', exact: /^- \[[ xX]\] /, strip: /^- \[[ xX]\] / },
|
|
26
|
+
};
|
|
27
|
+
const TABLE_GRID = '| Column 1 | Column 2 |\n| -------- | -------- |\n| | |\n| | |';
|
|
28
|
+
/** Wrap the selection in `marker`, or unwrap when the markers are already there (inside or just
|
|
29
|
+
* outside the selection). The returned range covers the text without its markers either way. */
|
|
30
|
+
function toggleWrap(doc, from, to, marker) {
|
|
31
|
+
const m = marker.length;
|
|
32
|
+
const sel = doc.slice(from, to);
|
|
33
|
+
if (sel.length >= 2 * m && sel.startsWith(marker) && sel.endsWith(marker)) {
|
|
34
|
+
const inner = sel.slice(m, sel.length - m);
|
|
35
|
+
return { doc: doc.slice(0, from) + inner + doc.slice(to), from, to: to - 2 * m };
|
|
36
|
+
}
|
|
37
|
+
if (from >= m && doc.slice(from - m, from) === marker && doc.slice(to, to + m) === marker) {
|
|
38
|
+
return { doc: doc.slice(0, from - m) + sel + doc.slice(to + m), from: from - m, to: to - m };
|
|
39
|
+
}
|
|
40
|
+
const next = doc.slice(0, from) + marker + sel + marker + doc.slice(to);
|
|
41
|
+
return { doc: next, from: from + m, to: to + m };
|
|
42
|
+
}
|
|
43
|
+
/** Apply a line-prefix kind to every selected line. When the kind toggles and every line already
|
|
44
|
+
* carries its marker, the markers come off; otherwise competing markers are replaced and each
|
|
45
|
+
* line gains the kind's prefix. The selection shifts with the first line's edit and stretches
|
|
46
|
+
* by the total length change, the same mechanics the original single-prefix version had. */
|
|
47
|
+
function applyLinePrefix(doc, from, to, kind) {
|
|
48
|
+
const { prefix, exact, strip } = LINE[kind];
|
|
49
|
+
const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
|
|
50
|
+
const lines = doc.slice(lineStart, to).split('\n');
|
|
51
|
+
const next = exact && lines.every((line) => exact.test(line))
|
|
52
|
+
? lines.map((line) => line.replace(exact, ''))
|
|
53
|
+
: lines.map((line, i) => prefix(i) + (strip ? line.replace(strip, '') : line));
|
|
54
|
+
const region = next.join('\n');
|
|
55
|
+
const firstDelta = next[0].length - lines[0].length;
|
|
56
|
+
const totalDelta = region.length - (to - lineStart);
|
|
57
|
+
return {
|
|
58
|
+
doc: doc.slice(0, lineStart) + region + doc.slice(to),
|
|
59
|
+
from: Math.max(lineStart, from + firstDelta),
|
|
60
|
+
to: to + totalDelta,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Fence the selected lines in triple backticks on their own lines, or remove the fences when the
|
|
64
|
+
* lines just above and below the selection already are fences. */
|
|
65
|
+
function toggleCodeFence(doc, from, to) {
|
|
66
|
+
const lineStart = doc.lastIndexOf('\n', from - 1) + 1;
|
|
67
|
+
const lineEndRaw = doc.indexOf('\n', to);
|
|
68
|
+
const lineEnd = lineEndRaw === -1 ? doc.length : lineEndRaw;
|
|
69
|
+
const prevStart = lineStart > 0 ? doc.lastIndexOf('\n', lineStart - 2) + 1 : -1;
|
|
70
|
+
const prevLine = prevStart >= 0 ? doc.slice(prevStart, lineStart - 1) : null;
|
|
71
|
+
const nextEndRaw = lineEnd < doc.length ? doc.indexOf('\n', lineEnd + 1) : -1;
|
|
72
|
+
const nextEnd = nextEndRaw === -1 ? doc.length : nextEndRaw;
|
|
73
|
+
const nextLine = lineEnd < doc.length ? doc.slice(lineEnd + 1, nextEnd) : null;
|
|
74
|
+
if (prevLine === '```' && nextLine === '```') {
|
|
75
|
+
const removedBefore = lineStart - prevStart; // the opening fence line and its newline
|
|
76
|
+
const next = doc.slice(0, prevStart) + doc.slice(lineStart, lineEnd) + doc.slice(nextEnd);
|
|
77
|
+
return { doc: next, from: from - removedBefore, to: to - removedBefore };
|
|
78
|
+
}
|
|
79
|
+
const open = '```\n';
|
|
80
|
+
const next = doc.slice(0, lineStart) + open + doc.slice(lineStart, lineEnd) + '\n```' + doc.slice(lineEnd);
|
|
81
|
+
return { doc: next, from: from + open.length, to: to + open.length };
|
|
82
|
+
}
|
|
13
83
|
export function applyMarkdownFormat(doc, from, to, kind) {
|
|
14
|
-
if (kind === 'bold' || kind === 'italic' || kind === 'code') {
|
|
15
|
-
|
|
16
|
-
const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
|
|
17
|
-
return { doc: next, from: from + marker.length, to: to + marker.length };
|
|
84
|
+
if (kind === 'bold' || kind === 'italic' || kind === 'code' || kind === 'strike') {
|
|
85
|
+
return toggleWrap(doc, from, to, WRAP[kind]);
|
|
18
86
|
}
|
|
19
87
|
if (kind === 'link') {
|
|
20
88
|
const text = doc.slice(from, to);
|
|
@@ -24,12 +92,23 @@ export function applyMarkdownFormat(doc, from, to, kind) {
|
|
|
24
92
|
const urlStart = from + lead.length;
|
|
25
93
|
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
|
|
26
94
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
95
|
+
if (kind === 'codeblock')
|
|
96
|
+
return toggleCodeFence(doc, from, to);
|
|
97
|
+
if (kind === 'hr') {
|
|
98
|
+
const inserted = '\n\n---\n\n';
|
|
99
|
+
const at = from + inserted.length;
|
|
100
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: at, to: at };
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'table') {
|
|
103
|
+
const inserted = `\n\n${TABLE_GRID}\n\n`;
|
|
104
|
+
const cellStart = from + inserted.indexOf('Column 1');
|
|
105
|
+
return {
|
|
106
|
+
doc: doc.slice(0, from) + inserted + doc.slice(to),
|
|
107
|
+
from: cellStart,
|
|
108
|
+
to: cellStart + 'Column 1'.length,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return applyLinePrefix(doc, from, to, kind);
|
|
33
112
|
}
|
|
34
113
|
/**
|
|
35
114
|
* Insert an inline markdown link at the selection. With a non-empty selection the selected text
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Every pending branch sits under this prefix; one matching-refs call lists them all. */
|
|
2
|
+
export declare const PENDING_PREFIX = "cairn/";
|
|
3
|
+
/** The branch name holding an entry's pending edits. */
|
|
4
|
+
export declare function pendingBranch(concept: string, id: string): string;
|
|
5
|
+
/** Parse a branch name or fully qualified ref back to its entry, or null for any other ref. */
|
|
6
|
+
export declare function parsePendingBranch(ref: string): {
|
|
7
|
+
concept: string;
|
|
8
|
+
id: string;
|
|
9
|
+
} | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The pending-branch codec (publish-workflow spec): a pending entry lives on
|
|
2
|
+
// `cairn/<conceptKey>/<id>`, and the ref's existence is the only pending state. Concept ids and
|
|
3
|
+
// entry ids are slug-safe, so the name needs no escaping; the parser is the codec's inverse.
|
|
4
|
+
/** Every pending branch sits under this prefix; one matching-refs call lists them all. */
|
|
5
|
+
export const PENDING_PREFIX = 'cairn/';
|
|
6
|
+
/** The branch name holding an entry's pending edits. */
|
|
7
|
+
export function pendingBranch(concept, id) {
|
|
8
|
+
return `${PENDING_PREFIX}${concept}/${id}`;
|
|
9
|
+
}
|
|
10
|
+
/** Parse a branch name or fully qualified ref back to its entry, or null for any other ref. */
|
|
11
|
+
export function parsePendingBranch(ref) {
|
|
12
|
+
const name = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
|
|
13
|
+
if (!name.startsWith(PENDING_PREFIX))
|
|
14
|
+
return null;
|
|
15
|
+
const rest = name.slice(PENDING_PREFIX.length);
|
|
16
|
+
const slash = rest.indexOf('/');
|
|
17
|
+
if (slash <= 0)
|
|
18
|
+
return null;
|
|
19
|
+
const concept = rest.slice(0, slash);
|
|
20
|
+
const id = rest.slice(slash + 1);
|
|
21
|
+
if (!id || id.includes('/'))
|
|
22
|
+
return null;
|
|
23
|
+
return { concept, id };
|
|
24
|
+
}
|
|
@@ -10,11 +10,18 @@ export interface CairnCondition {
|
|
|
10
10
|
why: string;
|
|
11
11
|
/** The fix, often a command. */
|
|
12
12
|
remediation: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The condition's section in the readiness checklist, written as
|
|
15
|
+
* 'cloudflare-readiness.md#<heading-slug>' so a doc can link it relative to docs/guides/.
|
|
16
|
+
* The check:readiness gate parses the part after '#' and asserts the heading exists; two
|
|
17
|
+
* conditions may share a section. Every entry carries one unless the gate's allowlist
|
|
18
|
+
* excuses it.
|
|
19
|
+
*/
|
|
14
20
|
docsAnchor?: string;
|
|
15
21
|
/** The log vocabulary event this condition correlates with, if any. */
|
|
16
22
|
logEvent?: CairnLogEvent;
|
|
17
23
|
}
|
|
24
|
+
export declare const REGISTRY: Record<string, CairnCondition>;
|
|
18
25
|
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
19
26
|
export declare function condition(id: string): CairnCondition;
|
|
20
27
|
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Exported for the freeze test only; resolve entries through condition() everywhere else.
|
|
2
|
+
export const REGISTRY = {
|
|
2
3
|
'edge.https-not-forced': {
|
|
3
4
|
id: 'edge.https-not-forced',
|
|
4
5
|
severity: 'blocker',
|
|
5
6
|
title: 'Always Use HTTPS is off',
|
|
6
7
|
why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
|
|
7
8
|
remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
|
|
9
|
+
docsAnchor: 'cloudflare-readiness.md#force-https-at-the-edge',
|
|
8
10
|
logEvent: 'guard.rejected',
|
|
9
11
|
},
|
|
10
12
|
'auth.csrf-token-invalid': {
|
|
@@ -13,6 +15,7 @@ const REGISTRY = {
|
|
|
13
15
|
title: 'Admin CSRF token check failed',
|
|
14
16
|
why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
|
|
15
17
|
remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
|
|
18
|
+
docsAnchor: 'cloudflare-readiness.md#admin-csrf-token-rejected',
|
|
16
19
|
logEvent: 'guard.rejected',
|
|
17
20
|
},
|
|
18
21
|
'auth.csrf-origin-mismatch': {
|
|
@@ -21,6 +24,7 @@ const REGISTRY = {
|
|
|
21
24
|
title: 'Non-admin form Origin rejected',
|
|
22
25
|
why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
|
|
23
26
|
remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
|
|
27
|
+
docsAnchor: 'cloudflare-readiness.md#non-admin-origin-rejected',
|
|
24
28
|
logEvent: 'guard.rejected',
|
|
25
29
|
},
|
|
26
30
|
'email.sender-not-onboarded': {
|
|
@@ -29,6 +33,7 @@ const REGISTRY = {
|
|
|
29
33
|
title: 'Email sending domain is not onboarded',
|
|
30
34
|
why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
|
|
31
35
|
remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
|
|
36
|
+
docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
|
|
32
37
|
logEvent: 'auth.link.send_failed',
|
|
33
38
|
},
|
|
34
39
|
'email.send-failed': {
|
|
@@ -37,9 +42,71 @@ const REGISTRY = {
|
|
|
37
42
|
title: 'Magic-link email send failed',
|
|
38
43
|
why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
|
|
39
44
|
remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
|
|
45
|
+
docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
|
|
40
46
|
logEvent: 'auth.link.send_failed',
|
|
41
47
|
},
|
|
48
|
+
'config.bindings-missing': {
|
|
49
|
+
id: 'config.bindings-missing',
|
|
50
|
+
severity: 'blocker',
|
|
51
|
+
title: 'Wrangler bindings are missing',
|
|
52
|
+
why: 'The wrangler config declares no send_email binding named EMAIL or no D1 binding named AUTH_DB, so the magic-link send or the session store has nothing to call and no editor can sign in.',
|
|
53
|
+
remediation: 'Declare the send_email binding as EMAIL and the d1_databases binding as AUTH_DB in wrangler.jsonc (or wrangler.toml), then re-deploy.',
|
|
54
|
+
docsAnchor: 'cloudflare-readiness.md#deploy-the-worker-with-its-bindings',
|
|
55
|
+
},
|
|
56
|
+
'config.observability-off': {
|
|
57
|
+
id: 'config.observability-off',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
title: 'Workers Logs has no sink',
|
|
60
|
+
why: 'observability.enabled is not true in the wrangler config, so the structured log records go nowhere and a runtime failure leaves nothing to read.',
|
|
61
|
+
remediation: 'Set observability.enabled to true in wrangler.jsonc, then re-deploy.',
|
|
62
|
+
docsAnchor: 'cloudflare-readiness.md#turn-on-observability',
|
|
63
|
+
},
|
|
64
|
+
'config.csrf-disable-missing': {
|
|
65
|
+
id: 'config.csrf-disable-missing',
|
|
66
|
+
severity: 'warning',
|
|
67
|
+
title: 'Framework CSRF check is not handed off',
|
|
68
|
+
why: "The CSRF authority is not handed to cairn cleanly. Either svelte.config.js does not carry csrf: { checkOrigin: false }, so SvelteKit's own Origin check runs ahead of cairn's guard and rejects an admin form POST that arrives without an Origin header, or the disable is present with no cairn guard wired in src/hooks.server.ts, which leaves the site with no CSRF protection at all.",
|
|
69
|
+
remediation: "Set csrf: { checkOrigin: false } in svelte.config.js and wire createAuthGuard into src/hooks.server.ts; cairn's guard owns the Origin and double-submit token checks.",
|
|
70
|
+
docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
|
|
71
|
+
},
|
|
72
|
+
'config.site-config-invalid': {
|
|
73
|
+
id: 'config.site-config-invalid',
|
|
74
|
+
severity: 'blocker',
|
|
75
|
+
title: 'Site config does not validate',
|
|
76
|
+
why: 'site.config.yaml fails to parse or fails the URL-policy validation, so the build and the admin cannot resolve the content concepts.',
|
|
77
|
+
remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
|
|
78
|
+
docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
|
|
79
|
+
},
|
|
80
|
+
'edge.hsts-off': {
|
|
81
|
+
id: 'edge.hsts-off',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
title: 'HSTS is off',
|
|
84
|
+
why: 'The zone sends no Strict-Transport-Security header with a meaningful max-age, so browsers do not pin https and a later http visit can still hit the admin guard rejection.',
|
|
85
|
+
remediation: 'Turn on HSTS for the zone under SSL/TLS, Edge Certificates, with a max-age of at least six months.',
|
|
86
|
+
docsAnchor: 'cloudflare-readiness.md#turn-on-hsts',
|
|
87
|
+
},
|
|
88
|
+
'auth.store-unreachable': {
|
|
89
|
+
id: 'auth.store-unreachable',
|
|
90
|
+
severity: 'blocker',
|
|
91
|
+
title: 'Auth store is unreachable',
|
|
92
|
+
why: 'The AUTH_DB D1 database is missing, lacks the auth schema, or holds no owner row, so no magic-link token can be minted and nobody can sign in.',
|
|
93
|
+
remediation: 'Create the database, apply the auth schema with `wrangler d1 execute <db> --remote --file ./migrations/0000_auth.sql`, seed the owner row, and check the AUTH_DB binding id in wrangler.jsonc.',
|
|
94
|
+
docsAnchor: 'cloudflare-readiness.md#provision-the-auth-store',
|
|
95
|
+
},
|
|
96
|
+
'github.app-unreachable': {
|
|
97
|
+
id: 'github.app-unreachable',
|
|
98
|
+
severity: 'blocker',
|
|
99
|
+
title: 'GitHub App is unreachable',
|
|
100
|
+
why: 'The App key fails to parse, the App fails to authenticate, the installation token fails to mint, or the repository refuses a read, so saves and publishes cannot commit.',
|
|
101
|
+
remediation: 'Check GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 against the App settings, and confirm the App is installed on the repository.',
|
|
102
|
+
docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
|
|
103
|
+
logEvent: 'github.unreachable',
|
|
104
|
+
},
|
|
42
105
|
};
|
|
106
|
+
// The registry is shared identity, never working state; freeze every entry and the map itself.
|
|
107
|
+
for (const entry of Object.values(REGISTRY))
|
|
108
|
+
Object.freeze(entry);
|
|
109
|
+
Object.freeze(REGISTRY);
|
|
43
110
|
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
44
111
|
export function condition(id) {
|
|
45
112
|
const found = REGISTRY[id];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cairn-doctor: the environment preflight. A thin shell over index.ts (where the unit tests
|
|
3
|
+
// reach the logic): parse the flags, assemble the context with the real fetch and filesystem,
|
|
4
|
+
// run the default registry plus the opt-in live send, print the report. Bad flags go to
|
|
5
|
+
// stderr with exit 2; a failed check exits 1; a clean or all-skip run exits 0. The codes go
|
|
6
|
+
// through process.exitCode, never process.exit, so a piped stdout flushes the whole report
|
|
7
|
+
// before the process ends.
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { liveSendCheck } from './check-send.js';
|
|
11
|
+
import { contextFromEnv, defaultChecks, formatReport, parseArgs, runDoctor } from './index.js';
|
|
12
|
+
async function main() {
|
|
13
|
+
let args;
|
|
14
|
+
try {
|
|
15
|
+
args = parseArgs(process.argv.slice(2));
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
19
|
+
process.exitCode = 2;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const ctx = {
|
|
24
|
+
...contextFromEnv(process.env, args, cwd),
|
|
25
|
+
fetch: globalThis.fetch,
|
|
26
|
+
readFile: async (relPath) => {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(resolve(cwd, relPath), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === 'ENOENT')
|
|
32
|
+
return null;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const checks = defaultChecks();
|
|
38
|
+
if (args.sendTest)
|
|
39
|
+
checks.push(liveSendCheck(args.sendTest));
|
|
40
|
+
const { results, failed } = await runDoctor(checks, ctx);
|
|
41
|
+
console.log(formatReport(results));
|
|
42
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
await main();
|
|
@@ -0,0 +1,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
|
+
}
|