@glw907/cairn-cms 0.41.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +2 -2
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +4 -5
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +107 -25
- package/dist/components/EditPage.svelte.d.ts +8 -10
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- package/dist/components/MarkdownEditor.svelte +20 -2
- package/dist/components/cairn-admin.css +57 -9
- package/dist/components/editor-highlight.d.ts +1 -0
- package/dist/components/editor-highlight.js +31 -8
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/components/preview-doc.d.ts +27 -0
- package/dist/components/preview-doc.js +64 -0
- package/dist/content/compose.js +1 -0
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +35 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -0
- package/dist/diagnostics/conditions.js +24 -0
- package/dist/doctor/bin.js +30 -12
- package/dist/doctor/check-floors.d.ts +15 -0
- package/dist/doctor/check-floors.js +107 -0
- package/dist/doctor/check-probe.d.ts +3 -0
- package/dist/doctor/check-probe.js +123 -0
- package/dist/doctor/checks-github.js +1 -1
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +28 -2
- package/dist/doctor/cloudflare-api.js +2 -2
- package/dist/doctor/index.d.ts +28 -3
- package/dist/doctor/index.js +47 -6
- package/dist/doctor/types.d.ts +2 -0
- package/dist/doctor/wrangler-config.d.ts +4 -0
- package/dist/doctor/wrangler-config.js +11 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +3 -2
- package/dist/env.js +12 -6
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +39 -15
- package/dist/sveltekit/content-routes.js +84 -50
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +18 -4
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +22 -19
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +6 -2
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +4 -5
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +107 -25
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/MarkdownEditor.svelte +20 -2
- package/src/lib/components/cairn-admin.css +59 -0
- package/src/lib/components/editor-highlight.ts +32 -7
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +34 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -0
- package/src/lib/diagnostics/conditions.ts +24 -0
- package/src/lib/doctor/bin.ts +35 -10
- package/src/lib/doctor/check-floors.ts +124 -0
- package/src/lib/doctor/check-probe.ts +138 -0
- package/src/lib/doctor/checks-github.ts +3 -1
- package/src/lib/doctor/checks-local.ts +28 -2
- package/src/lib/doctor/cloudflare-api.ts +4 -2
- package/src/lib/doctor/index.ts +67 -6
- package/src/lib/doctor/types.ts +2 -0
- package/src/lib/doctor/wrangler-config.ts +11 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +12 -6
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/types.ts +5 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +131 -62
- package/src/lib/sveltekit/guard.ts +20 -5
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +24 -21
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/src/lib/vite/index.ts +71 -17
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
|
@@ -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 {
|
|
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
|
}
|
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
// email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
|
|
5
5
|
import { redirect, error, fail } from '@sveltejs/kit';
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
|
-
import { extractCairnLinks, formatCairnToken } from '../content/links.js';
|
|
7
|
+
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
9
9
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
10
|
-
import { rewriteCairnLink } from '../components/markdown-format.js';
|
|
11
10
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
12
11
|
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
13
12
|
import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
|
|
14
13
|
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
15
14
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
16
15
|
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
17
|
-
import {
|
|
16
|
+
import { isConflict } from '../github/types.js';
|
|
18
17
|
import { log } from '../log/index.js';
|
|
19
18
|
import { issueCsrfToken } from './csrf.js';
|
|
20
|
-
import
|
|
21
|
-
import type {
|
|
19
|
+
import { requireSession } from './guard.js';
|
|
20
|
+
import type { CookieJar, EventBase } from './types.js';
|
|
21
|
+
import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
|
|
22
22
|
import type { Editor, Role } from '../auth/types.js';
|
|
23
23
|
|
|
24
24
|
/** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
|
|
@@ -101,15 +101,15 @@ export interface EditData {
|
|
|
101
101
|
publishedFlash: boolean;
|
|
102
102
|
/** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
|
|
103
103
|
discardedFlash: boolean;
|
|
104
|
+
/** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
|
|
105
|
+
* when one exists, applied over the top-level values); null when the site sets none, which
|
|
106
|
+
* leaves the frame rendering unstyled markup behind a hint. */
|
|
107
|
+
preview: ResolvedPreview | null;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
107
|
-
export interface ContentEvent {
|
|
108
|
-
url: URL;
|
|
111
|
+
export interface ContentEvent extends EventBase<GithubKeyEnv> {
|
|
109
112
|
params: Record<string, string>;
|
|
110
|
-
request: Request;
|
|
111
|
-
locals: { editor?: Editor | null };
|
|
112
|
-
platform?: { env?: GithubKeyEnv };
|
|
113
113
|
/** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
|
|
114
114
|
* token. Optional for non-route callers. */
|
|
115
115
|
cookies?: CookieJar;
|
|
@@ -117,15 +117,53 @@ export interface ContentEvent {
|
|
|
117
117
|
|
|
118
118
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
119
119
|
export interface ContentRoutesDeps {
|
|
120
|
-
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
121
|
-
|
|
120
|
+
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
121
|
+
* A bare string works too; the routes await whatever comes back. */
|
|
122
|
+
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
/** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
|
|
126
|
+
export interface SaveFailure {
|
|
127
|
+
/** The one-line human summary every content action failure carries. */
|
|
128
|
+
error: string;
|
|
129
|
+
/** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
|
|
130
|
+
brokenLinks: string[];
|
|
131
|
+
/** The author's edited markdown, so the editor reseeds with the unsaved work. */
|
|
132
|
+
body: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A refused delete: `fail(409)` while other entries still link to this one. */
|
|
136
|
+
export interface DeleteRefusal {
|
|
137
|
+
/** The one-line human summary every content action failure carries. */
|
|
138
|
+
error: string;
|
|
139
|
+
/** The entries whose bodies link to the refused one, for the blockers list. */
|
|
140
|
+
inboundLinks: InboundLink[];
|
|
141
|
+
/** The refused entry's id, so a list view marks the right row. */
|
|
142
|
+
id: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
|
|
146
|
+
export interface RenameFailure {
|
|
147
|
+
/** The one-line human summary every content action failure carries. */
|
|
148
|
+
error: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a route's single `form` export presents to a view component: whichever content action
|
|
152
|
+
* last failed, merged with every field optional. `error` is always set on a failure; the richer
|
|
153
|
+
* keys identify which guard refused. */
|
|
154
|
+
export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
|
|
155
|
+
|
|
156
|
+
/** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
157
|
+
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
158
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client. */
|
|
159
|
+
function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
|
|
160
|
+
if (!preview) return null;
|
|
161
|
+
const override = preview.byConcept?.[conceptId];
|
|
162
|
+
return {
|
|
163
|
+
stylesheets: preview.stylesheets,
|
|
164
|
+
bodyClass: override?.bodyClass ?? preview.bodyClass,
|
|
165
|
+
containerClass: override?.containerClass ?? preview.containerClass,
|
|
166
|
+
};
|
|
129
167
|
}
|
|
130
168
|
|
|
131
169
|
/** Look up the concept named by the `[concept]` route param, or a 404. */
|
|
@@ -161,7 +199,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
161
199
|
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
162
200
|
* and the pending entries behind the topbar's publish-all action. */
|
|
163
201
|
async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
|
|
164
|
-
const editor =
|
|
202
|
+
const editor = requireSession(event);
|
|
165
203
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
166
204
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
167
205
|
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
@@ -223,11 +261,39 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
223
261
|
}
|
|
224
262
|
}
|
|
225
263
|
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
264
|
+
/** Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
265
|
+
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
266
|
+
* an id-only row, so a ghost ref still lists. */
|
|
267
|
+
function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
|
|
268
|
+
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
|
|
269
|
+
...runtime.backend,
|
|
270
|
+
branch: pendingBranch(concept.id, id),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
275
|
+
* and read each one for its row, with edited and new rows reading branch-first. */
|
|
276
|
+
async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
|
|
277
|
+
const files = await listMarkdown(runtime.backend, concept.dir, token);
|
|
278
|
+
const entries = await Promise.all(
|
|
279
|
+
files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))),
|
|
280
|
+
);
|
|
281
|
+
// A ref with no main file is a never-published entry; its row reads from its branch.
|
|
282
|
+
const listed = new Set(files.map((f) => f.id));
|
|
283
|
+
const newRows = await Promise.all(
|
|
284
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
|
|
285
|
+
);
|
|
286
|
+
return [...entries, ...newRows];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** List a concept's entries with their publish status. Published rows project straight from
|
|
290
|
+
* main's manifest, which publish, delete, and rename keep atomically in sync with main, so
|
|
291
|
+
* the listing costs one manifest read plus one branch read per pending entry rather than one
|
|
292
|
+
* read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
|
|
293
|
+
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
294
|
+
* to an inline error, not a thrown 500. */
|
|
229
295
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
230
|
-
|
|
296
|
+
requireSession(event);
|
|
231
297
|
const concept = conceptOf(runtime, event.params);
|
|
232
298
|
const formError = event.url.searchParams.get('error');
|
|
233
299
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
@@ -240,8 +306,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
240
306
|
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
241
307
|
}
|
|
242
308
|
try {
|
|
243
|
-
const [
|
|
244
|
-
|
|
309
|
+
const [manifestRaw, refs] = await Promise.all([
|
|
310
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
245
311
|
listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
|
|
246
312
|
]);
|
|
247
313
|
const pendingIds = new Set(
|
|
@@ -250,27 +316,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
250
316
|
return entry && entry.concept.id === concept.id ? [entry.id] : [];
|
|
251
317
|
}),
|
|
252
318
|
);
|
|
253
|
-
//
|
|
254
|
-
//
|
|
319
|
+
// A repo with no committed manifest yet (a fresh site before its first publish) falls back
|
|
320
|
+
// to the crawl; a manifest that parses but is empty is trusted as-is.
|
|
321
|
+
if (manifestRaw === null) {
|
|
322
|
+
return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
|
|
323
|
+
}
|
|
324
|
+
// Newest id first, the same order the crawl's file listing produced.
|
|
325
|
+
const rows = parseManifest(manifestRaw)
|
|
326
|
+
.entries.filter((e) => e.concept === concept.id)
|
|
327
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
255
328
|
const entries = await Promise.all(
|
|
256
|
-
|
|
257
|
-
pendingIds.has(
|
|
258
|
-
?
|
|
259
|
-
:
|
|
329
|
+
rows.map((e) =>
|
|
330
|
+
pendingIds.has(e.id)
|
|
331
|
+
? pendingRow(concept, e.id, 'edited', token)
|
|
332
|
+
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const },
|
|
260
333
|
),
|
|
261
334
|
);
|
|
262
|
-
|
|
263
|
-
// summarize already degrades a failed read to an id-only row.
|
|
264
|
-
const listed = new Set(files.map((f) => f.id));
|
|
335
|
+
const listed = new Set(rows.map((e) => e.id));
|
|
265
336
|
const newRows = await Promise.all(
|
|
266
|
-
[...pendingIds]
|
|
267
|
-
.filter((id) => !listed.has(id))
|
|
268
|
-
.map((id) =>
|
|
269
|
-
summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
|
|
270
|
-
...runtime.backend,
|
|
271
|
-
branch: pendingBranch(concept.id, id),
|
|
272
|
-
}),
|
|
273
|
-
),
|
|
337
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
|
|
274
338
|
);
|
|
275
339
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
276
340
|
} catch {
|
|
@@ -280,7 +344,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
280
344
|
|
|
281
345
|
/** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
|
|
282
346
|
async function createAction(event: ContentEvent): Promise<never> {
|
|
283
|
-
|
|
347
|
+
requireSession(event);
|
|
284
348
|
const concept = conceptOf(runtime, event.params);
|
|
285
349
|
const form = await event.request.formData();
|
|
286
350
|
const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
|
|
@@ -325,7 +389,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
325
389
|
|
|
326
390
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
327
391
|
async function editLoad(event: ContentEvent): Promise<EditData> {
|
|
328
|
-
|
|
392
|
+
requireSession(event);
|
|
329
393
|
const concept = conceptOf(runtime, event.params);
|
|
330
394
|
const id = event.params.id ?? '';
|
|
331
395
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -387,14 +451,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
387
451
|
published,
|
|
388
452
|
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
389
453
|
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
454
|
+
preview: resolvePreview(runtime.preview, concept.id),
|
|
390
455
|
};
|
|
391
456
|
}
|
|
392
457
|
|
|
393
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
394
|
-
function isConflict(err: unknown): boolean {
|
|
395
|
-
return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
|
|
396
|
-
}
|
|
397
|
-
|
|
398
458
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
399
459
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
400
460
|
* failures carry the same shape under their own event name. */
|
|
@@ -492,7 +552,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
492
552
|
else if (target.draft) draftLinks.push(formatCairnToken(ref));
|
|
493
553
|
}
|
|
494
554
|
if (absent.length) {
|
|
495
|
-
|
|
555
|
+
const noun = absent.length === 1 ? 'page' : 'pages';
|
|
556
|
+
return fail(400, {
|
|
557
|
+
error: `This page links to ${absent.length} missing ${noun}.`,
|
|
558
|
+
brokenLinks: absent,
|
|
559
|
+
body,
|
|
560
|
+
} satisfies SaveFailure);
|
|
496
561
|
}
|
|
497
562
|
|
|
498
563
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
@@ -525,7 +590,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
525
590
|
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
526
591
|
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
527
592
|
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
528
|
-
const editor =
|
|
593
|
+
const editor = requireSession(event);
|
|
529
594
|
const concept = conceptOf(runtime, event.params);
|
|
530
595
|
const id = event.params.id ?? '';
|
|
531
596
|
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
@@ -546,7 +611,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
546
611
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
547
612
|
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
548
613
|
async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
549
|
-
const editor =
|
|
614
|
+
const editor = requireSession(event);
|
|
550
615
|
const concept = conceptOf(runtime, event.params);
|
|
551
616
|
const id = event.params.id ?? '';
|
|
552
617
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -585,7 +650,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
585
650
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
586
651
|
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
587
652
|
async function publishAllAction(event: ContentEvent): Promise<never> {
|
|
588
|
-
const editor =
|
|
653
|
+
const editor = requireSession(event);
|
|
589
654
|
const first = runtime.concepts[0];
|
|
590
655
|
if (!first) throw error(404, 'No content types configured');
|
|
591
656
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -672,7 +737,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
672
737
|
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
673
738
|
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
674
739
|
async function discardAction(event: ContentEvent): Promise<never> {
|
|
675
|
-
const editor =
|
|
740
|
+
const editor = requireSession(event);
|
|
676
741
|
const concept = conceptOf(runtime, event.params);
|
|
677
742
|
const id = event.params.id ?? '';
|
|
678
743
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -705,7 +770,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
705
770
|
const manifest = await readManifest(token);
|
|
706
771
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
707
772
|
if (inbound.length) {
|
|
708
|
-
return fail(409, {
|
|
773
|
+
return fail(409, {
|
|
774
|
+
error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
|
|
775
|
+
inboundLinks: inbound,
|
|
776
|
+
id,
|
|
777
|
+
} satisfies DeleteRefusal);
|
|
709
778
|
}
|
|
710
779
|
|
|
711
780
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
@@ -749,7 +818,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
749
818
|
|
|
750
819
|
/** Delete an entry from its editor. The id comes from the route param. */
|
|
751
820
|
async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
752
|
-
const editor =
|
|
821
|
+
const editor = requireSession(event);
|
|
753
822
|
const concept = conceptOf(runtime, event.params);
|
|
754
823
|
const id = event.params.id ?? '';
|
|
755
824
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -758,7 +827,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
758
827
|
|
|
759
828
|
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
760
829
|
async function listDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
761
|
-
const editor =
|
|
830
|
+
const editor = requireSession(event);
|
|
762
831
|
const concept = conceptOf(runtime, event.params);
|
|
763
832
|
const form = await event.request.formData();
|
|
764
833
|
const id = String(form.get('id') ?? '');
|
|
@@ -771,7 +840,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
771
840
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
772
841
|
* caught by the build's fail-closed backstop. */
|
|
773
842
|
async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
774
|
-
const editor =
|
|
843
|
+
const editor = requireSession(event);
|
|
775
844
|
const concept = conceptOf(runtime, event.params);
|
|
776
845
|
const id = event.params.id ?? '';
|
|
777
846
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -780,20 +849,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
780
849
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
781
850
|
// them, so refuse until the editor publishes or discards.
|
|
782
851
|
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
783
|
-
return fail(409, {
|
|
852
|
+
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
|
|
784
853
|
}
|
|
785
854
|
|
|
786
855
|
const form = await event.request.formData();
|
|
787
856
|
const newSlug = String(form.get('slug') ?? '').trim();
|
|
788
857
|
if (!isValidId(newSlug)) {
|
|
789
|
-
return fail(400, {
|
|
858
|
+
return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies RenameFailure);
|
|
790
859
|
}
|
|
791
860
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
792
861
|
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
793
|
-
return fail(400, {
|
|
862
|
+
return fail(400, { error: 'Leave the date out of the slug.' } satisfies RenameFailure);
|
|
794
863
|
}
|
|
795
864
|
if (newSlug === slugFromId(id, datePrefix)) {
|
|
796
|
-
return fail(400, {
|
|
865
|
+
return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
|
|
797
866
|
}
|
|
798
867
|
const newId = renameId(id, newSlug, datePrefix);
|
|
799
868
|
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
@@ -804,7 +873,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
804
873
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
805
874
|
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
806
875
|
if (clobber !== null) {
|
|
807
|
-
return fail(409, {
|
|
876
|
+
return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
|
|
808
877
|
}
|
|
809
878
|
|
|
810
879
|
const [entryRaw, manifest] = await Promise.all([
|
|
@@ -6,7 +6,7 @@ import { resolveSession } from '../auth/store.js';
|
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
7
|
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
8
8
|
import { applySecurityHeaders } from './admin-response.js';
|
|
9
|
-
import { renderConditionResponse } from './condition-response.js';
|
|
9
|
+
import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
11
|
import type { Editor } from '../auth/types.js';
|
|
12
12
|
import type { HandleInput, RequestContext } from './types.js';
|
|
@@ -60,6 +60,20 @@ export function createAuthGuard() {
|
|
|
60
60
|
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// No auth store binding means no admin path can work: the gated views cannot resolve a
|
|
64
|
+
// session, and a login or confirm POST would die in its action with a raw 500. That is an
|
|
65
|
+
// operator fault, not a sign-in problem, so name the condition on every admin path, the
|
|
66
|
+
// public ones included, instead of rendering a login form that can never succeed.
|
|
67
|
+
const env = event.platform?.env ?? {};
|
|
68
|
+
if (!env.AUTH_DB) {
|
|
69
|
+
log.error('guard.rejected', {
|
|
70
|
+
reason: 'bindings',
|
|
71
|
+
conditionId: REASON_CONDITION.bindings,
|
|
72
|
+
path: pathname,
|
|
73
|
+
});
|
|
74
|
+
return renderConditionResponse(REASON_CONDITION.bindings);
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
64
78
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
65
79
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
@@ -68,9 +82,8 @@ export function createAuthGuard() {
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
if (!isPublicAdminPath(pathname)) {
|
|
71
|
-
const env = event.platform?.env ?? {};
|
|
72
85
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
73
|
-
const editor = id
|
|
86
|
+
const editor = id ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
|
|
74
87
|
if (!editor) throw redirect(303, '/admin/login');
|
|
75
88
|
event.locals.editor = editor;
|
|
76
89
|
}
|
|
@@ -80,8 +93,10 @@ export function createAuthGuard() {
|
|
|
80
93
|
};
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
84
|
-
|
|
96
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
97
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
98
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
99
|
+
export function requireSession(event: { locals: { editor?: Editor | null } }): Editor {
|
|
85
100
|
const editor = event.locals.editor;
|
|
86
101
|
if (!editor) throw redirect(303, '/admin/login');
|
|
87
102
|
return editor;
|