@glw907/cairn-cms 0.37.1 → 0.40.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 (69) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +6 -5
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +13 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +575 -70
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +202 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/LoginPage.svelte +16 -4
  16. package/dist/components/LoginPage.svelte.d.ts +3 -1
  17. package/dist/components/MarkdownEditor.svelte +80 -34
  18. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  19. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  20. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  21. package/dist/components/RenameDialog.svelte +13 -4
  22. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  23. package/dist/components/WebLinkDialog.svelte +89 -0
  24. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  25. package/dist/components/cairn-admin.css +353 -4
  26. package/dist/components/editor-highlight.d.ts +9 -0
  27. package/dist/components/editor-highlight.js +62 -0
  28. package/dist/components/markdown-directives.d.ts +7 -0
  29. package/dist/components/markdown-directives.js +22 -0
  30. package/dist/components/markdown-format.d.ts +1 -1
  31. package/dist/components/markdown-format.js +91 -12
  32. package/dist/content/pending.d.ts +9 -0
  33. package/dist/content/pending.js +24 -0
  34. package/dist/diagnostics/conditions.js +16 -0
  35. package/dist/email.d.ts +20 -1
  36. package/dist/email.js +25 -0
  37. package/dist/github/branches.d.ts +11 -0
  38. package/dist/github/branches.js +75 -0
  39. package/dist/log/events.d.ts +1 -1
  40. package/dist/sveltekit/auth-routes.d.ts +16 -3
  41. package/dist/sveltekit/auth-routes.js +47 -28
  42. package/dist/sveltekit/content-routes.d.ts +22 -1
  43. package/dist/sveltekit/content-routes.js +312 -72
  44. package/dist/sveltekit/index.d.ts +1 -1
  45. package/package.json +3 -2
  46. package/src/lib/components/AdminLayout.svelte +53 -0
  47. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  48. package/src/lib/components/ConceptList.svelte +13 -3
  49. package/src/lib/components/DeleteDialog.svelte +18 -7
  50. package/src/lib/components/EditPage.svelte +575 -70
  51. package/src/lib/components/EditorToolbar.svelte +202 -29
  52. package/src/lib/components/LinkPicker.svelte +14 -6
  53. package/src/lib/components/LoginPage.svelte +16 -4
  54. package/src/lib/components/MarkdownEditor.svelte +80 -34
  55. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  56. package/src/lib/components/RenameDialog.svelte +13 -4
  57. package/src/lib/components/WebLinkDialog.svelte +89 -0
  58. package/src/lib/components/cairn-admin.css +26 -4
  59. package/src/lib/components/editor-highlight.ts +67 -0
  60. package/src/lib/components/markdown-directives.ts +23 -0
  61. package/src/lib/components/markdown-format.ts +118 -13
  62. package/src/lib/content/pending.ts +24 -0
  63. package/src/lib/diagnostics/conditions.ts +16 -0
  64. package/src/lib/email.ts +31 -1
  65. package/src/lib/github/branches.ts +83 -0
  66. package/src/lib/log/events.ts +3 -0
  67. package/src/lib/sveltekit/auth-routes.ts +59 -29
  68. package/src/lib/sveltekit/content-routes.ts +391 -73
  69. package/src/lib/sveltekit/index.ts +1 -1
@@ -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' | 'heading' | 'quote' | 'ul' | 'link';
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
- const LINE_PREFIX = { heading: '# ', quote: '> ', ul: '- ' };
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
- const marker = WRAP[kind];
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
- const prefix = LINE_PREFIX[kind];
28
- const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
29
- const region = doc.slice(lineStart, to);
30
- const prefixed = region.replace(/^/gm, prefix);
31
- const added = prefixed.length - region.length;
32
- return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
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
+ }
@@ -23,6 +23,22 @@ const REGISTRY = {
23
23
  remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
24
24
  logEvent: 'guard.rejected',
25
25
  },
26
+ 'email.sender-not-onboarded': {
27
+ id: 'email.sender-not-onboarded',
28
+ severity: 'blocker',
29
+ title: 'Email sending domain is not onboarded',
30
+ 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
+ remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
32
+ logEvent: 'auth.link.send_failed',
33
+ },
34
+ 'email.send-failed': {
35
+ id: 'email.send-failed',
36
+ severity: 'blocker',
37
+ title: 'Magic-link email send failed',
38
+ 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
+ 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.',
40
+ logEvent: 'auth.link.send_failed',
41
+ },
26
42
  };
27
43
  /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
28
44
  export function condition(id) {
package/dist/email.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AuthEnv } from './auth/types.js';
2
+ import { CairnError } from './diagnostics/index.js';
2
3
  export type { AuthEnv };
3
4
  /** The message a built magic-link email carries. */
4
5
  export interface MagicLinkMessage {
@@ -14,7 +15,9 @@ export interface AuthBranding {
14
15
  from: string;
15
16
  replyTo?: string;
16
17
  }
17
- /** The injected send. Production uses `cloudflareSend`; tests pass a sink. */
18
+ /** The injected send. Production uses `cloudflareSend`; tests pass a sink. A thrown error's
19
+ * text reaches the structured log (scrubbed and truncated), so a custom sender must not embed
20
+ * the message body or the magic link in what it throws. */
18
21
  export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
19
22
  /** Build the confirmation email. The link is the only action; the copy stays plain. */
20
23
  export declare function buildMagicLinkMessage(input: {
@@ -24,3 +27,19 @@ export declare function buildMagicLinkMessage(input: {
24
27
  }): MagicLinkMessage;
25
28
  /** The production send: Cloudflare Email Sending through the EMAIL binding. */
26
29
  export declare const cloudflareSend: SendMagicLink;
30
+ /**
31
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
32
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
33
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
34
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
35
+ * undefined and the record still logs cleanly.
36
+ */
37
+ export declare function errorCode(err: unknown): string | undefined;
38
+ /**
39
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
40
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
41
+ * also been observed throwing the bare "not a verified address" string with no code, so that
42
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
43
+ * the conditionId and code, and returns a send_error status.
44
+ */
45
+ export declare function emailSendFailure(err: unknown): CairnError;
package/dist/email.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CairnError } from './diagnostics/index.js';
1
2
  /** Escape the five HTML-significant characters. */
2
3
  function escapeHtml(value) {
3
4
  return value
@@ -23,3 +24,27 @@ export const cloudflareSend = async (env, message) => {
23
24
  throw new Error('EMAIL binding is not configured');
24
25
  await env.EMAIL.send(message);
25
26
  };
27
+ /**
28
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
29
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
30
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
31
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
32
+ * undefined and the record still logs cleanly.
33
+ */
34
+ export function errorCode(err) {
35
+ if (typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string') {
36
+ return err.code;
37
+ }
38
+ return String(err).match(/\bE_[A-Z][A-Z_]*\b/)?.[0];
39
+ }
40
+ /**
41
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
42
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
43
+ * also been observed throwing the bare "not a verified address" string with no code, so that
44
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
45
+ * the conditionId and code, and returns a send_error status.
46
+ */
47
+ export function emailSendFailure(err) {
48
+ const onboarding = errorCode(err) === 'E_SENDER_NOT_VERIFIED' || String(err).includes('not a verified address');
49
+ return new CairnError(onboarding ? 'email.sender-not-onboarded' : 'email.send-failed', { cause: err });
50
+ }
@@ -0,0 +1,11 @@
1
+ import type { RepoRef } from './types.js';
2
+ /** The head commit sha of a branch, or null when the branch does not exist. */
3
+ export declare function branchHeadSha(repo: RepoRef, branch: string, token: string): Promise<string | null>;
4
+ /** Create `branch` pointing at `fromSha`. Throws on any failure including an existing ref. */
5
+ export declare function createBranch(repo: RepoRef, branch: string, fromSha: string, token: string): Promise<void>;
6
+ /** Delete `branch`. A 404 (already gone) is success: the desired state holds. */
7
+ export declare function deleteBranch(repo: RepoRef, branch: string, token: string): Promise<void>;
8
+ /** Branch names under `prefix`, sorted. The matching-refs API paginates at 30 by default, so a
9
+ * site with 31+ pending entries would silently truncate; request the 100-per-page maximum and
10
+ * follow the Link rel="next" chain until exhausted. */
11
+ export declare function listBranches(repo: RepoRef, prefix: string, token: string): Promise<string[]>;
@@ -0,0 +1,75 @@
1
+ const API = 'https://api.github.com';
2
+ function headers(token) {
3
+ return {
4
+ Accept: 'application/vnd.github+json',
5
+ 'User-Agent': 'cairn-cms',
6
+ 'X-GitHub-Api-Version': '2022-11-28',
7
+ Authorization: `Bearer ${token}`,
8
+ 'Content-Type': 'application/json',
9
+ };
10
+ }
11
+ function gitUrl(repo, suffix) {
12
+ return `${API}/repos/${repo.owner}/${repo.repo}/git/${suffix}`;
13
+ }
14
+ /** The head commit sha of a branch, or null when the branch does not exist. */
15
+ export async function branchHeadSha(repo, branch, token) {
16
+ const res = await fetch(gitUrl(repo, `ref/heads/${encodeURIComponent(branch)}`), { headers: headers(token) });
17
+ // The 404 probe is a hot path (every editLoad); drain the body so the connection frees
18
+ // immediately instead of pinning one of workerd's six until GC.
19
+ if (res.status === 404) {
20
+ await res.body?.cancel();
21
+ return null;
22
+ }
23
+ if (!res.ok)
24
+ throw new Error(`GitHub ref ${branch} failed: ${res.status} ${await res.text()}`);
25
+ return (await res.json()).object.sha;
26
+ }
27
+ /** Create `branch` pointing at `fromSha`. Throws on any failure including an existing ref. */
28
+ export async function createBranch(repo, branch, fromSha, token) {
29
+ const res = await fetch(gitUrl(repo, 'refs'), {
30
+ method: 'POST',
31
+ headers: headers(token),
32
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: fromSha }),
33
+ });
34
+ if (!res.ok)
35
+ throw new Error(`GitHub branch create ${branch} failed: ${res.status} ${await res.text()}`);
36
+ await res.body?.cancel();
37
+ }
38
+ /** Delete `branch`. A 404 (already gone) is success: the desired state holds. */
39
+ export async function deleteBranch(repo, branch, token) {
40
+ const res = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(branch)}`), {
41
+ method: 'DELETE',
42
+ headers: headers(token),
43
+ });
44
+ if (!res.ok && res.status !== 404) {
45
+ throw new Error(`GitHub branch delete ${branch} failed: ${res.status} ${await res.text()}`);
46
+ }
47
+ await res.body?.cancel();
48
+ }
49
+ /** The rel="next" URL from a GitHub Link header, or null on the last page. */
50
+ function nextPageUrl(link) {
51
+ if (!link)
52
+ return null;
53
+ for (const part of link.split(',')) {
54
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
55
+ if (match)
56
+ return match[1];
57
+ }
58
+ return null;
59
+ }
60
+ /** Branch names under `prefix`, sorted. The matching-refs API paginates at 30 by default, so a
61
+ * site with 31+ pending entries would silently truncate; request the 100-per-page maximum and
62
+ * follow the Link rel="next" chain until exhausted. */
63
+ export async function listBranches(repo, prefix, token) {
64
+ const names = [];
65
+ let url = `${gitUrl(repo, `matching-refs/heads/${prefix}`)}?per_page=100`;
66
+ while (url) {
67
+ const res = await fetch(url, { headers: headers(token) });
68
+ if (!res.ok)
69
+ throw new Error(`GitHub matching-refs ${prefix} failed: ${res.status} ${await res.text()}`);
70
+ const refs = (await res.json());
71
+ names.push(...refs.map((r) => r.ref.replace(/^refs\/heads\//, '')));
72
+ url = nextPageUrl(res.headers.get('Link'));
73
+ }
74
+ return names;
75
+ }
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'guard.rejected';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'guard.rejected';
@@ -4,15 +4,28 @@ export interface AuthRoutesConfig {
4
4
  branding: AuthBranding;
5
5
  send?: SendMagicLink;
6
6
  }
7
+ /**
8
+ * The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
9
+ * own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
10
+ * identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
11
+ */
12
+ export type RequestResult = {
13
+ status: 'sent';
14
+ sent: true;
15
+ } | {
16
+ status: 'send_error';
17
+ sent: false;
18
+ } | {
19
+ status: 'throttled';
20
+ sent: false;
21
+ };
7
22
  export declare function createAuthRoutes(config: AuthRoutesConfig): {
8
23
  loginLoad: (event: RequestContext) => {
9
24
  siteName: string;
10
25
  error: string | null;
11
26
  csrf: string;
12
27
  };
13
- requestAction: (event: RequestContext) => Promise<{
14
- sent: true;
15
- }>;
28
+ requestAction: (event: RequestContext) => Promise<RequestResult>;
16
29
  confirmLoad: (event: RequestContext) => {
17
30
  token: string;
18
31
  siteName: string;
@@ -5,15 +5,26 @@ import { redirect } from '@sveltejs/kit';
5
5
  import { requireOrigin, requireDb } from '../env.js';
6
6
  import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
7
7
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
8
- import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
8
+ import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode } from '../email.js';
9
9
  import { issueCsrfToken } from './csrf.js';
10
10
  import { log } from '../log/index.js';
11
+ /**
12
+ * The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
13
+ * an injection seam, and a custom sender's thrown error may embed the failed message and with it
14
+ * the magic link. Scrub any token query value and cap the length, so the documented "records never
15
+ * carry a token" guarantee holds for the seam too.
16
+ */
17
+ function scrubSendError(err) {
18
+ return String(err)
19
+ .replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
20
+ .slice(0, 300);
21
+ }
11
22
  export function createAuthRoutes(config) {
12
23
  const send = config.send ?? cloudflareSend;
13
24
  /**
14
- * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
15
- * and emails the confirmation link. The response is identical whether or not the email is
16
- * allow-listed, so the endpoint never leaks membership.
25
+ * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
26
+ * emails the confirmation link, and awaits the send so the status reflects its outcome. The
27
+ * neutral and send-ok responses are identical, so the common case never leaks membership.
17
28
  */
18
29
  async function requestAction(event) {
19
30
  const env = event.platform?.env ?? {};
@@ -26,31 +37,39 @@ export function createAuthRoutes(config) {
26
37
  // fits well under this; only a junk payload is truncated.
27
38
  log.info('auth.link.requested', { email: email.slice(0, 320) });
28
39
  const editor = email ? await findEditor(db, email) : null;
29
- if (editor) {
30
- const now = Date.now();
31
- // Per-email cooldown: skip the reissue and send when a token for this email was issued within
32
- // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
33
- // the non-leak property holds.
34
- if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
35
- const token = generateToken();
36
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
37
- log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
38
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
39
- // The token row is the security-critical write the email depends on, so it is awaited. The
40
- // send is a post-response side effect, handed to waitUntil so a slow email provider does not
41
- // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
42
- // failure is logged so observability survives a backgrounded send.
43
- const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => log.error('auth.link.send_failed', { email, error: String(err) }));
44
- // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
45
- // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
46
- const ctx = event.platform?.ctx ?? event.platform?.context;
47
- if (ctx?.waitUntil)
48
- ctx.waitUntil(sending);
49
- else
50
- await sending;
51
- }
40
+ // Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
41
+ // membership. Response timing still differs (the editor path awaits the send), the side-channel
42
+ // the design accepts as strictly weaker than the explicit throttled signal below.
43
+ if (!editor)
44
+ return { status: 'sent', sent: true };
45
+ const now = Date.now();
46
+ // Per-email cooldown: an editor who requested within the window gets the throttled signal rather
47
+ // than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
48
+ if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
49
+ return { status: 'throttled', sent: false };
50
+ }
51
+ const token = generateToken();
52
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
53
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
54
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
55
+ // The token row is the security-critical write the email depends on, so it is awaited first.
56
+ // The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
57
+ // confirm the link went out before telling an editor to check their inbox. The cost is one
58
+ // email-API round trip on the login POST, the right trade for a login flow.
59
+ try {
60
+ await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
61
+ }
62
+ catch (err) {
63
+ // Map the binding failure to its registered condition (carried as a CairnError with the
64
+ // original as cause), and log the greppable code plus the conditionId so the next onboarding
65
+ // gap reads straight to its fix. The editor sees only a generic message, never this detail.
66
+ const failure = emailSendFailure(err);
67
+ log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
68
+ // A plain 200 with a status field, not fail(): the result stays one uniform union for the
69
+ // page, and the failure is already observable through the error-level log record.
70
+ return { status: 'send_error', sent: false };
52
71
  }
53
- return { sent: true };
72
+ return { status: 'sent', sent: true };
54
73
  }
55
74
  /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
56
75
  function loginLoad(event) {
@@ -29,6 +29,12 @@ export interface LayoutData {
29
29
  collapsedNav: string[];
30
30
  /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
31
31
  csrf: string;
32
+ /** Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
33
+ * Null when GitHub is unreachable, so the topbar hides the action rather than lying. */
34
+ pendingEntries: {
35
+ concept: string;
36
+ id: string;
37
+ }[] | null;
32
38
  }
33
39
  /** One row in a concept's list view. */
34
40
  export interface EntrySummary {
@@ -36,6 +42,8 @@ export interface EntrySummary {
36
42
  title: string;
37
43
  date: string | null;
38
44
  draft: boolean;
45
+ /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
46
+ status: 'published' | 'edited' | 'new';
39
47
  }
40
48
  /** The concept list view's data. */
41
49
  export interface ListData {
@@ -48,6 +56,8 @@ export interface ListData {
48
56
  error: string | null;
49
57
  /** A create-form bounce error read from `?error`. */
50
58
  formError: string | null;
59
+ /** The entry count from a publish-all redirect (`?publishedAll=`), for the list page's flash. */
60
+ publishedAll: number | null;
51
61
  }
52
62
  /** The editor's data. `frontmatter` holds form-ready values (dates already `YYYY-MM-DD`). */
53
63
  export interface EditData {
@@ -69,6 +79,14 @@ export interface EditData {
69
79
  linkTargets: LinkTarget[];
70
80
  /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
71
81
  inboundLinks: InboundLink[];
82
+ /** True when the entry has a pending branch, so the body above came from that branch. */
83
+ pending: boolean;
84
+ /** True when the entry file exists on the default branch (the live site shows it). */
85
+ published: boolean;
86
+ /** True after a publish redirect (`?published=1`), for the confirmation strip. */
87
+ publishedFlash: boolean;
88
+ /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
89
+ discardedFlash: boolean;
72
90
  }
73
91
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
74
92
  export interface ContentEvent {
@@ -91,12 +109,15 @@ export interface ContentRoutesDeps {
91
109
  mintToken?: (env: GithubKeyEnv) => Promise<string>;
92
110
  }
93
111
  export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
94
- layoutLoad: (event: ContentEvent) => LayoutData;
112
+ layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
95
113
  indexRedirect: () => never;
96
114
  listLoad: (event: ContentEvent) => Promise<ListData>;
97
115
  createAction: (event: ContentEvent) => Promise<never>;
98
116
  editLoad: (event: ContentEvent) => Promise<EditData>;
99
117
  saveAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
118
+ publishAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
119
+ publishAllAction: (event: ContentEvent) => Promise<never>;
120
+ discardAction: (event: ContentEvent) => Promise<never>;
100
121
  deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
101
122
  listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
102
123
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;