@glw907/cairn-cms 0.34.0 → 0.36.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 (64) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/auth/crypto.d.ts +4 -0
  3. package/dist/auth/crypto.js +10 -0
  4. package/dist/components/AdminLayout.svelte +8 -1
  5. package/dist/components/ConceptList.svelte +3 -0
  6. package/dist/components/ConfirmPage.svelte +4 -2
  7. package/dist/components/ConfirmPage.svelte.d.ts +2 -1
  8. package/dist/components/CsrfField.svelte +20 -0
  9. package/dist/components/CsrfField.svelte.d.ts +12 -0
  10. package/dist/components/DeleteDialog.svelte +2 -0
  11. package/dist/components/EditPage.svelte +2 -0
  12. package/dist/components/LoginPage.svelte +4 -2
  13. package/dist/components/LoginPage.svelte.d.ts +2 -1
  14. package/dist/components/ManageEditors.svelte +4 -0
  15. package/dist/components/NavTree.svelte +2 -0
  16. package/dist/components/RenameDialog.svelte +3 -0
  17. package/dist/components/csrf-context.d.ts +2 -0
  18. package/dist/components/csrf-context.js +2 -0
  19. package/dist/components/index.d.ts +1 -0
  20. package/dist/components/index.js +1 -0
  21. package/dist/log/emit.d.ts +14 -0
  22. package/dist/log/emit.js +18 -0
  23. package/dist/log/events.d.ts +1 -0
  24. package/dist/log/events.js +1 -0
  25. package/dist/log/index.d.ts +3 -0
  26. package/dist/log/index.js +1 -0
  27. package/dist/sveltekit/auth-routes.d.ts +2 -0
  28. package/dist/sveltekit/auth-routes.js +22 -5
  29. package/dist/sveltekit/content-routes.d.ts +6 -4
  30. package/dist/sveltekit/content-routes.js +23 -0
  31. package/dist/sveltekit/csrf-required-page.d.ts +2 -0
  32. package/dist/sveltekit/csrf-required-page.js +25 -0
  33. package/dist/sveltekit/csrf.d.ts +18 -0
  34. package/dist/sveltekit/csrf.js +60 -0
  35. package/dist/sveltekit/guard.js +35 -6
  36. package/dist/sveltekit/https-required-page.js +10 -191
  37. package/dist/sveltekit/nav-routes.js +5 -0
  38. package/dist/sveltekit/static-admin-page.d.ts +11 -0
  39. package/dist/sveltekit/static-admin-page.js +195 -0
  40. package/package.json +1 -1
  41. package/src/lib/auth/crypto.ts +13 -0
  42. package/src/lib/components/AdminLayout.svelte +8 -1
  43. package/src/lib/components/ConceptList.svelte +3 -0
  44. package/src/lib/components/ConfirmPage.svelte +4 -2
  45. package/src/lib/components/CsrfField.svelte +20 -0
  46. package/src/lib/components/DeleteDialog.svelte +2 -0
  47. package/src/lib/components/EditPage.svelte +2 -0
  48. package/src/lib/components/LoginPage.svelte +4 -2
  49. package/src/lib/components/ManageEditors.svelte +4 -0
  50. package/src/lib/components/NavTree.svelte +2 -0
  51. package/src/lib/components/RenameDialog.svelte +3 -0
  52. package/src/lib/components/csrf-context.ts +2 -0
  53. package/src/lib/components/index.ts +1 -0
  54. package/src/lib/log/emit.ts +42 -0
  55. package/src/lib/log/events.ts +13 -0
  56. package/src/lib/log/index.ts +3 -0
  57. package/src/lib/sveltekit/auth-routes.ts +25 -7
  58. package/src/lib/sveltekit/content-routes.ts +29 -2
  59. package/src/lib/sveltekit/csrf-required-page.ts +26 -0
  60. package/src/lib/sveltekit/csrf.ts +61 -0
  61. package/src/lib/sveltekit/guard.ts +43 -6
  62. package/src/lib/sveltekit/https-required-page.ts +10 -194
  63. package/src/lib/sveltekit/nav-routes.ts +5 -0
  64. package/src/lib/sveltekit/static-admin-page.ts +200 -0
@@ -13,6 +13,9 @@ import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/r
13
13
  import { cachedInstallationToken } from '../github/signing.js';
14
14
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
15
15
  import { CommitConflictError } from '../github/types.js';
16
+ import { log } from '../log/index.js';
17
+ import { issueCsrfToken } from './csrf.js';
18
+ import type { CookieJar } from './types.js';
16
19
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
17
20
  import type { Editor, Role } from '../auth/types.js';
18
21
 
@@ -36,6 +39,8 @@ export interface LayoutData {
36
39
  /** The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
37
40
  * collapsed group renders collapsed with no flash. Empty when none are collapsed. */
38
41
  collapsedNav: string[];
42
+ /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
43
+ csrf: string;
39
44
  }
40
45
 
41
46
  /** One row in a concept's list view. */
@@ -88,8 +93,9 @@ export interface ContentEvent {
88
93
  request: Request;
89
94
  locals: { editor?: Editor | null };
90
95
  platform?: { env?: GithubKeyEnv };
91
- /** SvelteKit's cookie jar; the layout load reads the persisted admin theme. Optional for non-route callers. */
92
- cookies?: { get(name: string): string | undefined };
96
+ /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
97
+ * token. Optional for non-route callers. */
98
+ cookies?: CookieJar;
93
99
  }
94
100
 
95
101
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
@@ -134,6 +140,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
134
140
  navLabel: runtime.navMenu?.label ?? null,
135
141
  theme,
136
142
  collapsedNav,
143
+ csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
137
144
  };
138
145
  }
139
146
 
@@ -277,6 +284,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
277
284
  return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
278
285
  }
279
286
 
287
+ /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
288
+ * reason; any other error is unexpected and logs at error with the stringified cause. The caller
289
+ * still owns the redirect or rethrow, so control flow stays at the call site. */
290
+ function logCommitFailed(fields: { concept: string; id: string; editor: string }, err: unknown): void {
291
+ if (isConflict(err)) {
292
+ log.warn('commit.failed', { ...fields, reason: 'conflict' });
293
+ } else {
294
+ log.error('commit.failed', { ...fields, error: String(err) });
295
+ }
296
+ }
297
+
280
298
  /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
281
299
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
282
300
  const editor = sessionOf(event);
@@ -332,6 +350,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
332
350
  return fail(400, { brokenLinks: absent, body });
333
351
  }
334
352
 
353
+ const commitFields = { concept: concept.id, id, editor: editor.email };
335
354
  try {
336
355
  await commitFiles(
337
356
  runtime.backend,
@@ -342,7 +361,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
342
361
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
343
362
  token,
344
363
  );
364
+ log.info('commit.succeeded', commitFields);
345
365
  } catch (err) {
366
+ logCommitFailed(commitFields, err);
346
367
  if (isConflict(err)) {
347
368
  const message = 'This file changed since you opened it. Reload and reapply your edits.';
348
369
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
@@ -377,6 +398,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
377
398
  }
378
399
 
379
400
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
401
+ const commitFields = { concept: concept.id, id, editor: editor.email };
380
402
  try {
381
403
  await commitFiles(
382
404
  runtime.backend,
@@ -387,7 +409,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
387
409
  { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
388
410
  token,
389
411
  );
412
+ log.info('commit.succeeded', commitFields);
390
413
  } catch (err) {
414
+ logCommitFailed(commitFields, err);
391
415
  if (isConflict(err)) {
392
416
  const message = 'This file changed since you opened it. Reload and try again.';
393
417
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -486,6 +510,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
486
510
 
487
511
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
488
512
 
513
+ const commitFields = { concept: concept.id, id: newId, editor: editor.email };
489
514
  try {
490
515
  await commitFiles(
491
516
  runtime.backend,
@@ -493,7 +518,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
493
518
  { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } },
494
519
  token,
495
520
  );
521
+ log.info('commit.succeeded', commitFields);
496
522
  } catch (err) {
523
+ logCommitFailed(commitFields, err);
497
524
  if (isConflict(err)) {
498
525
  const message = 'This file changed since you opened it. Reload and try again.';
499
526
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -0,0 +1,26 @@
1
+ // The branded 403 the guard serves when an admin form POST fails the double-submit token check.
2
+ // A sibling to https-required-page, built through the shared shell. It names the likely cause and
3
+ // offers a fresh sign-in, and it does not mention Origin headers (the token path does not read them).
4
+ import { renderStaticAdminPage } from './static-admin-page.js';
5
+
6
+ /** Render the full HTML document for the CSRF-failed page. */
7
+ export function csrfRequiredPage(): string {
8
+ const inner = `
9
+ <span class="eyebrow">
10
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
11
+ Security check
12
+ </span>
13
+ <h1>Let's try that again</h1>
14
+ <p>Your sign-in form could not be verified. This usually means the page was open across a browser restart, or cookies are blocked for this site.</p>
15
+
16
+ <a class="cta" href="/admin/login">
17
+ Back to sign-in
18
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
19
+ </a>
20
+
21
+ <div class="fix">
22
+ <h2>If it keeps happening</h2>
23
+ <p>Allow cookies for this site, then open the sign-in page fresh and request a new link.</p>
24
+ </div>`;
25
+ return renderStaticAdminPage({ title: 'Security check · Cairn', innerHtml: inner });
26
+ }
@@ -0,0 +1,61 @@
1
+ // cairn owns CSRF for the admin once a site disables SvelteKit's global checkOrigin. These helpers
2
+ // back the guard's two rules and the loads that issue the double-submit token. See
3
+ // docs/superpowers/specs/2026-06-08-cairn-login-csrf-ownership-design.md.
4
+ import { csrfCookieName, generateCsrfToken } from '../auth/crypto.js';
5
+ import type { CookieJar, RequestContext } from './types.js';
6
+
7
+ const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
8
+ const FORM_CONTENT_TYPES = new Set([
9
+ 'application/x-www-form-urlencoded',
10
+ 'multipart/form-data',
11
+ 'text/plain',
12
+ ]);
13
+
14
+ /** True for a request SvelteKit's CSRF guard screens: an unsafe method with a form content type. */
15
+ export function isUnsafeFormRequest(request: Request): boolean {
16
+ if (!UNSAFE_METHODS.has(request.method)) return false;
17
+ const type = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();
18
+ return FORM_CONTENT_TYPES.has(type);
19
+ }
20
+
21
+ /** The faithful framework check: the Origin header equals the request's own origin. */
22
+ export function originMatches(event: Pick<RequestContext, 'url' | 'request'>): boolean {
23
+ return event.request.headers.get('origin') === event.url.origin;
24
+ }
25
+
26
+ /** A length-checked constant-time compare, so the token check leaks no timing. */
27
+ export function tokensMatch(a: string, b: string): boolean {
28
+ if (a.length === 0 || a.length !== b.length) return false;
29
+ let diff = 0;
30
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
31
+ return diff === 0;
32
+ }
33
+
34
+ /**
35
+ * Return the session's CSRF token, minting and setting it when absent. Lazy and stable: a second
36
+ * open admin tab reuses the same value, so its form field still matches the cookie. Session-scoped
37
+ * (no maxAge), HttpOnly (the server sets both halves), SameSite=Strict, and __Host- on https.
38
+ */
39
+ export function issueCsrfToken(event: { url: URL; cookies: CookieJar }): string {
40
+ const secure = event.url.protocol === 'https:';
41
+ const name = csrfCookieName(secure);
42
+ const existing = event.cookies.get(name);
43
+ if (existing) return existing;
44
+ const token = generateCsrfToken();
45
+ event.cookies.set(name, token, { path: '/', httpOnly: true, secure, sameSite: 'strict' });
46
+ return token;
47
+ }
48
+
49
+ /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
50
+ export async function validateCsrfToken(event: RequestContext): Promise<boolean> {
51
+ const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
52
+ if (!cookie) return false;
53
+ let submitted = '';
54
+ try {
55
+ const form = await event.request.clone().formData();
56
+ submitted = String(form.get('csrf') ?? '');
57
+ } catch {
58
+ return false;
59
+ }
60
+ return tokensMatch(submitted, cookie);
61
+ }
@@ -5,6 +5,9 @@ import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
7
  import { httpsRequiredPage } from './https-required-page.js';
8
+ import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
9
+ import { csrfRequiredPage } from './csrf-required-page.js';
10
+ import { log } from '../log/index.js';
8
11
  import type { Editor } from '../auth/types.js';
9
12
  import type { HandleInput, RequestContext } from './types.js';
10
13
 
@@ -46,30 +49,64 @@ function applySecurityHeaders(headers: Headers): void {
46
49
  headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
47
50
  }
48
51
 
52
+ /** A branded full-document admin page, hardened with the baseline headers and never cached. */
53
+ function brandedAdminPage(status: number, body: string): Response {
54
+ const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
55
+ applySecurityHeaders(headers);
56
+ return new Response(body, { status, headers });
57
+ }
58
+
49
59
  /** The hardened 400 help page for a deployed admin request that arrived over http. */
50
60
  function httpsRequiredResponse(url: URL): Response {
51
61
  const httpsUrl = new URL(url);
52
62
  httpsUrl.protocol = 'https:';
53
- const headers = new Headers({
54
- 'Content-Type': 'text/html; charset=utf-8',
55
- 'Cache-Control': 'no-store',
63
+ return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
64
+ }
65
+
66
+ /** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
67
+ function csrfForbidden(): Response {
68
+ return new Response('Cross-site POST form submissions are forbidden', {
69
+ status: 403,
70
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
56
71
  });
57
- applySecurityHeaders(headers);
58
- return new Response(httpsRequiredPage(httpsUrl.toString()), { status: 400, headers });
72
+ }
73
+
74
+ /** The branded 403 for a failed admin double-submit token check. */
75
+ function csrfRequiredResponse(): Response {
76
+ return brandedAdminPage(403, csrfRequiredPage());
59
77
  }
60
78
 
61
79
  /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
62
80
  export function createAuthGuard() {
63
81
  return async function handle({ event, resolve }: HandleInput): Promise<Response> {
64
82
  const { pathname } = event.url;
65
- if (!isAdminPath(pathname)) return resolve(event);
83
+
84
+ // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
85
+ // they set checkOrigin: false to hand cairn the admin CSRF authority.
86
+ if (!isAdminPath(pathname)) {
87
+ if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
88
+ log.warn('guard.rejected', { reason: 'origin', path: pathname });
89
+ return csrfForbidden();
90
+ }
91
+ return resolve(event);
92
+ }
93
+
66
94
  // A deployed admin request over http never works: the magic-link form POST would fail the
67
95
  // framework's CSRF guard with an opaque 403. Serve the help page instead, before resolve()
68
96
  // runs that check. This covers the public login/auth paths too, since that is where the form
69
97
  // posts. Local http (wrangler dev) is exempt.
70
98
  if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
99
+ log.warn('guard.rejected', { reason: 'https', path: pathname });
71
100
  return httpsRequiredResponse(event.url);
72
101
  }
102
+
103
+ // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
104
+ // 403 before resolve() runs. This covers the public login/auth posts too.
105
+ if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
106
+ log.warn('guard.rejected', { reason: 'csrf', path: pathname });
107
+ return csrfRequiredResponse();
108
+ }
109
+
73
110
  if (!isPublicAdminPath(pathname)) {
74
111
  const env = event.platform?.env ?? {};
75
112
  const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
@@ -1,32 +1,10 @@
1
- // The standalone "this admin needs HTTPS" page. The auth guard serves it when a request reaches a
2
- // deployed Worker over http, which is the one case that makes the magic-link sign-in fail: the
3
- // JS-free login form posts over http, and the framework's CSRF guard rejects a form POST whose
4
- // origin scheme does not match, so the editor would otherwise hit an opaque 403. This page names
5
- // the problem, says why https is needed, and gives the exact Cloudflare fix.
6
- //
7
- // It is served raw from the edge, before SvelteKit renders anything, so it carries no external
8
- // request: the Warm Stone tokens are inlined for both colour schemes and the type falls back to the
9
- // system stack (the shipped admin fonts are not reachable from here). The cairn glyph is the same
10
- // public-domain Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
11
-
12
- /** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
13
- function escapeHtml(value: string): string {
14
- return value
15
- .replace(/&/g, '&amp;')
16
- .replace(/</g, '&lt;')
17
- .replace(/>/g, '&gt;')
18
- .replace(/"/g, '&quot;');
19
- }
20
-
21
- // The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
22
- const CAIRN_GLYPH =
23
- '<path d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 ' +
24
- '13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 ' +
25
- '6.28 14ZM6.92 4.5C6.67 4.5 5 4.43 5 3.88C5 3.07 5.75 2.51 5.96 2.35C6.36 2.03 6.32 1.62 6.54 ' +
26
- '1.27C6.84 0.79 7.61 0.5 7.88 0.5C8.1 0.5 8.75 0.9 9.23 1.42C9.45 1.66 10 2.77 10 3.12C10 4.22 ' +
27
- '9.36 4.5 8.85 4.5C8.33 4.5 8.15 4.5 6.92 4.5ZM3.68 8.22C3 7.73 3.67 6.86 4.57 6.21C5.38 5.63 ' +
28
- '5.92 5.96 6.79 5.7C8.33 5.24 9.02 5.72 9.02 5.72L10.9 6.82C12.03 7.63 10.99 7.67 10.38 8.56C9.79 ' +
29
- '9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"/>';
1
+ // The "this admin needs HTTPS" page. The auth guard serves it when a request reaches a deployed
2
+ // Worker over http, which is the one case that makes the magic-link sign-in fail: the JS-free login
3
+ // form posts over http, and the framework's CSRF guard rejects a form POST whose origin scheme does
4
+ // not match, so the editor would otherwise hit an opaque 403. This page names the problem, says why
5
+ // https is needed, and gives the exact Cloudflare fix. The shared shell lives in
6
+ // static-admin-page.ts. See guard.ts.
7
+ import { escapeHtml, renderStaticAdminPage } from './static-admin-page.js';
30
8
 
31
9
  /**
32
10
  * Render the full HTML document for the HTTPS-required page.
@@ -34,166 +12,7 @@ const CAIRN_GLYPH =
34
12
  */
35
13
  export function httpsRequiredPage(httpsUrl: string): string {
36
14
  const href = escapeHtml(httpsUrl);
37
- return `<!doctype html>
38
- <html lang="en">
39
- <head>
40
- <meta charset="utf-8" />
41
- <meta name="viewport" content="width=device-width, initial-scale=1" />
42
- <meta name="robots" content="noindex, nofollow" />
43
- <title>HTTPS required · Cairn</title>
44
- <style>
45
- :root {
46
- color-scheme: light;
47
- --bg: oklch(96.5% 0.006 75);
48
- --glow: oklch(52% 0.2 293 / 0.06);
49
- --panel: oklch(99% 0.004 75);
50
- --recessed: oklch(95% 0.008 75);
51
- --ink: oklch(26% 0.014 75);
52
- --muted: oklch(48% 0.01 75);
53
- --subtle: oklch(42% 0.01 75);
54
- --primary: oklch(52% 0.2 293);
55
- --primary-content: oklch(98% 0.012 293);
56
- --border: oklch(93% 0.008 75);
57
- --shadow: 0 1px 2px oklch(28% 0.02 75 / 0.05), 0 18px 40px -12px oklch(28% 0.02 75 / 0.16);
58
- --radius-box: 1rem;
59
- --radius-field: 0.625rem;
60
- --font: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
61
- }
62
- @media (prefers-color-scheme: dark) {
63
- :root {
64
- color-scheme: dark;
65
- --bg: oklch(15.5% 0.009 75);
66
- --glow: oklch(68% 0.18 293 / 0.1);
67
- --panel: oklch(24% 0.01 75);
68
- --recessed: oklch(20% 0.01 75);
69
- --ink: oklch(93% 0.006 75);
70
- --muted: oklch(72% 0.01 75);
71
- --subtle: oklch(80% 0.008 75);
72
- --primary: oklch(68% 0.18 293);
73
- --primary-content: oklch(20% 0.04 293);
74
- --border: oklch(30% 0.014 75);
75
- --shadow: 0 1px 2px oklch(0% 0 0 / 0.35), 0 18px 40px -12px oklch(0% 0 0 / 0.55);
76
- }
77
- }
78
- * { box-sizing: border-box; }
79
- body {
80
- margin: 0;
81
- min-height: 100vh;
82
- display: flex;
83
- align-items: center;
84
- justify-content: center;
85
- padding: 1.5rem;
86
- font-family: var(--font);
87
- color: var(--ink);
88
- background-color: var(--bg);
89
- background-image: radial-gradient(80rem 50rem at 50% -20%, var(--glow), transparent 60%);
90
- -webkit-font-smoothing: antialiased;
91
- -moz-osx-font-smoothing: grayscale;
92
- line-height: 1.55;
93
- }
94
- main {
95
- width: 100%;
96
- max-width: 30rem;
97
- background: var(--panel);
98
- border: 1px solid var(--border);
99
- border-radius: var(--radius-box);
100
- box-shadow: var(--shadow);
101
- padding: 2.25rem;
102
- }
103
- .brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; }
104
- .brand .tile {
105
- display: grid;
106
- place-items: center;
107
- width: 2rem;
108
- height: 2rem;
109
- border-radius: 0.75rem;
110
- background: var(--primary);
111
- color: var(--primary-content);
112
- box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12);
113
- }
114
- .brand .tile svg { width: 1.25rem; height: 1.25rem; }
115
- .brand .word {
116
- font-weight: 700;
117
- font-size: 1.25rem;
118
- letter-spacing: -0.01em;
119
- }
120
- .eyebrow {
121
- display: inline-flex;
122
- align-items: center;
123
- gap: 0.4rem;
124
- font-size: 0.6875rem;
125
- font-weight: 600;
126
- text-transform: uppercase;
127
- letter-spacing: 0.08em;
128
- color: var(--muted);
129
- margin-bottom: 0.6rem;
130
- }
131
- .eyebrow svg { width: 0.85rem; height: 0.85rem; }
132
- h1 {
133
- margin: 0 0 0.75rem;
134
- font-size: 1.6rem;
135
- font-weight: 800;
136
- letter-spacing: -0.02em;
137
- line-height: 1.15;
138
- }
139
- p { margin: 0 0 1rem; color: var(--subtle); }
140
- .cta {
141
- display: inline-flex;
142
- align-items: center;
143
- gap: 0.5rem;
144
- margin: 0.25rem 0 0.5rem;
145
- padding: 0.7rem 1.15rem;
146
- border-radius: var(--radius-field);
147
- background: var(--primary);
148
- color: var(--primary-content);
149
- font-weight: 600;
150
- font-size: 0.95rem;
151
- text-decoration: none;
152
- box-shadow: 0 4px 14px -4px oklch(52% 0.2 293 / 0.5);
153
- transition: transform 0.12s ease, box-shadow 0.12s ease;
154
- }
155
- .cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px -6px oklch(52% 0.2 293 / 0.55); }
156
- .cta:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
157
- .cta svg { width: 1rem; height: 1rem; }
158
- .fix {
159
- margin-top: 1.75rem;
160
- padding: 1.1rem 1.2rem;
161
- background: var(--recessed);
162
- border: 1px solid var(--border);
163
- border-radius: var(--radius-field);
164
- }
165
- .fix h2 {
166
- margin: 0 0 0.5rem;
167
- font-size: 0.8125rem;
168
- font-weight: 700;
169
- letter-spacing: 0.01em;
170
- }
171
- .fix p { margin: 0 0 0.65rem; font-size: 0.875rem; }
172
- .fix p:last-child { margin-bottom: 0; }
173
- .path {
174
- display: block;
175
- font-size: 0.8125rem;
176
- font-weight: 600;
177
- color: var(--ink);
178
- letter-spacing: 0.01em;
179
- margin: 0 0 0.65rem;
180
- }
181
- .path .arrow { color: var(--muted); padding: 0 0.35rem; font-weight: 400; }
182
- .foot {
183
- margin-top: 1.75rem;
184
- text-align: center;
185
- font-size: 0.75rem;
186
- color: var(--muted);
187
- }
188
- </style>
189
- </head>
190
- <body>
191
- <main>
192
- <div class="brand">
193
- <span class="tile"><svg viewBox="0 0 15 15" fill="currentColor" aria-hidden="true">${CAIRN_GLYPH}</svg></span>
194
- <span class="word">Cairn</span>
195
- </div>
196
-
15
+ const inner = `
197
16
  <span class="eyebrow">
198
17
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
199
18
  Secure connection required
@@ -212,9 +31,6 @@ p { margin: 0 0 1rem; color: var(--subtle); }
212
31
  <span class="path">SSL/TLS<span class="arrow">&rsaquo;</span>Edge Certificates<span class="arrow">&rsaquo;</span>Always Use HTTPS</span>
213
32
  <p>Keep HSTS on too. The browser then stays on https and sign-in works.</p>
214
33
  </div>
215
-
216
- <p class="foot">Powered by Cairn</p>
217
- </main>
218
- </body>
219
- </html>`;
34
+ `;
35
+ return renderStaticAdminPage({ title: 'HTTPS required · Cairn', innerHtml: inner });
220
36
  }
@@ -6,6 +6,7 @@ import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
6
6
  import { cachedInstallationToken } from '../github/signing.js';
7
7
  import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
8
  import { CommitConflictError } from '../github/types.js';
9
+ import { log } from '../log/index.js';
9
10
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
10
11
  import type { CairnRuntime } from '../content/types.js';
11
12
  import type { ContentEvent } from './content-routes.js';
@@ -116,6 +117,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
116
117
  const raw = await readRaw(runtime.backend, config.configPath, token);
117
118
  if (raw === null) throw error(404, 'Site config not found');
118
119
 
120
+ const commitFields = { concept: 'nav', id: 'site-config', editor: editor.email };
119
121
  try {
120
122
  await commitFile(
121
123
  runtime.backend,
@@ -124,11 +126,14 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
124
126
  { message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } },
125
127
  token,
126
128
  );
129
+ log.info('commit.succeeded', commitFields);
127
130
  } catch (err) {
128
131
  if (isConflict(err)) {
132
+ log.warn('commit.failed', { ...commitFields, reason: 'conflict' });
129
133
  const message = 'The site config changed since you opened it. Reload and reapply your edits.';
130
134
  throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
131
135
  }
136
+ log.error('commit.failed', { ...commitFields, error: String(err) });
132
137
  throw err;
133
138
  }
134
139