@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 +31 -0
- package/dist/components/LoginPage.svelte +30 -4
- package/dist/components/cairn-admin.css +36 -0
- package/dist/log/emit.d.ts +14 -0
- package/dist/log/emit.js +18 -0
- package/dist/log/events.d.ts +1 -0
- package/dist/log/events.js +1 -0
- package/dist/log/index.d.ts +3 -0
- package/dist/log/index.js +1 -0
- package/dist/sveltekit/auth-routes.js +12 -2
- package/dist/sveltekit/content-routes.js +21 -0
- package/dist/sveltekit/guard.js +6 -1
- package/dist/sveltekit/nav-routes.js +5 -0
- package/package.json +1 -1
- package/src/lib/components/LoginPage.svelte +30 -4
- package/src/lib/log/emit.ts +42 -0
- package/src/lib/log/events.ts +13 -0
- package/src/lib/log/index.ts +3 -0
- package/src/lib/sveltekit/auth-routes.ts +13 -2
- package/src/lib/sveltekit/content-routes.ts +21 -0
- package/src/lib/sveltekit/guard.ts +7 -1
- package/src/lib/sveltekit/nav-routes.ts +5 -0
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="
|
|
51
|
-
|
|
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;
|
package/dist/log/emit.js
ADDED
|
@@ -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 @@
|
|
|
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) =>
|
|
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)}`);
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -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
|
@@ -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="
|
|
51
|
-
|
|
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';
|
|
@@ -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) =>
|
|
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)
|
|
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))
|
|
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
|
|