@glw907/cairn-cms 0.35.0 → 0.37.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 CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.37.0
6
+
7
+ The magic-link sign-in confirmation is now a branded panel in place of the flat success bar. After an
8
+ editor requests a link, the page shows a mail icon in a soft success tile, a "Check your email"
9
+ heading, and the ten-minute expiry note, all in the admin's Warm Stone styling. Below a divider it
10
+ adds guidance for the link that never arrives: check the spam folder first, then confirm the address
11
+ matches the one the site owner added. This covers the common fat-finger case, where a mistyped address
12
+ gets the same neutral confirmation and no email. A "Use a different email" action returns to the form
13
+ so the address gets corrected without a reload. The confirmation copy stays identical whether or not
14
+ the email is on the allowlist, so the page still never leaks membership.
15
+
16
+ The change is internal to the `LoginPage` component and needs no action.
17
+
18
+ ## 0.36.0
19
+
20
+ cairn now emits structured diagnostic events. The engine had three bare `console.error` calls and no
21
+ queryable diagnostics. An internal logger assembles a JSON record for each event, with an envelope
22
+ (`level`, `event`, `timestamp`) and event-specific fields, and writes it to `console`. Cloudflare
23
+ Workers Logs ingests and indexes those records when a site sets `observability.enabled = true`, so
24
+ each field filters. The event vocabulary covers the auth flow, the commit pipeline, and the admin
25
+ guard's pre-resolve refusals. The records carry an editor's email for attribution and never carry a
26
+ magic-link token, a session id, or a magic-link's contents; a standing redaction test pins that.
27
+
28
+ The event names are a stable contract, so renaming one is a breaking change later. The full list, with
29
+ each event's level, trigger, and fields, is in the new [log events reference](docs/reference/log-events.md),
30
+ and the [read cairn's logs guide](docs/guides/read-cairn-logs.md) covers the one setup line and the
31
+ dashboard query.
32
+
33
+ Consumers may: set `observability.enabled = true` in `wrangler.jsonc` to read the events in Workers
34
+ Logs. The change is otherwise additive and needs no action.
35
+
5
36
  ## 0.35.0
6
37
 
7
38
  cairn now owns CSRF for the admin. A consuming site disables SvelteKit's global `checkOrigin`, and
@@ -7,6 +7,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
7
7
  <script lang="ts">
8
8
  import './cairn-admin.css';
9
9
  import { onMount } from 'svelte';
10
+ import MailCheckIcon from '@lucide/svelte/icons/mail-check';
10
11
  import CairnLogo from './CairnLogo.svelte';
11
12
  import CsrfField from './CsrfField.svelte';
12
13
  import { cairnFaviconHref } from './cairn-favicon.js';
@@ -22,6 +23,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
22
23
  let { data, form }: Props = $props();
23
24
 
24
25
  let rootEl = $state<HTMLElement>();
26
+ // Lets a mistyped address go back to the form without a reload, even though the server still
27
+ // reports `sent`. The success copy never reveals whether the email was on the allowlist.
28
+ let dismissed = $state(false);
25
29
  onMount(() => {
26
30
  if (rootEl) warnIfChromeWrapped(rootEl);
27
31
  });
@@ -44,13 +48,35 @@ the allowlist, so the page never leaks membership (spec §7.1).
44
48
  </div>
45
49
 
46
50
  <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
47
- <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
48
51
 
49
- {#if form?.sent}
50
- <div role="status" class="alert alert-success text-sm">
51
- Check your email for a sign-in link. It expires in 10 minutes.
52
+ {#if form?.sent && !dismissed}
53
+ <div role="status" class="mt-5 flex flex-col items-center text-center">
54
+ <div
55
+ class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl text-[var(--color-success)]"
56
+ style="background-color: color-mix(in oklch, var(--color-success) 16%, transparent);"
57
+ >
58
+ <MailCheckIcon class="h-6 w-6" />
59
+ </div>
60
+ <h2 class="text-lg font-semibold">Check your email</h2>
61
+ <p class="mt-1 text-sm text-[var(--color-muted)]">
62
+ We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
63
+ </p>
64
+ <div class="mt-5 w-full border-t border-[var(--cairn-card-border)] pt-4 text-left">
65
+ <p class="text-sm text-[var(--color-muted)]">
66
+ No link after a minute or two? Check your spam folder first. If it still hasn't arrived,
67
+ double-check the address. It has to match the one your site owner added.
68
+ </p>
69
+ <button
70
+ type="button"
71
+ class="btn btn-ghost btn-sm mt-3 -ml-2 text-primary"
72
+ onclick={() => (dismissed = true)}
73
+ >
74
+ Use a different email
75
+ </button>
76
+ </div>
52
77
  </div>
53
78
  {:else}
79
+ <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
54
80
  {#if data.error}
55
81
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
56
82
  {/if}
@@ -3337,6 +3337,10 @@
3337
3337
  margin-top: calc(var(--spacing) * 4);
3338
3338
  }
3339
3339
 
3340
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-5 {
3341
+ margin-top: calc(var(--spacing) * 5);
3342
+ }
3343
+
3340
3344
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-6 {
3341
3345
  margin-top: calc(var(--spacing) * 6);
3342
3346
  }
@@ -3394,6 +3398,10 @@
3394
3398
  margin-bottom: calc(var(--spacing) * 6);
3395
3399
  }
3396
3400
 
3401
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .-ml-2 {
3402
+ margin-left: calc(var(--spacing) * -2);
3403
+ }
3404
+
3397
3405
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ml-1 {
3398
3406
  margin-left: calc(var(--spacing) * 1);
3399
3407
  }
@@ -3786,6 +3794,10 @@
3786
3794
  height: calc(var(--spacing) * 5);
3787
3795
  }
3788
3796
 
3797
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-6 {
3798
+ height: calc(var(--spacing) * 6);
3799
+ }
3800
+
3789
3801
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-7 {
3790
3802
  height: calc(var(--spacing) * 7);
3791
3803
  }
@@ -3794,6 +3806,10 @@
3794
3806
  height: calc(var(--spacing) * 8);
3795
3807
  }
3796
3808
 
3809
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-11 {
3810
+ height: calc(var(--spacing) * 11);
3811
+ }
3812
+
3797
3813
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-12 {
3798
3814
  height: calc(var(--spacing) * 12);
3799
3815
  }
@@ -3852,6 +3868,10 @@
3852
3868
  width: calc(var(--spacing) * 5);
3853
3869
  }
3854
3870
 
3871
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-6 {
3872
+ width: calc(var(--spacing) * 6);
3873
+ }
3874
+
3855
3875
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-7 {
3856
3876
  width: calc(var(--spacing) * 7);
3857
3877
  }
@@ -3864,6 +3884,10 @@
3864
3884
  width: calc(var(--spacing) * 9);
3865
3885
  }
3866
3886
 
3887
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-11 {
3888
+ width: calc(var(--spacing) * 11);
3889
+ }
3890
+
3867
3891
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-12 {
3868
3892
  width: calc(var(--spacing) * 12);
3869
3893
  }
@@ -4420,6 +4444,10 @@
4420
4444
  padding-top: calc(var(--spacing) * 3);
4421
4445
  }
4422
4446
 
4447
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pt-4 {
4448
+ padding-top: calc(var(--spacing) * 4);
4449
+ }
4450
+
4423
4451
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pr-3 {
4424
4452
  padding-right: calc(var(--spacing) * 3);
4425
4453
  }
@@ -4432,6 +4460,10 @@
4432
4460
  text-align: center;
4433
4461
  }
4434
4462
 
4463
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-left {
4464
+ text-align: left;
4465
+ }
4466
+
4435
4467
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-right {
4436
4468
  text-align: right;
4437
4469
  }
@@ -4561,6 +4593,10 @@
4561
4593
  color: var(--color-subtle);
4562
4594
  }
4563
4595
 
4596
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[var\(--color-success\)\] {
4597
+ color: var(--color-success);
4598
+ }
4599
+
4564
4600
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-base-content {
4565
4601
  color: var(--color-base-content);
4566
4602
  }
@@ -0,0 +1,14 @@
1
+ import type { CairnLogEvent } from './events.js';
2
+ export type LogLevel = 'info' | 'warn' | 'error';
3
+ export interface LogRecord {
4
+ level: LogLevel;
5
+ event: CairnLogEvent;
6
+ timestamp: string;
7
+ [field: string]: unknown;
8
+ }
9
+ export interface Logger {
10
+ info(event: CairnLogEvent, fields?: Record<string, unknown>): void;
11
+ warn(event: CairnLogEvent, fields?: Record<string, unknown>): void;
12
+ error(event: CairnLogEvent, fields?: Record<string, unknown>): void;
13
+ }
14
+ export declare const log: Logger;
@@ -0,0 +1,18 @@
1
+ const sinkByLevel = {
2
+ info: (record) => console.log(record),
3
+ warn: (record) => console.warn(record),
4
+ error: (record) => console.error(record),
5
+ };
6
+ function buildRecord(level, event, fields) {
7
+ // The envelope keys are written last, so a stray field named level/event/timestamp cannot
8
+ // corrupt the record shape a subscriber relies on.
9
+ return { ...fields, level, event, timestamp: new Date().toISOString() };
10
+ }
11
+ function emit(level, event, fields = {}) {
12
+ sinkByLevel[level](buildRecord(level, event, fields));
13
+ }
14
+ export const log = {
15
+ info: (event, fields) => emit('info', event, fields),
16
+ warn: (event, fields) => emit('warn', event, fields),
17
+ error: (event, fields) => emit('error', event, fields),
18
+ };
@@ -0,0 +1 @@
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'guard.rejected';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { log } from './emit.js';
2
+ export type { Logger, LogLevel, LogRecord } from './emit.js';
3
+ export type { CairnLogEvent } from './events.js';
@@ -0,0 +1 @@
1
+ export { log } from './emit.js';
@@ -7,6 +7,7 @@ import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_
7
7
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
8
8
  import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
9
9
  import { issueCsrfToken } from './csrf.js';
10
+ import { log } from '../log/index.js';
10
11
  export function createAuthRoutes(config) {
11
12
  const send = config.send ?? cloudflareSend;
12
13
  /**
@@ -20,6 +21,10 @@ export function createAuthRoutes(config) {
20
21
  const db = requireDb(env);
21
22
  const form = await event.request.formData();
22
23
  const email = String(form.get('email') ?? '').trim().toLowerCase();
24
+ // `email` here is unvalidated request input logged before the allowlist check, so bound the
25
+ // logged value to the RFC 5321 maximum to cap an abusive record's size. A real editor's address
26
+ // fits well under this; only a junk payload is truncated.
27
+ log.info('auth.link.requested', { email: email.slice(0, 320) });
23
28
  const editor = email ? await findEditor(db, email) : null;
24
29
  if (editor) {
25
30
  const now = Date.now();
@@ -29,12 +34,13 @@ export function createAuthRoutes(config) {
29
34
  if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
30
35
  const token = generateToken();
31
36
  await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
37
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
32
38
  const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
33
39
  // The token row is the security-critical write the email depends on, so it is awaited. The
34
40
  // send is a post-response side effect, handed to waitUntil so a slow email provider does not
35
41
  // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
36
42
  // failure is logged so observability survives a backgrounded send.
37
- const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => console.error('cairn: magic-link send failed', err));
43
+ const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => log.error('auth.link.send_failed', { email, error: String(err) }));
38
44
  // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
39
45
  // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
40
46
  const ctx = event.platform?.ctx ?? event.platform?.context;
@@ -83,8 +89,10 @@ export function createAuthRoutes(config) {
83
89
  const email = await consumeToken(db, await hashToken(token), now);
84
90
  if (!email)
85
91
  throw redirect(303, '/admin/login?error=expired');
92
+ log.info('auth.token.confirmed', { email });
86
93
  const id = generateSessionId();
87
94
  await createSession(db, id, email, now + SESSION_TTL_MS, now);
95
+ log.info('auth.session.created', { email });
88
96
  const secure = event.url.protocol === 'https:';
89
97
  event.cookies.set(sessionCookieName(secure), id, {
90
98
  path: '/',
@@ -101,8 +109,10 @@ export function createAuthRoutes(config) {
101
109
  const db = requireDb(event.platform?.env ?? {});
102
110
  const name = sessionCookieName(event.url.protocol === 'https:');
103
111
  const id = event.cookies.get(name);
104
- if (id)
112
+ if (id) {
105
113
  await deleteSession(db, id);
114
+ log.info('auth.session.destroyed');
115
+ }
106
116
  event.cookies.delete(name, { path: '/' });
107
117
  throw redirect(303, '/admin/login');
108
118
  }
@@ -13,6 +13,7 @@ import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
13
13
  import { cachedInstallationToken } from '../github/signing.js';
14
14
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
15
15
  import { CommitConflictError } from '../github/types.js';
16
+ import { log } from '../log/index.js';
16
17
  import { issueCsrfToken } from './csrf.js';
17
18
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
18
19
  function sessionOf(event) {
@@ -192,6 +193,17 @@ export function createContentRoutes(runtime, deps = {}) {
192
193
  function isConflict(err) {
193
194
  return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
194
195
  }
196
+ /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
197
+ * reason; any other error is unexpected and logs at error with the stringified cause. The caller
198
+ * still owns the redirect or rethrow, so control flow stays at the call site. */
199
+ function logCommitFailed(fields, err) {
200
+ if (isConflict(err)) {
201
+ log.warn('commit.failed', { ...fields, reason: 'conflict' });
202
+ }
203
+ else {
204
+ log.error('commit.failed', { ...fields, error: String(err) });
205
+ }
206
+ }
195
207
  /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
196
208
  async function saveAction(event) {
197
209
  const editor = sessionOf(event);
@@ -245,13 +257,16 @@ export function createContentRoutes(runtime, deps = {}) {
245
257
  if (absent.length) {
246
258
  return fail(400, { brokenLinks: absent, body });
247
259
  }
260
+ const commitFields = { concept: concept.id, id, editor: editor.email };
248
261
  try {
249
262
  await commitFiles(runtime.backend, [
250
263
  { path, content: markdown },
251
264
  { path: runtime.manifestPath, content: nextManifest },
252
265
  ], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
266
+ log.info('commit.succeeded', commitFields);
253
267
  }
254
268
  catch (err) {
269
+ logCommitFailed(commitFields, err);
255
270
  if (isConflict(err)) {
256
271
  const message = 'This file changed since you opened it. Reload and reapply your edits.';
257
272
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
@@ -278,13 +293,16 @@ export function createContentRoutes(runtime, deps = {}) {
278
293
  return fail(409, { inboundLinks: inbound, id });
279
294
  }
280
295
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
296
+ const commitFields = { concept: concept.id, id, editor: editor.email };
281
297
  try {
282
298
  await commitFiles(runtime.backend, [
283
299
  { path, content: null },
284
300
  { path: runtime.manifestPath, content: nextManifest },
285
301
  ], { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
302
+ log.info('commit.succeeded', commitFields);
286
303
  }
287
304
  catch (err) {
305
+ logCommitFailed(commitFields, err);
288
306
  if (isConflict(err)) {
289
307
  const message = 'This file changed since you opened it. Reload and try again.';
290
308
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -378,10 +396,13 @@ export function createContentRoutes(runtime, deps = {}) {
378
396
  next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
379
397
  }
380
398
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
399
+ const commitFields = { concept: concept.id, id: newId, editor: editor.email };
381
400
  try {
382
401
  await commitFiles(runtime.backend, changes, { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } }, token);
402
+ log.info('commit.succeeded', commitFields);
383
403
  }
384
404
  catch (err) {
405
+ logCommitFailed(commitFields, err);
385
406
  if (isConflict(err)) {
386
407
  const message = 'This file changed since you opened it. Reload and try again.';
387
408
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -7,6 +7,7 @@ import { sessionCookieName } from '../auth/crypto.js';
7
7
  import { httpsRequiredPage } from './https-required-page.js';
8
8
  import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
9
9
  import { csrfRequiredPage } from './csrf-required-page.js';
10
+ import { log } from '../log/index.js';
10
11
  /** The login page and the auth endpoints are public; everything else under /admin is gated. */
11
12
  function isPublicAdminPath(pathname) {
12
13
  return pathname === '/admin/login' || pathname.startsWith('/admin/auth/');
@@ -69,8 +70,10 @@ export function createAuthGuard() {
69
70
  // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
70
71
  // they set checkOrigin: false to hand cairn the admin CSRF authority.
71
72
  if (!isAdminPath(pathname)) {
72
- if (isUnsafeFormRequest(event.request) && !originMatches(event))
73
+ if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
74
+ log.warn('guard.rejected', { reason: 'origin', path: pathname });
73
75
  return csrfForbidden();
76
+ }
74
77
  return resolve(event);
75
78
  }
76
79
  // A deployed admin request over http never works: the magic-link form POST would fail the
@@ -78,11 +81,13 @@ export function createAuthGuard() {
78
81
  // runs that check. This covers the public login/auth paths too, since that is where the form
79
82
  // posts. Local http (wrangler dev) is exempt.
80
83
  if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
84
+ log.warn('guard.rejected', { reason: 'https', path: pathname });
81
85
  return httpsRequiredResponse(event.url);
82
86
  }
83
87
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
84
88
  // 403 before resolve() runs. This covers the public login/auth posts too.
85
89
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
90
+ log.warn('guard.rejected', { reason: 'csrf', path: pathname });
86
91
  return csrfRequiredResponse();
87
92
  }
88
93
  if (!isPublicAdminPath(pathname)) {
@@ -6,6 +6,7 @@ import { appCredentials } 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 } from '../nav/site-config.js';
10
11
  /** The signed-in editor the guard resolved, or a login redirect. */
11
12
  function sessionOf(event) {
@@ -87,14 +88,18 @@ export function createNavRoutes(runtime, deps = {}) {
87
88
  const raw = await readRaw(runtime.backend, config.configPath, token);
88
89
  if (raw === null)
89
90
  throw error(404, 'Site config not found');
91
+ const commitFields = { concept: 'nav', id: 'site-config', editor: editor.email };
90
92
  try {
91
93
  await commitFile(runtime.backend, config.configPath, setMenu(raw, config.menuName, tree), { message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } }, token);
94
+ log.info('commit.succeeded', commitFields);
92
95
  }
93
96
  catch (err) {
94
97
  if (isConflict(err)) {
98
+ log.warn('commit.failed', { ...commitFields, reason: 'conflict' });
95
99
  const message = 'The site config changed since you opened it. Reload and reapply your edits.';
96
100
  throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
97
101
  }
102
+ log.error('commit.failed', { ...commitFields, error: String(err) });
98
103
  throw err;
99
104
  }
100
105
  throw redirect(303, '/admin/nav?saved=1');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -7,6 +7,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
7
7
  <script lang="ts">
8
8
  import './cairn-admin.css';
9
9
  import { onMount } from 'svelte';
10
+ import MailCheckIcon from '@lucide/svelte/icons/mail-check';
10
11
  import CairnLogo from './CairnLogo.svelte';
11
12
  import CsrfField from './CsrfField.svelte';
12
13
  import { cairnFaviconHref } from './cairn-favicon.js';
@@ -22,6 +23,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
22
23
  let { data, form }: Props = $props();
23
24
 
24
25
  let rootEl = $state<HTMLElement>();
26
+ // Lets a mistyped address go back to the form without a reload, even though the server still
27
+ // reports `sent`. The success copy never reveals whether the email was on the allowlist.
28
+ let dismissed = $state(false);
25
29
  onMount(() => {
26
30
  if (rootEl) warnIfChromeWrapped(rootEl);
27
31
  });
@@ -44,13 +48,35 @@ the allowlist, so the page never leaks membership (spec §7.1).
44
48
  </div>
45
49
 
46
50
  <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
47
- <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
48
51
 
49
- {#if form?.sent}
50
- <div role="status" class="alert alert-success text-sm">
51
- Check your email for a sign-in link. It expires in 10 minutes.
52
+ {#if form?.sent && !dismissed}
53
+ <div role="status" class="mt-5 flex flex-col items-center text-center">
54
+ <div
55
+ class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl text-[var(--color-success)]"
56
+ style="background-color: color-mix(in oklch, var(--color-success) 16%, transparent);"
57
+ >
58
+ <MailCheckIcon class="h-6 w-6" />
59
+ </div>
60
+ <h2 class="text-lg font-semibold">Check your email</h2>
61
+ <p class="mt-1 text-sm text-[var(--color-muted)]">
62
+ We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
63
+ </p>
64
+ <div class="mt-5 w-full border-t border-[var(--cairn-card-border)] pt-4 text-left">
65
+ <p class="text-sm text-[var(--color-muted)]">
66
+ No link after a minute or two? Check your spam folder first. If it still hasn't arrived,
67
+ double-check the address. It has to match the one your site owner added.
68
+ </p>
69
+ <button
70
+ type="button"
71
+ class="btn btn-ghost btn-sm mt-3 -ml-2 text-primary"
72
+ onclick={() => (dismissed = true)}
73
+ >
74
+ Use a different email
75
+ </button>
76
+ </div>
52
77
  </div>
53
78
  {:else}
79
+ <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
54
80
  {#if data.error}
55
81
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
56
82
  {/if}
@@ -0,0 +1,42 @@
1
+ // The engine's one logger and the single console chokepoint. Every diagnostic routes through
2
+ // `log`; today each call writes a structured JSON object to console, which Workers Logs ingests
3
+ // and indexes when a consumer sets observability.enabled. A future admin-extension pass adds a
4
+ // subscriber fan-out inside this module, leaving every call site unchanged.
5
+ import type { CairnLogEvent } from './events.js';
6
+
7
+ export type LogLevel = 'info' | 'warn' | 'error';
8
+
9
+ export interface LogRecord {
10
+ level: LogLevel;
11
+ event: CairnLogEvent;
12
+ timestamp: string;
13
+ [field: string]: unknown;
14
+ }
15
+
16
+ export interface Logger {
17
+ info(event: CairnLogEvent, fields?: Record<string, unknown>): void;
18
+ warn(event: CairnLogEvent, fields?: Record<string, unknown>): void;
19
+ error(event: CairnLogEvent, fields?: Record<string, unknown>): void;
20
+ }
21
+
22
+ const sinkByLevel: Record<LogLevel, (record: LogRecord) => void> = {
23
+ info: (record) => console.log(record),
24
+ warn: (record) => console.warn(record),
25
+ error: (record) => console.error(record),
26
+ };
27
+
28
+ function buildRecord(level: LogLevel, event: CairnLogEvent, fields: Record<string, unknown>): LogRecord {
29
+ // The envelope keys are written last, so a stray field named level/event/timestamp cannot
30
+ // corrupt the record shape a subscriber relies on.
31
+ return { ...fields, level, event, timestamp: new Date().toISOString() };
32
+ }
33
+
34
+ function emit(level: LogLevel, event: CairnLogEvent, fields: Record<string, unknown> = {}): void {
35
+ sinkByLevel[level](buildRecord(level, event, fields));
36
+ }
37
+
38
+ export const log: Logger = {
39
+ info: (event, fields) => emit('info', event, fields),
40
+ warn: (event, fields) => emit('warn', event, fields),
41
+ error: (event, fields) => emit('error', event, fields),
42
+ };
@@ -0,0 +1,13 @@
1
+ // The cairn engine's diagnostic event vocabulary. Each name is the stable `type` a future
2
+ // admin-extension subscriber switches on, so it is public-observable API: renaming one is a
3
+ // breaking change. See docs/reference/log-events.md, kept in step with this union.
4
+ export type CairnLogEvent =
5
+ | 'auth.link.requested'
6
+ | 'auth.link.send_failed'
7
+ | 'auth.token.minted'
8
+ | 'auth.token.confirmed'
9
+ | 'auth.session.created'
10
+ | 'auth.session.destroyed'
11
+ | 'commit.succeeded'
12
+ | 'commit.failed'
13
+ | 'guard.rejected';
@@ -0,0 +1,3 @@
1
+ export { log } from './emit.js';
2
+ export type { Logger, LogLevel, LogRecord } from './emit.js';
3
+ export type { CairnLogEvent } from './events.js';
@@ -15,6 +15,7 @@ import {
15
15
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
16
16
  import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
17
17
  import { issueCsrfToken } from './csrf.js';
18
+ import { log } from '../log/index.js';
18
19
  import type { RequestContext } from './types.js';
19
20
 
20
21
  export interface AuthRoutesConfig {
@@ -36,6 +37,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
36
37
  const db = requireDb(env);
37
38
  const form = await event.request.formData();
38
39
  const email = String(form.get('email') ?? '').trim().toLowerCase();
40
+ // `email` here is unvalidated request input logged before the allowlist check, so bound the
41
+ // logged value to the RFC 5321 maximum to cap an abusive record's size. A real editor's address
42
+ // fits well under this; only a junk payload is truncated.
43
+ log.info('auth.link.requested', { email: email.slice(0, 320) });
39
44
 
40
45
  const editor = email ? await findEditor(db, email) : null;
41
46
  if (editor) {
@@ -46,13 +51,14 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
46
51
  if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
47
52
  const token = generateToken();
48
53
  await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
54
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
49
55
  const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
50
56
  // The token row is the security-critical write the email depends on, so it is awaited. The
51
57
  // send is a post-response side effect, handed to waitUntil so a slow email provider does not
52
58
  // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
53
59
  // failure is logged so observability survives a backgrounded send.
54
60
  const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
55
- (err) => console.error('cairn: magic-link send failed', err),
61
+ (err) => log.error('auth.link.send_failed', { email, error: String(err) }),
56
62
  );
57
63
  // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
58
64
  // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
@@ -104,9 +110,11 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
104
110
  const now = Date.now();
105
111
  const email = await consumeToken(db, await hashToken(token), now);
106
112
  if (!email) throw redirect(303, '/admin/login?error=expired');
113
+ log.info('auth.token.confirmed', { email });
107
114
 
108
115
  const id = generateSessionId();
109
116
  await createSession(db, id, email, now + SESSION_TTL_MS, now);
117
+ log.info('auth.session.created', { email });
110
118
  const secure = event.url.protocol === 'https:';
111
119
  event.cookies.set(sessionCookieName(secure), id, {
112
120
  path: '/',
@@ -124,7 +132,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
124
132
  const db = requireDb(event.platform?.env ?? {});
125
133
  const name = sessionCookieName(event.url.protocol === 'https:');
126
134
  const id = event.cookies.get(name);
127
- if (id) await deleteSession(db, id);
135
+ if (id) {
136
+ await deleteSession(db, id);
137
+ log.info('auth.session.destroyed');
138
+ }
128
139
  event.cookies.delete(name, { path: '/' });
129
140
  throw redirect(303, '/admin/login');
130
141
  }
@@ -13,6 +13,7 @@ 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';
16
17
  import { issueCsrfToken } from './csrf.js';
17
18
  import type { CookieJar } from './types.js';
18
19
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
@@ -283,6 +284,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
283
284
  return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
284
285
  }
285
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
+
286
298
  /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
287
299
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
288
300
  const editor = sessionOf(event);
@@ -338,6 +350,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
338
350
  return fail(400, { brokenLinks: absent, body });
339
351
  }
340
352
 
353
+ const commitFields = { concept: concept.id, id, editor: editor.email };
341
354
  try {
342
355
  await commitFiles(
343
356
  runtime.backend,
@@ -348,7 +361,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
348
361
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
349
362
  token,
350
363
  );
364
+ log.info('commit.succeeded', commitFields);
351
365
  } catch (err) {
366
+ logCommitFailed(commitFields, err);
352
367
  if (isConflict(err)) {
353
368
  const message = 'This file changed since you opened it. Reload and reapply your edits.';
354
369
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
@@ -383,6 +398,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
383
398
  }
384
399
 
385
400
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
401
+ const commitFields = { concept: concept.id, id, editor: editor.email };
386
402
  try {
387
403
  await commitFiles(
388
404
  runtime.backend,
@@ -393,7 +409,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
393
409
  { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
394
410
  token,
395
411
  );
412
+ log.info('commit.succeeded', commitFields);
396
413
  } catch (err) {
414
+ logCommitFailed(commitFields, err);
397
415
  if (isConflict(err)) {
398
416
  const message = 'This file changed since you opened it. Reload and try again.';
399
417
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -492,6 +510,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
492
510
 
493
511
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
494
512
 
513
+ const commitFields = { concept: concept.id, id: newId, editor: editor.email };
495
514
  try {
496
515
  await commitFiles(
497
516
  runtime.backend,
@@ -499,7 +518,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
499
518
  { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } },
500
519
  token,
501
520
  );
521
+ log.info('commit.succeeded', commitFields);
502
522
  } catch (err) {
523
+ logCommitFailed(commitFields, err);
503
524
  if (isConflict(err)) {
504
525
  const message = 'This file changed since you opened it. Reload and try again.';
505
526
  throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
@@ -7,6 +7,7 @@ import { sessionCookieName } from '../auth/crypto.js';
7
7
  import { httpsRequiredPage } from './https-required-page.js';
8
8
  import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
9
9
  import { csrfRequiredPage } from './csrf-required-page.js';
10
+ import { log } from '../log/index.js';
10
11
  import type { Editor } from '../auth/types.js';
11
12
  import type { HandleInput, RequestContext } from './types.js';
12
13
 
@@ -83,7 +84,10 @@ export function createAuthGuard() {
83
84
  // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
84
85
  // they set checkOrigin: false to hand cairn the admin CSRF authority.
85
86
  if (!isAdminPath(pathname)) {
86
- if (isUnsafeFormRequest(event.request) && !originMatches(event)) return csrfForbidden();
87
+ if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
88
+ log.warn('guard.rejected', { reason: 'origin', path: pathname });
89
+ return csrfForbidden();
90
+ }
87
91
  return resolve(event);
88
92
  }
89
93
 
@@ -92,12 +96,14 @@ export function createAuthGuard() {
92
96
  // runs that check. This covers the public login/auth paths too, since that is where the form
93
97
  // posts. Local http (wrangler dev) is exempt.
94
98
  if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
99
+ log.warn('guard.rejected', { reason: 'https', path: pathname });
95
100
  return httpsRequiredResponse(event.url);
96
101
  }
97
102
 
98
103
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
99
104
  // 403 before resolve() runs. This covers the public login/auth posts too.
100
105
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
106
+ log.warn('guard.rejected', { reason: 'csrf', path: pathname });
101
107
  return csrfRequiredResponse();
102
108
  }
103
109
 
@@ -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