@glw907/cairn-cms 0.40.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -3
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +18 -10
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +47 -19
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/EditorToolbar.svelte +4 -0
  14. package/dist/components/LoginPage.svelte +2 -2
  15. package/dist/components/LoginPage.svelte.d.ts +1 -1
  16. package/dist/components/ManageEditors.svelte +4 -3
  17. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +1 -0
  20. package/dist/components/link-completion.js +10 -3
  21. package/dist/components/markdown-format.d.ts +0 -8
  22. package/dist/components/markdown-format.js +0 -28
  23. package/dist/content/links.d.ts +8 -0
  24. package/dist/content/links.js +28 -0
  25. package/dist/content/types.d.ts +2 -2
  26. package/dist/delivery/data.d.ts +3 -5
  27. package/dist/delivery/data.js +2 -3
  28. package/dist/delivery/feeds.js +1 -7
  29. package/dist/delivery/index.d.ts +2 -2
  30. package/dist/delivery/index.js +1 -1
  31. package/dist/delivery/manifest.d.ts +0 -5
  32. package/dist/delivery/manifest.js +5 -16
  33. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  34. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  35. package/dist/delivery/site-indexes.d.ts +3 -3
  36. package/dist/delivery/site-indexes.js +3 -3
  37. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  38. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  39. package/dist/delivery/sitemap.js +1 -3
  40. package/dist/delivery/xml.d.ts +2 -0
  41. package/dist/delivery/xml.js +11 -0
  42. package/dist/diagnostics/conditions.d.ts +8 -1
  43. package/dist/diagnostics/conditions.js +68 -1
  44. package/dist/doctor/bin.d.ts +2 -0
  45. package/dist/doctor/bin.js +44 -0
  46. package/dist/doctor/check-send.d.ts +3 -0
  47. package/dist/doctor/check-send.js +43 -0
  48. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  49. package/dist/doctor/checks-cloudflare.js +200 -0
  50. package/dist/doctor/checks-github.d.ts +2 -0
  51. package/dist/doctor/checks-github.js +57 -0
  52. package/dist/doctor/checks-local.d.ts +5 -0
  53. package/dist/doctor/checks-local.js +112 -0
  54. package/dist/doctor/cloudflare-api.d.ts +7 -0
  55. package/dist/doctor/cloudflare-api.js +24 -0
  56. package/dist/doctor/index.d.ts +23 -0
  57. package/dist/doctor/index.js +68 -0
  58. package/dist/doctor/report.d.ts +5 -0
  59. package/dist/doctor/report.js +21 -0
  60. package/dist/doctor/run.d.ts +8 -0
  61. package/dist/doctor/run.js +20 -0
  62. package/dist/doctor/types.d.ts +41 -0
  63. package/dist/doctor/types.js +10 -0
  64. package/dist/doctor/wrangler-config.d.ts +12 -0
  65. package/dist/doctor/wrangler-config.js +125 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +1 -1
  68. package/dist/env.js +3 -2
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/signing.d.ts +3 -1
  74. package/dist/github/signing.js +13 -5
  75. package/dist/github/types.d.ts +2 -0
  76. package/dist/github/types.js +4 -0
  77. package/dist/log/events.d.ts +1 -1
  78. package/dist/nav/site-config.d.ts +2 -0
  79. package/dist/nav/site-config.js +2 -0
  80. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  81. package/dist/sveltekit/admin-dispatch.js +62 -0
  82. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  83. package/dist/sveltekit/cairn-admin.js +126 -0
  84. package/dist/sveltekit/condition-response.d.ts +1 -0
  85. package/dist/sveltekit/condition-response.js +25 -0
  86. package/dist/sveltekit/content-routes.d.ts +34 -14
  87. package/dist/sveltekit/content-routes.js +78 -44
  88. package/dist/sveltekit/guard.js +15 -3
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +19 -10
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/package.json +10 -4
  98. package/src/lib/ambient.ts +19 -0
  99. package/src/lib/components/AdminLayout.svelte +6 -8
  100. package/src/lib/components/CairnAdmin.svelte +67 -0
  101. package/src/lib/components/ConceptList.svelte +18 -10
  102. package/src/lib/components/ConfirmPage.svelte +1 -1
  103. package/src/lib/components/EditPage.svelte +47 -19
  104. package/src/lib/components/EditorToolbar.svelte +4 -0
  105. package/src/lib/components/LoginPage.svelte +2 -2
  106. package/src/lib/components/ManageEditors.svelte +4 -3
  107. package/src/lib/components/index.ts +1 -0
  108. package/src/lib/components/link-completion.ts +10 -3
  109. package/src/lib/components/markdown-format.ts +0 -27
  110. package/src/lib/content/links.ts +28 -0
  111. package/src/lib/content/types.ts +2 -2
  112. package/src/lib/delivery/data.ts +3 -5
  113. package/src/lib/delivery/feeds.ts +1 -8
  114. package/src/lib/delivery/index.ts +2 -2
  115. package/src/lib/delivery/manifest.ts +5 -18
  116. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  117. package/src/lib/delivery/site-indexes.ts +6 -6
  118. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  119. package/src/lib/delivery/sitemap.ts +1 -4
  120. package/src/lib/delivery/xml.ts +12 -0
  121. package/src/lib/diagnostics/conditions.ts +75 -2
  122. package/src/lib/doctor/bin.ts +45 -0
  123. package/src/lib/doctor/check-send.ts +43 -0
  124. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  125. package/src/lib/doctor/checks-github.ts +63 -0
  126. package/src/lib/doctor/checks-local.ts +119 -0
  127. package/src/lib/doctor/cloudflare-api.ts +33 -0
  128. package/src/lib/doctor/index.ts +93 -0
  129. package/src/lib/doctor/report.ts +30 -0
  130. package/src/lib/doctor/run.ts +23 -0
  131. package/src/lib/doctor/types.ts +52 -0
  132. package/src/lib/doctor/wrangler-config.ts +142 -0
  133. package/src/lib/email.ts +4 -11
  134. package/src/lib/env.ts +3 -2
  135. package/src/lib/escape.ts +12 -0
  136. package/src/lib/github/credentials.ts +6 -2
  137. package/src/lib/github/signing.ts +13 -6
  138. package/src/lib/github/types.ts +5 -0
  139. package/src/lib/log/events.ts +2 -0
  140. package/src/lib/nav/site-config.ts +3 -0
  141. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  142. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  143. package/src/lib/sveltekit/condition-response.ts +27 -1
  144. package/src/lib/sveltekit/content-routes.ts +121 -55
  145. package/src/lib/sveltekit/guard.ts +16 -3
  146. package/src/lib/sveltekit/https-required-page.ts +2 -1
  147. package/src/lib/sveltekit/index.ts +6 -0
  148. package/src/lib/sveltekit/nav-routes.ts +21 -11
  149. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  150. package/src/lib/sveltekit/types.ts +16 -7
  151. package/dist/delivery/paginate.d.ts +0 -12
  152. package/dist/delivery/paginate.js +0 -20
  153. package/dist/render/index.d.ts +0 -5
  154. package/dist/render/index.js +0 -8
  155. package/src/lib/delivery/paginate.ts +0 -32
  156. package/src/lib/render/index.ts +0 -8
@@ -79,7 +79,7 @@ export async function installationToken(creds: AppCredentials): Promise<string>
79
79
  }
80
80
 
81
81
  interface CachedToken {
82
- token: string;
82
+ token: Promise<string>;
83
83
  expiresAt: number;
84
84
  }
85
85
 
@@ -89,7 +89,9 @@ interface CachedToken {
89
89
  * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
90
90
  * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
91
91
  * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
92
- * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
92
+ * lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
93
+ * promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
94
+ * a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
93
95
  * cache is testable with no network call and no real clock.
94
96
  */
95
97
  export function createInstallationTokenCache(
@@ -98,12 +100,17 @@ export function createInstallationTokenCache(
98
100
  ttlMs = 55 * 60 * 1000,
99
101
  ): (creds: AppCredentials) => Promise<string> {
100
102
  const cache = new Map<string, CachedToken>();
101
- return async function get(creds: AppCredentials): Promise<string> {
103
+ return function get(creds: AppCredentials): Promise<string> {
102
104
  const hit = cache.get(creds.installationId);
103
105
  if (hit && hit.expiresAt > now()) return hit.token;
104
- const token = await mint(creds);
105
- cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
106
- return token;
106
+ const entry: CachedToken = { token: mint(creds), expiresAt: now() + ttlMs };
107
+ cache.set(creds.installationId, entry);
108
+ // Evict only this entry on rejection: a newer entry that replaced it must survive. The
109
+ // caller's await surfaces the rejection itself, so this side handler swallows nothing.
110
+ entry.token.catch(() => {
111
+ if (cache.get(creds.installationId) === entry) cache.delete(creds.installationId);
112
+ });
113
+ return entry.token;
107
114
  };
108
115
  }
109
116
 
@@ -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
+ }
@@ -10,7 +10,9 @@ 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'
17
+ | 'github.unreachable'
16
18
  | 'guard.rejected';
@@ -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
+ }
@@ -0,0 +1,177 @@
1
+ // The single-mount admin facade. One factory closes over the composed runtime, instantiates
2
+ // the existing per-surface route factories (auth, content, editors, nav), and serves every
3
+ // admin view through the one load and one actions record a site's catch-all /admin/[...path]
4
+ // route exports. The path authority is admin-dispatch's parseAdminPath; this module only maps
5
+ // each view to the wrapped load it delegates to, and each named action validates that the
6
+ // parsed view supports it before delegating to the same wrapped factories.
7
+ import { error } from '@sveltejs/kit';
8
+ import { parseAdminPath, type AdminView } from './admin-dispatch.js';
9
+ import { createAuthRoutes } from './auth-routes.js';
10
+ import {
11
+ createContentRoutes,
12
+ type ContentEvent,
13
+ type ContentRoutesDeps,
14
+ type LayoutData,
15
+ type ListData,
16
+ type EditData,
17
+ } from './content-routes.js';
18
+ import { createEditorRoutes } from './editors-routes.js';
19
+ import { createNavRoutes, type NavLoadData } from './nav-routes.js';
20
+ import type { AuthBranding, SendMagicLink } from '../email.js';
21
+ import type { AuthEnv, Editor } from '../auth/types.js';
22
+ import type { GithubKeyEnv } from '../github/credentials.js';
23
+ import type { CairnRuntime } from '../content/types.js';
24
+ import type { CookieJar, EventBase } from './types.js';
25
+
26
+ /**
27
+ * The structural event the single-mount load reads: the union of what the wrapped loads need
28
+ * (ContentEvent minus params, which the dispatcher synthesizes, plus RequestContext's cookies
29
+ * and setHeaders). A real SvelteKit RequestEvent satisfies it.
30
+ */
31
+ export interface AdminEvent extends EventBase<GithubKeyEnv & AuthEnv> {
32
+ cookies: CookieJar;
33
+ setHeaders(headers: Record<string, string>): void;
34
+ }
35
+
36
+ /** Injectable dependencies. Branding defaults from the runtime's siteName and sender, so a
37
+ * site overrides it only to change the magic-link email identity; `send` and `mintToken`
38
+ * are the same seams the underlying factories take. */
39
+ export interface CairnAdminDeps {
40
+ branding?: AuthBranding;
41
+ send?: SendMagicLink;
42
+ mintToken?: ContentRoutesDeps['mintToken'];
43
+ }
44
+
45
+ /**
46
+ * One admin view's data, discriminated for the admin page component's switch. The public
47
+ * views (login, confirm) carry no layout; every authed view pairs the shared layout with its
48
+ * page data, the same shapes the per-surface loads have always returned.
49
+ */
50
+ export type AdminData =
51
+ | { view: 'login'; page: { siteName: string; error: string | null; csrf: string } }
52
+ | { view: 'confirm'; page: { token: string; siteName: string; error: string | null; csrf: string } }
53
+ | { view: 'list'; layout: LayoutData; page: ListData }
54
+ | { view: 'edit'; layout: LayoutData; page: EditData }
55
+ | { view: 'editors'; layout: LayoutData; page: { editors: Editor[]; self: string } }
56
+ | { view: 'nav'; layout: LayoutData; page: NavLoadData };
57
+
58
+ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {}) {
59
+ // The runtime already composes the site name and the sender identity, so the magic-link
60
+ // branding needs no second copy of either unless a site overrides it.
61
+ const branding: AuthBranding = deps.branding ?? {
62
+ siteName: runtime.siteName,
63
+ from: runtime.sender.from,
64
+ replyTo: runtime.sender.replyTo,
65
+ };
66
+ const auth = createAuthRoutes({ branding, send: deps.send });
67
+ const content = createContentRoutes(runtime, { mintToken: deps.mintToken });
68
+ const editors = createEditorRoutes();
69
+ // The nav surface exists only when the site configures a menu; without one its view is a 404.
70
+ const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
71
+
72
+ /** Build the event a wrapped content load reads. The catch-all route carries only a rest
73
+ * param, so `concept` and `id` are synthesized from the parsed view. The override names
74
+ * each field explicitly rather than spreading: a real RequestEvent's fields can sit behind
75
+ * getters a bare spread copies poorly, and the structural ContentEvent contract needs only
76
+ * these. */
77
+ function contentEvent(event: AdminEvent, params: Record<string, string>): ContentEvent {
78
+ return {
79
+ url: event.url,
80
+ params,
81
+ request: event.request,
82
+ locals: event.locals,
83
+ platform: event.platform,
84
+ cookies: event.cookies,
85
+ };
86
+ }
87
+
88
+ /** Serve the admin view the pathname names, or a 404 for any shape the parser refuses.
89
+ * The authed views run the layout load and the view load concurrently; both mint a GitHub
90
+ * token, and the installation-token cache coalesces the mints into one signing. */
91
+ async function load(event: AdminEvent): Promise<AdminData> {
92
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
93
+ if (!view) throw error(404, 'Not found');
94
+ switch (view.view) {
95
+ case 'index':
96
+ return content.indexRedirect();
97
+ case 'login':
98
+ return { view: 'login', page: auth.loginLoad(event) };
99
+ case 'confirm':
100
+ return { view: 'confirm', page: auth.confirmLoad(event) };
101
+ case 'list': {
102
+ const delegated = contentEvent(event, { concept: view.concept.id });
103
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.listLoad(delegated)]);
104
+ return { view: 'list', layout, page };
105
+ }
106
+ case 'edit': {
107
+ const delegated = contentEvent(event, { concept: view.concept.id, id: view.id });
108
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.editLoad(delegated)]);
109
+ return { view: 'edit', layout, page };
110
+ }
111
+ case 'editors': {
112
+ // editorsLoad gates itself with requireOwner, so the dispatcher adds no second gate.
113
+ const [layout, page] = await Promise.all([
114
+ content.layoutLoad(contentEvent(event, {})),
115
+ editors.editorsLoad(event),
116
+ ]);
117
+ return { view: 'editors', layout, page };
118
+ }
119
+ case 'nav': {
120
+ if (!nav) throw error(404, 'Not found');
121
+ const delegated = contentEvent(event, {});
122
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
123
+ return { view: 'nav', layout, page };
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
129
+ * as load does, 404 on a null parse or a view outside the allowed set, then hand the
130
+ * narrowed view to the delegate. */
131
+ function viewAction<V extends AdminView['view'], R>(
132
+ allowed: readonly V[],
133
+ delegate: (event: AdminEvent, view: Extract<AdminView, { view: V }>) => Promise<R>,
134
+ ): (event: AdminEvent) => Promise<R> {
135
+ return async (event) => {
136
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
137
+ if (!view || !(allowed as readonly string[]).includes(view.view)) throw error(404, 'Not found');
138
+ // The includes check above proves the membership the cast asserts.
139
+ return delegate(event, view as Extract<AdminView, { view: V }>);
140
+ };
141
+ }
142
+
143
+ // The topbar posts publishAll from every authed admin page; login and confirm may not.
144
+ const authedViews = ['list', 'edit', 'editors', 'nav'] as const;
145
+ // An editor signs out from wherever they are, so logout accepts any parsed view.
146
+ const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'] as const;
147
+
148
+ /** The full admin action vocabulary, one named async function per action, so a site's
149
+ * catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
150
+ * validate the view, synthesize the params the wrapped action reads, delegate. The
151
+ * editor actions gate themselves with requireOwner, so no second gate is added here. */
152
+ const actions = {
153
+ request: viewAction(['login'], (event) => auth.requestAction(event)),
154
+ confirm: viewAction(['confirm'], (event) => auth.confirmAction(event)),
155
+ logout: viewAction(anyView, (event) => auth.logoutAction(event)),
156
+ create: viewAction(['list'], (event, view) => content.createAction(contentEvent(event, { concept: view.concept.id }))),
157
+ save: viewAction(['edit', 'nav'], (event, view) => {
158
+ if (view.view === 'edit') return content.saveAction(contentEvent(event, { concept: view.concept.id, id: view.id }));
159
+ if (!nav) throw error(404, 'Not found');
160
+ return nav.navSave(contentEvent(event, {}));
161
+ }),
162
+ publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
163
+ discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
164
+ rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
165
+ delete: viewAction(['edit', 'list'], (event, view) =>
166
+ view.view === 'edit'
167
+ ? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
168
+ : content.listDeleteAction(contentEvent(event, { concept: view.concept.id })),
169
+ ),
170
+ publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
171
+ addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
172
+ removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
173
+ setRole: viewAction(['editors'], (event) => editors.setRoleAction(event)),
174
+ };
175
+
176
+ return { load, actions };
177
+ }
@@ -4,15 +4,38 @@
4
4
  import { brandedAdminPage } from './admin-response.js';
5
5
  import { httpsRequiredPage } from './https-required-page.js';
6
6
  import { csrfRequiredPage } from './csrf-required-page.js';
7
- import { condition } from '../diagnostics/index.js';
7
+ import { escapeHtml } from '../escape.js';
8
+ import { renderStaticAdminPage } from './static-admin-page.js';
9
+ import { condition, type CairnCondition } from '../diagnostics/index.js';
8
10
 
9
11
  /** The guard.rejected reasons, each mapped to its registered condition id. */
10
12
  export const REASON_CONDITION = {
11
13
  https: 'edge.https-not-forced',
12
14
  csrf: 'auth.csrf-token-invalid',
13
15
  origin: 'auth.csrf-origin-mismatch',
16
+ bindings: 'config.bindings-missing',
14
17
  } as const;
15
18
 
19
+ /**
20
+ * A branded page for an operator fault, built straight from the registered condition's fields so
21
+ * the served copy, the doctor's report, and the readiness checklist say the same thing.
22
+ */
23
+ function conditionFaultPage(cond: CairnCondition): string {
24
+ const inner = `
25
+ <span class="eyebrow">
26
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
27
+ Site setup required
28
+ </span>
29
+ <h1>${escapeHtml(cond.title)}</h1>
30
+ <p>${escapeHtml(cond.why)}</p>
31
+
32
+ <div class="fix">
33
+ <h2>If you run this site</h2>
34
+ <p>${escapeHtml(cond.remediation)}</p>
35
+ </div>`;
36
+ return renderStaticAdminPage({ title: `${cond.title} · Cairn`, innerHtml: inner });
37
+ }
38
+
16
39
  export type GuardReason = keyof typeof REASON_CONDITION;
17
40
 
18
41
  /** Render the Response the guard serves for a rejection, by its condition id. */
@@ -32,6 +55,9 @@ export function renderConditionResponse(id: string, ctx: { url?: URL } = {}): Re
32
55
  status: 403,
33
56
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
34
57
  });
58
+ case REASON_CONDITION.bindings:
59
+ // An operator fault, not a request fault: the Worker deployed without its bindings.
60
+ return brandedAdminPage(500, conditionFaultPage(condition(id)));
35
61
  default:
36
62
  throw new Error(`no runtime renderer for condition: ${id}`);
37
63
  }