@glw907/cairn-cms 0.36.0 → 0.37.1
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/README.md +69 -48
- package/dist/components/LoginPage.svelte +46 -11
- package/dist/components/cairn-admin.css +61 -0
- package/dist/diagnostics/conditions.d.ts +21 -0
- package/dist/diagnostics/conditions.js +37 -0
- package/dist/diagnostics/error.d.ts +9 -0
- package/dist/diagnostics/error.js +15 -0
- package/dist/diagnostics/index.d.ts +3 -0
- package/dist/diagnostics/index.js +3 -0
- package/dist/github/repo.js +0 -1
- package/dist/github/types.js +0 -1
- package/dist/render/component-grammar.js +1 -1
- package/dist/sveltekit/admin-response.d.ts +7 -0
- package/dist/sveltekit/admin-response.js +21 -0
- package/dist/sveltekit/condition-response.d.ts +11 -0
- package/dist/sveltekit/condition-response.js +34 -0
- package/dist/sveltekit/guard.js +5 -40
- package/package.json +1 -1
- package/src/lib/components/LoginPage.svelte +46 -11
- package/src/lib/diagnostics/conditions.ts +64 -0
- package/src/lib/diagnostics/error.ts +20 -0
- package/src/lib/diagnostics/index.ts +4 -0
- package/src/lib/github/credentials.ts +0 -1
- package/src/lib/github/repo.ts +0 -1
- package/src/lib/github/signing.ts +0 -1
- package/src/lib/github/types.ts +0 -1
- package/src/lib/render/component-grammar.ts +1 -1
- package/src/lib/sveltekit/admin-response.ts +23 -0
- package/src/lib/sveltekit/condition-response.ts +38 -0
- package/src/lib/sveltekit/guard.ts +5 -45
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.1
|
|
6
|
+
|
|
7
|
+
Internal groundwork and a docs overhaul; nothing in the public surface or runtime behavior
|
|
8
|
+
changes, and no consumer action is needed.
|
|
9
|
+
|
|
10
|
+
The diagnostics foundation lands as an internal module: a condition registry
|
|
11
|
+
(`CairnCondition`), a `CairnError` throw primitive, and a shared condition-response renderer
|
|
12
|
+
that the admin guard's three rejection responses (the two CSRF reasons and the HTTPS check) now
|
|
13
|
+
route through. Those responses are unchanged and regression-pinned, and the module exports from
|
|
14
|
+
no package subpath. This is Pass 1 of the diagnostics initiative, the base the upcoming
|
|
15
|
+
`cairn doctor` and readiness checks build on.
|
|
16
|
+
|
|
17
|
+
Docs are reorganized and rewritten. A new README front door tells the save-flow story, says
|
|
18
|
+
what cairn is not, names the chosen stack, and then opens three doors: the tutorial, the
|
|
19
|
+
showcase, and the docs map. Stray top-level pages joined their Diátaxis arms (the admin route
|
|
20
|
+
contract is `docs/reference/admin-routes.md`, the sanitize floor is
|
|
21
|
+
`docs/explanation/render-safety.md`, key rotation is
|
|
22
|
+
`docs/guides/rotate-the-github-app-key.md`), and every adopter-facing page is rewritten in a
|
|
23
|
+
second-person, example-first voice with its technical content intact.
|
|
24
|
+
|
|
25
|
+
The magic-link sign-in confirmation is now a branded panel in place of the flat success bar. After an
|
|
26
|
+
editor requests a link, the page shows a mail icon in a soft success tile, a "Check your email"
|
|
27
|
+
heading, and the ten-minute expiry note, all in the admin's Warm Stone styling. Below a divider it
|
|
28
|
+
adds guidance for the link that never arrives: check the spam folder first, then confirm the address
|
|
29
|
+
matches the one the site owner added. This covers the common fat-finger case, where a mistyped address
|
|
30
|
+
gets the same neutral confirmation and no email. A "Use a different email" action returns to the form
|
|
31
|
+
so the address gets corrected without a reload. The confirmation copy stays identical whether or not
|
|
32
|
+
the email is on the allowlist, so the page still never leaks membership.
|
|
33
|
+
|
|
34
|
+
The change is internal to the `LoginPage` component and needs no action.
|
|
35
|
+
|
|
5
36
|
## 0.36.0
|
|
6
37
|
|
|
7
38
|
cairn now emits structured diagnostic events. The engine had three bare `console.error` calls and no
|
package/README.md
CHANGED
|
@@ -1,33 +1,62 @@
|
|
|
1
1
|
# cairn-cms
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
A CMS that lives inside your SvelteKit site and commits to git. Your editors log in with an
|
|
4
|
+
email link (no GitHub account, no password), write raw markdown in a CodeMirror editor with a
|
|
5
|
+
live preview, and hit Save.
|
|
6
|
+
|
|
7
|
+
When they hit Save, cairn doesn't write to a database. It commits the markdown straight to
|
|
8
|
+
the repo's `main` branch. The commit goes through a GitHub App, so the editor never touches
|
|
9
|
+
GitHub; they still show up as the commit author, and `cairn-cms[bot]` does the signing. From
|
|
10
|
+
there your normal Cloudflare deploy takes over, the same as if you'd pushed from a terminal.
|
|
11
|
+
Commit is publish. If someone else changed the file mid-edit, cairn refuses the save instead
|
|
12
|
+
of guessing how to merge.
|
|
13
|
+
|
|
14
|
+
## How it fits your site
|
|
15
|
+
|
|
16
|
+
cairn is an engine your site imports, not a platform you deploy to. The engine owns the
|
|
17
|
+
machinery that has to be right: the magic-link auth, the commit path, the admin app, the
|
|
18
|
+
render pipeline and its sanitize floor. Your site owns everything a visitor sees: the adapter
|
|
19
|
+
(your content concepts, frontmatter schema, and slug rules through
|
|
20
|
+
`defineAdapter`/`defineFields`), your markdown pipeline, your CSS. Two production sites run
|
|
21
|
+
the same engine today and look nothing alike: [ecnordic.ski](https://ecnordic.ski) renders
|
|
22
|
+
through its own remark directive pipeline, [907.life](https://907.life) through the engine's
|
|
23
|
+
`createRenderer`.
|
|
24
|
+
|
|
25
|
+
## What cairn is not
|
|
26
|
+
|
|
27
|
+
- Not a hosted platform. There is no cairn server; the admin ships inside your site's Worker
|
|
28
|
+
and your repo is the source of truth.
|
|
29
|
+
- Not a database CMS. Content is markdown files in your repo, so it outlives the tool and
|
|
30
|
+
never needs an export.
|
|
31
|
+
- Not an open-ended collection builder. Content is a fixed set of first-class concepts (Posts
|
|
32
|
+
and Pages today), each with its own behavior, because the engine should have an opinion
|
|
33
|
+
about what a Post is.
|
|
34
|
+
|
|
35
|
+
## The stack is chosen for you
|
|
36
|
+
|
|
37
|
+
SvelteKit on Cloudflare Workers, D1 for the auth store, GitHub for content. cairn is
|
|
38
|
+
deliberately opinionated: if that stack matches yours, the pieces click together, and if it
|
|
39
|
+
doesn't, cairn is the wrong tool and will not try to meet you halfway.
|
|
40
|
+
|
|
41
|
+
## Start here
|
|
42
|
+
|
|
43
|
+
1. **[Build your first cairn site](./docs/tutorial/build-your-first-cairn-site.md)**, the
|
|
44
|
+
tutorial, takes you from an empty directory to a deployed site with a working `/admin`.
|
|
45
|
+
2. **[`examples/showcase`](./examples/showcase)** is a complete consumer site wired to the
|
|
46
|
+
engine, and the worked reference for every shape in the docs. When a guide says "mount the
|
|
47
|
+
route shims," the showcase shows the mounted result.
|
|
48
|
+
3. **[The docs](./docs/README.md)** are organized in four arms: the tutorial, task guides,
|
|
49
|
+
one reference page per export, and explanation pages for the architecture and design
|
|
50
|
+
rules.
|
|
14
51
|
|
|
15
52
|
## Status
|
|
16
53
|
|
|
17
|
-
cairn-cms runs two production sites
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Editor auth is self-owned: an atomic single-use magic-link token, a POST-confirm flow, opaque
|
|
24
|
-
D1-backed session rows, and two-tier `owner`/`editor` roles. There is no better-auth, Drizzle,
|
|
25
|
-
or ORM. Pin a caret range and read the CHANGELOG before bumping; every breaking entry carries a
|
|
26
|
-
"Consumers must" line.
|
|
27
|
-
|
|
28
|
-
A contributor who feels inspired is welcome to open an issue or a discussion to start a
|
|
29
|
-
conversation. There is no formal contribution process yet, so this is not an open call for
|
|
30
|
-
pull requests.
|
|
54
|
+
cairn-cms runs the two production sites above. It is `0.x` and breaks between minor versions,
|
|
55
|
+
so pin a caret range and read the [CHANGELOG](./CHANGELOG.md) before bumping; every breaking
|
|
56
|
+
entry carries a "Consumers must" line. The author is still working through the core-feature
|
|
57
|
+
[ROADMAP](./ROADMAP.md), and the project stays closely held until that core lands. A
|
|
58
|
+
contributor who feels inspired is welcome to open an issue or a discussion; there is no
|
|
59
|
+
formal contribution process yet, so this is not an open call for pull requests.
|
|
31
60
|
|
|
32
61
|
## Install
|
|
33
62
|
|
|
@@ -35,35 +64,27 @@ pull requests.
|
|
|
35
64
|
npm install @glw907/cairn-cms
|
|
36
65
|
```
|
|
37
66
|
|
|
38
|
-
Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a
|
|
39
|
-
and mounts thin `/admin` route shims around the package subpaths:
|
|
67
|
+
Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a
|
|
68
|
+
`CairnAdapter` and mounts thin `/admin` route shims around the package subpaths:
|
|
40
69
|
|
|
41
70
|
- `@glw907/cairn-cms`: the core engine and adapter contract.
|
|
42
71
|
- `@glw907/cairn-cms/sveltekit`: the server load and action logic.
|
|
43
72
|
- `@glw907/cairn-cms/components`: the admin Svelte UI.
|
|
44
73
|
- `@glw907/cairn-cms/delivery` and `/delivery/data`: the public read model (indexes, feeds,
|
|
45
|
-
sitemap, SEO head). The `/delivery/data` barrel is node-safe, with no `@sveltejs/kit` in
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
magic tokens) and a `[[send_email]]` binding named `EMAIL`. The worked reference for every shape is
|
|
51
|
-
`examples/showcase`.
|
|
74
|
+
sitemap, SEO head). The `/delivery/data` barrel is node-safe, with no `@sveltejs/kit` in
|
|
75
|
+
its graph.
|
|
76
|
+
- `@glw907/cairn-cms/vite`: the `cairnManifest()` Vite plugin, paired with the
|
|
77
|
+
`cairn-manifest` bin, that builds and verifies the committed content manifest at build
|
|
78
|
+
time.
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
site end to end, how-to guides for each setup task, a reference for every package export, and
|
|
57
|
-
explanation pages for the architecture and design rules. Start at the
|
|
58
|
-
[documentation index](./docs/README.md). The [security policy](./SECURITY.md) covers reporting
|
|
59
|
-
and the security posture.
|
|
80
|
+
Each site binds a Cloudflare D1 database as `AUTH_DB` (the editor allowlist, sessions, and
|
|
81
|
+
single-use magic tokens) and a `[[send_email]]` binding named `EMAIL`. The
|
|
82
|
+
[security policy](./SECURITY.md) covers reporting and the security posture.
|
|
60
83
|
|
|
61
84
|
## How it's developed
|
|
62
85
|
|
|
63
|
-
This is a standalone repo. Consumer sites install the published package from the npm registry
|
|
64
|
-
version range. The library's own development proves changes against `examples/showcase`,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
The historical rebuild plan and the early architecture writeups live under `docs/internal/`.
|
|
69
|
-
They are kept for history and are not current.
|
|
86
|
+
This is a standalone repo. Consumer sites install the published package from the npm registry
|
|
87
|
+
by version range. The library's own development proves changes against `examples/showcase`,
|
|
88
|
+
which consumes the package through the relative `file:../..` path, so a change is exercised
|
|
89
|
+
end to end before it publishes. The historical rebuild plan and the early architecture
|
|
90
|
+
writeups live under `docs/internal/history/` and are not current.
|
|
@@ -7,6 +7,8 @@ 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';
|
|
11
|
+
import InfoIcon from '@lucide/svelte/icons/info';
|
|
10
12
|
import CairnLogo from './CairnLogo.svelte';
|
|
11
13
|
import CsrfField from './CsrfField.svelte';
|
|
12
14
|
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
@@ -22,6 +24,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
22
24
|
let { data, form }: Props = $props();
|
|
23
25
|
|
|
24
26
|
let rootEl = $state<HTMLElement>();
|
|
27
|
+
// Lets a mistyped address go back to the form without a reload, even though the server still
|
|
28
|
+
// reports `sent`. The success copy never reveals whether the email was on the allowlist.
|
|
29
|
+
let dismissed = $state(false);
|
|
25
30
|
onMount(() => {
|
|
26
31
|
if (rootEl) warnIfChromeWrapped(rootEl);
|
|
27
32
|
});
|
|
@@ -33,24 +38,54 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
33
38
|
<meta name="robots" content="noindex, nofollow" />
|
|
34
39
|
</svelte:head>
|
|
35
40
|
|
|
41
|
+
<!-- The brand mark renders in both states; the parent container sets its alignment (left for the
|
|
42
|
+
form, centered for the confirmation), so one snippet covers both. -->
|
|
43
|
+
{#snippet brand()}
|
|
44
|
+
<div class="flex items-center gap-2">
|
|
45
|
+
<CairnLogo class="h-8 w-8 text-primary" />
|
|
46
|
+
<span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
|
|
47
|
+
</div>
|
|
48
|
+
{/snippet}
|
|
49
|
+
|
|
36
50
|
<!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
|
|
37
51
|
level in (a class on the theme element itself would not match). -->
|
|
38
52
|
<div data-theme="cairn-admin" bind:this={rootEl}>
|
|
39
53
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
|
|
40
54
|
<div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
{#if form?.sent && !dismissed}
|
|
56
|
+
<!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
|
|
57
|
+
instruction. The fallback help sits in a gentle inset note below. -->
|
|
58
|
+
<div role="status" class="flex flex-col items-center text-center">
|
|
59
|
+
<div class="mb-7">{@render brand()}</div>
|
|
60
|
+
<div
|
|
61
|
+
class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
|
|
62
|
+
style="background-color: color-mix(in oklch, var(--color-success) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-success) 22%, transparent);"
|
|
63
|
+
>
|
|
64
|
+
<MailCheckIcon class="h-6 w-6" />
|
|
65
|
+
</div>
|
|
66
|
+
<h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
|
|
67
|
+
<p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
|
|
68
|
+
We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
|
|
69
|
+
</p>
|
|
70
|
+
<div class="mt-6 flex w-full items-start gap-2.5 rounded-[var(--radius-field)] bg-base-content/[0.04] p-3.5 text-left">
|
|
71
|
+
<InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
|
|
72
|
+
<p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
|
|
73
|
+
No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
|
|
74
|
+
address may not match the one your site owner added.
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
|
|
80
|
+
onclick={() => (dismissed = true)}
|
|
81
|
+
>
|
|
82
|
+
Use a different email
|
|
83
|
+
</button>
|
|
52
84
|
</div>
|
|
53
85
|
{:else}
|
|
86
|
+
<div class="mb-6 flex justify-center">{@render brand()}</div>
|
|
87
|
+
<h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
88
|
+
<p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
|
|
54
89
|
{#if data.error}
|
|
55
90
|
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
|
|
56
91
|
{/if}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
--tw-skew-y: initial;
|
|
12
12
|
--tw-space-y-reverse: 0;
|
|
13
13
|
--tw-border-style: solid;
|
|
14
|
+
--tw-leading: initial;
|
|
14
15
|
--tw-font-weight: initial;
|
|
15
16
|
--tw-tracking: initial;
|
|
16
17
|
--tw-ordinal: initial;
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
--font-weight-bold: 700;
|
|
80
81
|
--tracking-tight: -.025em;
|
|
81
82
|
--tracking-wide: .025em;
|
|
83
|
+
--leading-relaxed: 1.625;
|
|
82
84
|
--radius-md: .375rem;
|
|
83
85
|
--radius-xl: .75rem;
|
|
84
86
|
--animate-spin: spin 1s linear infinite;
|
|
@@ -3337,6 +3339,10 @@
|
|
|
3337
3339
|
margin-top: calc(var(--spacing) * 4);
|
|
3338
3340
|
}
|
|
3339
3341
|
|
|
3342
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-5 {
|
|
3343
|
+
margin-top: calc(var(--spacing) * 5);
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3340
3346
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-6 {
|
|
3341
3347
|
margin-top: calc(var(--spacing) * 6);
|
|
3342
3348
|
}
|
|
@@ -3353,6 +3359,10 @@
|
|
|
3353
3359
|
margin-top: auto;
|
|
3354
3360
|
}
|
|
3355
3361
|
|
|
3362
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-px {
|
|
3363
|
+
margin-top: 1px;
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3356
3366
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mr-1 {
|
|
3357
3367
|
margin-right: calc(var(--spacing) * 1);
|
|
3358
3368
|
}
|
|
@@ -3394,6 +3404,10 @@
|
|
|
3394
3404
|
margin-bottom: calc(var(--spacing) * 6);
|
|
3395
3405
|
}
|
|
3396
3406
|
|
|
3407
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mb-7 {
|
|
3408
|
+
margin-bottom: calc(var(--spacing) * 7);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3397
3411
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ml-1 {
|
|
3398
3412
|
margin-left: calc(var(--spacing) * 1);
|
|
3399
3413
|
}
|
|
@@ -3786,6 +3800,10 @@
|
|
|
3786
3800
|
height: calc(var(--spacing) * 5);
|
|
3787
3801
|
}
|
|
3788
3802
|
|
|
3803
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-6 {
|
|
3804
|
+
height: calc(var(--spacing) * 6);
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3789
3807
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-7 {
|
|
3790
3808
|
height: calc(var(--spacing) * 7);
|
|
3791
3809
|
}
|
|
@@ -3852,6 +3870,10 @@
|
|
|
3852
3870
|
width: calc(var(--spacing) * 5);
|
|
3853
3871
|
}
|
|
3854
3872
|
|
|
3873
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-6 {
|
|
3874
|
+
width: calc(var(--spacing) * 6);
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3855
3877
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-7 {
|
|
3856
3878
|
width: calc(var(--spacing) * 7);
|
|
3857
3879
|
}
|
|
@@ -4014,6 +4036,10 @@
|
|
|
4014
4036
|
cursor: pointer;
|
|
4015
4037
|
}
|
|
4016
4038
|
|
|
4039
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .appearance-none {
|
|
4040
|
+
appearance: none;
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4017
4043
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .flex-col {
|
|
4018
4044
|
flex-direction: column;
|
|
4019
4045
|
}
|
|
@@ -4146,6 +4172,10 @@
|
|
|
4146
4172
|
border-radius: .25rem;
|
|
4147
4173
|
}
|
|
4148
4174
|
|
|
4175
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-\[var\(--radius-field\)\] {
|
|
4176
|
+
border-radius: var(--radius-field);
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4149
4179
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-box {
|
|
4150
4180
|
border-radius: var(--radius-box);
|
|
4151
4181
|
border-radius: var(--radius-box);
|
|
@@ -4188,6 +4218,11 @@
|
|
|
4188
4218
|
border-bottom-width: 1px;
|
|
4189
4219
|
}
|
|
4190
4220
|
|
|
4221
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-none {
|
|
4222
|
+
--tw-border-style: none;
|
|
4223
|
+
border-style: none;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4191
4226
|
@layer daisyui.l1.l2 {
|
|
4192
4227
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .badge-ghost {
|
|
4193
4228
|
border-color: var(--color-base-200);
|
|
@@ -4305,6 +4340,10 @@
|
|
|
4305
4340
|
padding: calc(var(--spacing) * 3);
|
|
4306
4341
|
}
|
|
4307
4342
|
|
|
4343
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-3\.5 {
|
|
4344
|
+
padding: calc(var(--spacing) * 3.5);
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4308
4347
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-4 {
|
|
4309
4348
|
padding: calc(var(--spacing) * 4);
|
|
4310
4349
|
}
|
|
@@ -4432,6 +4471,10 @@
|
|
|
4432
4471
|
text-align: center;
|
|
4433
4472
|
}
|
|
4434
4473
|
|
|
4474
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-left {
|
|
4475
|
+
text-align: left;
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4435
4478
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-right {
|
|
4436
4479
|
text-align: right;
|
|
4437
4480
|
}
|
|
@@ -4489,6 +4532,15 @@
|
|
|
4489
4532
|
font-size: .6875rem;
|
|
4490
4533
|
}
|
|
4491
4534
|
|
|
4535
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.8125rem\] {
|
|
4536
|
+
font-size: .8125rem;
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .leading-relaxed {
|
|
4540
|
+
--tw-leading: var(--leading-relaxed);
|
|
4541
|
+
line-height: var(--leading-relaxed);
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4492
4544
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .font-bold {
|
|
4493
4545
|
--tw-font-weight: var(--font-weight-bold);
|
|
4494
4546
|
font-weight: var(--font-weight-bold);
|
|
@@ -4561,6 +4613,10 @@
|
|
|
4561
4613
|
color: var(--color-subtle);
|
|
4562
4614
|
}
|
|
4563
4615
|
|
|
4616
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[var\(--color-success\)\] {
|
|
4617
|
+
color: var(--color-success);
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4564
4620
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-base-content {
|
|
4565
4621
|
color: var(--color-base-content);
|
|
4566
4622
|
}
|
|
@@ -5328,6 +5384,11 @@
|
|
|
5328
5384
|
initial-value: solid;
|
|
5329
5385
|
}
|
|
5330
5386
|
|
|
5387
|
+
@property --tw-leading {
|
|
5388
|
+
syntax: "*";
|
|
5389
|
+
inherits: false
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5331
5392
|
@property --tw-font-weight {
|
|
5332
5393
|
syntax: "*";
|
|
5333
5394
|
inherits: false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CairnLogEvent } from '../log/index.js';
|
|
2
|
+
export type ConditionSeverity = 'blocker' | 'warning';
|
|
3
|
+
export interface CairnCondition {
|
|
4
|
+
/** Stable, greppable id, e.g. 'edge.https-not-forced'. */
|
|
5
|
+
id: string;
|
|
6
|
+
severity: ConditionSeverity;
|
|
7
|
+
/** Short human label. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** One or two sentences on why the condition bites. */
|
|
10
|
+
why: string;
|
|
11
|
+
/** The fix, often a command. */
|
|
12
|
+
remediation: string;
|
|
13
|
+
/** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
|
|
14
|
+
docsAnchor?: string;
|
|
15
|
+
/** The log vocabulary event this condition correlates with, if any. */
|
|
16
|
+
logEvent?: CairnLogEvent;
|
|
17
|
+
}
|
|
18
|
+
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
19
|
+
export declare function condition(id: string): CairnCondition;
|
|
20
|
+
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
21
|
+
export declare function allConditions(): CairnCondition[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const REGISTRY = {
|
|
2
|
+
'edge.https-not-forced': {
|
|
3
|
+
id: 'edge.https-not-forced',
|
|
4
|
+
severity: 'blocker',
|
|
5
|
+
title: 'Always Use HTTPS is off',
|
|
6
|
+
why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
|
|
7
|
+
remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
|
|
8
|
+
logEvent: 'guard.rejected',
|
|
9
|
+
},
|
|
10
|
+
'auth.csrf-token-invalid': {
|
|
11
|
+
id: 'auth.csrf-token-invalid',
|
|
12
|
+
severity: 'blocker',
|
|
13
|
+
title: 'Admin CSRF token check failed',
|
|
14
|
+
why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
|
|
15
|
+
remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
|
|
16
|
+
logEvent: 'guard.rejected',
|
|
17
|
+
},
|
|
18
|
+
'auth.csrf-origin-mismatch': {
|
|
19
|
+
id: 'auth.csrf-origin-mismatch',
|
|
20
|
+
severity: 'blocker',
|
|
21
|
+
title: 'Non-admin form Origin rejected',
|
|
22
|
+
why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
|
|
23
|
+
remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
|
|
24
|
+
logEvent: 'guard.rejected',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
28
|
+
export function condition(id) {
|
|
29
|
+
const found = REGISTRY[id];
|
|
30
|
+
if (!found)
|
|
31
|
+
throw new Error(`unknown cairn condition: ${id}`);
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
35
|
+
export function allConditions() {
|
|
36
|
+
return Object.values(REGISTRY);
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CairnCondition } from './conditions.js';
|
|
2
|
+
export declare class CairnError extends Error {
|
|
3
|
+
readonly conditionId: string;
|
|
4
|
+
readonly condition: CairnCondition;
|
|
5
|
+
constructor(conditionId: string, options?: {
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
message?: string;
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// CairnError: a thrown failure that names a known condition. A catch site narrows on it, logs from
|
|
2
|
+
// the condition, and renders the condition's message in place of an opaque string. Its first
|
|
3
|
+
// throw-site is Pass 2 (the email send mapping); Pass 1 lands and tests the primitive.
|
|
4
|
+
import { condition } from './conditions.js';
|
|
5
|
+
export class CairnError extends Error {
|
|
6
|
+
conditionId;
|
|
7
|
+
condition;
|
|
8
|
+
constructor(conditionId, options) {
|
|
9
|
+
const resolved = condition(conditionId);
|
|
10
|
+
super(options?.message ?? resolved.title, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
11
|
+
this.name = 'CairnError';
|
|
12
|
+
this.conditionId = conditionId;
|
|
13
|
+
this.condition = resolved;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/github/repo.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/repo.ts
|
|
2
1
|
// cairn-cms: repo reads and the commit, over the GitHub REST API. Listing a concept
|
|
3
2
|
// directory uses the Git Trees API (the contents API silently truncates at 1,000 entries,
|
|
4
3
|
// spec §7.3); a single-file read uses the contents API. An optional token lifts reads to
|
package/dist/github/types.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/types.ts
|
|
2
1
|
// cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
|
|
3
2
|
// reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
|
|
4
3
|
// `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
|
|
@@ -47,7 +47,7 @@ export function serializeComponent(def, values) {
|
|
|
47
47
|
if (!content)
|
|
48
48
|
continue;
|
|
49
49
|
if (lines.length > 1)
|
|
50
|
-
lines.push('');
|
|
50
|
+
lines.push('');
|
|
51
51
|
lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
|
|
52
52
|
}
|
|
53
53
|
lines.push(fence);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
3
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
4
|
+
*/
|
|
5
|
+
export declare function applySecurityHeaders(headers: Headers): void;
|
|
6
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
7
|
+
export declare function brandedAdminPage(status: number, body: string): Response;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared response helpers for cairn's admin pages: the baseline security headers and a branded
|
|
2
|
+
// full-document response. Extracted from guard.ts so the guard's resolve path and the condition
|
|
3
|
+
// renderer share one definition.
|
|
4
|
+
/**
|
|
5
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
6
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
7
|
+
*/
|
|
8
|
+
export function applySecurityHeaders(headers) {
|
|
9
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
10
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
11
|
+
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
12
|
+
headers.set('Referrer-Policy', 'no-referrer');
|
|
13
|
+
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
14
|
+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
15
|
+
}
|
|
16
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
17
|
+
export function brandedAdminPage(status, body) {
|
|
18
|
+
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
19
|
+
applySecurityHeaders(headers);
|
|
20
|
+
return new Response(body, { status, headers });
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The guard.rejected reasons, each mapped to its registered condition id. */
|
|
2
|
+
export declare const REASON_CONDITION: {
|
|
3
|
+
readonly https: "edge.https-not-forced";
|
|
4
|
+
readonly csrf: "auth.csrf-token-invalid";
|
|
5
|
+
readonly origin: "auth.csrf-origin-mismatch";
|
|
6
|
+
};
|
|
7
|
+
export type GuardReason = keyof typeof REASON_CONDITION;
|
|
8
|
+
/** Render the Response the guard serves for a rejection, by its condition id. */
|
|
9
|
+
export declare function renderConditionResponse(id: string, ctx?: {
|
|
10
|
+
url?: URL;
|
|
11
|
+
}): Response;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The runtime renderer leg of the diagnostics model: map a condition to the Response the guard
|
|
2
|
+
// serves. Re-homes the three rejection responses guard.ts built inline, keyed by condition id, so
|
|
3
|
+
// the guard's reason, the registered condition, and the served page stay in step.
|
|
4
|
+
import { brandedAdminPage } from './admin-response.js';
|
|
5
|
+
import { httpsRequiredPage } from './https-required-page.js';
|
|
6
|
+
import { csrfRequiredPage } from './csrf-required-page.js';
|
|
7
|
+
import { condition } from '../diagnostics/index.js';
|
|
8
|
+
/** The guard.rejected reasons, each mapped to its registered condition id. */
|
|
9
|
+
export const REASON_CONDITION = {
|
|
10
|
+
https: 'edge.https-not-forced',
|
|
11
|
+
csrf: 'auth.csrf-token-invalid',
|
|
12
|
+
origin: 'auth.csrf-origin-mismatch',
|
|
13
|
+
};
|
|
14
|
+
/** Render the Response the guard serves for a rejection, by its condition id. */
|
|
15
|
+
export function renderConditionResponse(id, ctx = {}) {
|
|
16
|
+
// Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
|
|
17
|
+
condition(id);
|
|
18
|
+
switch (id) {
|
|
19
|
+
case REASON_CONDITION.https: {
|
|
20
|
+
const httpsUrl = new URL(ctx.url);
|
|
21
|
+
httpsUrl.protocol = 'https:';
|
|
22
|
+
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
23
|
+
}
|
|
24
|
+
case REASON_CONDITION.csrf:
|
|
25
|
+
return brandedAdminPage(403, csrfRequiredPage());
|
|
26
|
+
case REASON_CONDITION.origin:
|
|
27
|
+
return new Response('Cross-site POST form submissions are forbidden', {
|
|
28
|
+
status: 403,
|
|
29
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
30
|
+
});
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`no runtime renderer for condition: ${id}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
-
import { httpsRequiredPage } from './https-required-page.js';
|
|
8
7
|
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
9
|
-
import {
|
|
8
|
+
import { applySecurityHeaders } from './admin-response.js';
|
|
9
|
+
import { renderConditionResponse } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
11
|
/** The login page and the auth endpoints are public; everything else under /admin is gated. */
|
|
12
12
|
function isPublicAdminPath(pathname) {
|
|
@@ -28,41 +28,6 @@ function isLocalHost(hostname) {
|
|
|
28
28
|
hostname === '[::1]' ||
|
|
29
29
|
hostname.endsWith('.localhost'));
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
33
|
-
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
34
|
-
*/
|
|
35
|
-
function applySecurityHeaders(headers) {
|
|
36
|
-
headers.set('X-Content-Type-Options', 'nosniff');
|
|
37
|
-
headers.set('X-Frame-Options', 'DENY');
|
|
38
|
-
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
39
|
-
headers.set('Referrer-Policy', 'no-referrer');
|
|
40
|
-
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
41
|
-
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
42
|
-
}
|
|
43
|
-
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
44
|
-
function brandedAdminPage(status, body) {
|
|
45
|
-
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
46
|
-
applySecurityHeaders(headers);
|
|
47
|
-
return new Response(body, { status, headers });
|
|
48
|
-
}
|
|
49
|
-
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
50
|
-
function httpsRequiredResponse(url) {
|
|
51
|
-
const httpsUrl = new URL(url);
|
|
52
|
-
httpsUrl.protocol = 'https:';
|
|
53
|
-
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
54
|
-
}
|
|
55
|
-
/** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
|
|
56
|
-
function csrfForbidden() {
|
|
57
|
-
return new Response('Cross-site POST form submissions are forbidden', {
|
|
58
|
-
status: 403,
|
|
59
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
/** The branded 403 for a failed admin double-submit token check. */
|
|
63
|
-
function csrfRequiredResponse() {
|
|
64
|
-
return brandedAdminPage(403, csrfRequiredPage());
|
|
65
|
-
}
|
|
66
31
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
67
32
|
export function createAuthGuard() {
|
|
68
33
|
return async function handle({ event, resolve }) {
|
|
@@ -72,7 +37,7 @@ export function createAuthGuard() {
|
|
|
72
37
|
if (!isAdminPath(pathname)) {
|
|
73
38
|
if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
|
|
74
39
|
log.warn('guard.rejected', { reason: 'origin', path: pathname });
|
|
75
|
-
return
|
|
40
|
+
return renderConditionResponse('auth.csrf-origin-mismatch');
|
|
76
41
|
}
|
|
77
42
|
return resolve(event);
|
|
78
43
|
}
|
|
@@ -82,13 +47,13 @@ export function createAuthGuard() {
|
|
|
82
47
|
// posts. Local http (wrangler dev) is exempt.
|
|
83
48
|
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
84
49
|
log.warn('guard.rejected', { reason: 'https', path: pathname });
|
|
85
|
-
return
|
|
50
|
+
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
86
51
|
}
|
|
87
52
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
88
53
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
89
54
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
90
55
|
log.warn('guard.rejected', { reason: 'csrf', path: pathname });
|
|
91
|
-
return
|
|
56
|
+
return renderConditionResponse('auth.csrf-token-invalid');
|
|
92
57
|
}
|
|
93
58
|
if (!isPublicAdminPath(pathname)) {
|
|
94
59
|
const env = event.platform?.env ?? {};
|
package/package.json
CHANGED
|
@@ -7,6 +7,8 @@ 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';
|
|
11
|
+
import InfoIcon from '@lucide/svelte/icons/info';
|
|
10
12
|
import CairnLogo from './CairnLogo.svelte';
|
|
11
13
|
import CsrfField from './CsrfField.svelte';
|
|
12
14
|
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
@@ -22,6 +24,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
22
24
|
let { data, form }: Props = $props();
|
|
23
25
|
|
|
24
26
|
let rootEl = $state<HTMLElement>();
|
|
27
|
+
// Lets a mistyped address go back to the form without a reload, even though the server still
|
|
28
|
+
// reports `sent`. The success copy never reveals whether the email was on the allowlist.
|
|
29
|
+
let dismissed = $state(false);
|
|
25
30
|
onMount(() => {
|
|
26
31
|
if (rootEl) warnIfChromeWrapped(rootEl);
|
|
27
32
|
});
|
|
@@ -33,24 +38,54 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
33
38
|
<meta name="robots" content="noindex, nofollow" />
|
|
34
39
|
</svelte:head>
|
|
35
40
|
|
|
41
|
+
<!-- The brand mark renders in both states; the parent container sets its alignment (left for the
|
|
42
|
+
form, centered for the confirmation), so one snippet covers both. -->
|
|
43
|
+
{#snippet brand()}
|
|
44
|
+
<div class="flex items-center gap-2">
|
|
45
|
+
<CairnLogo class="h-8 w-8 text-primary" />
|
|
46
|
+
<span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
|
|
47
|
+
</div>
|
|
48
|
+
{/snippet}
|
|
49
|
+
|
|
36
50
|
<!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
|
|
37
51
|
level in (a class on the theme element itself would not match). -->
|
|
38
52
|
<div data-theme="cairn-admin" bind:this={rootEl}>
|
|
39
53
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
|
|
40
54
|
<div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
{#if form?.sent && !dismissed}
|
|
56
|
+
<!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
|
|
57
|
+
instruction. The fallback help sits in a gentle inset note below. -->
|
|
58
|
+
<div role="status" class="flex flex-col items-center text-center">
|
|
59
|
+
<div class="mb-7">{@render brand()}</div>
|
|
60
|
+
<div
|
|
61
|
+
class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
|
|
62
|
+
style="background-color: color-mix(in oklch, var(--color-success) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-success) 22%, transparent);"
|
|
63
|
+
>
|
|
64
|
+
<MailCheckIcon class="h-6 w-6" />
|
|
65
|
+
</div>
|
|
66
|
+
<h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
|
|
67
|
+
<p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
|
|
68
|
+
We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
|
|
69
|
+
</p>
|
|
70
|
+
<div class="mt-6 flex w-full items-start gap-2.5 rounded-[var(--radius-field)] bg-base-content/[0.04] p-3.5 text-left">
|
|
71
|
+
<InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
|
|
72
|
+
<p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
|
|
73
|
+
No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
|
|
74
|
+
address may not match the one your site owner added.
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
|
|
80
|
+
onclick={() => (dismissed = true)}
|
|
81
|
+
>
|
|
82
|
+
Use a different email
|
|
83
|
+
</button>
|
|
52
84
|
</div>
|
|
53
85
|
{:else}
|
|
86
|
+
<div class="mb-6 flex justify-center">{@render brand()}</div>
|
|
87
|
+
<h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
88
|
+
<p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
|
|
54
89
|
{#if data.error}
|
|
55
90
|
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
|
|
56
91
|
{/if}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The cairn condition registry: one entry per known environment or operational failure mode. It is
|
|
2
|
+
// the shared identity the readiness checklist, the doctor probe, and the runtime renderer all draw
|
|
3
|
+
// from, so the three surfaces agree (the 1:1:1). Internal: exported from no public package subpath,
|
|
4
|
+
// so the shape stays free to grow, the same stance as src/lib/log/. Renaming an id is a breaking
|
|
5
|
+
// change to the observable contract. See
|
|
6
|
+
// docs/superpowers/specs/2026-06-08-cairn-diagnostics-initiative-design.md.
|
|
7
|
+
import type { CairnLogEvent } from '../log/index.js';
|
|
8
|
+
|
|
9
|
+
export type ConditionSeverity = 'blocker' | 'warning';
|
|
10
|
+
|
|
11
|
+
export interface CairnCondition {
|
|
12
|
+
/** Stable, greppable id, e.g. 'edge.https-not-forced'. */
|
|
13
|
+
id: string;
|
|
14
|
+
severity: ConditionSeverity;
|
|
15
|
+
/** Short human label. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** One or two sentences on why the condition bites. */
|
|
18
|
+
why: string;
|
|
19
|
+
/** The fix, often a command. */
|
|
20
|
+
remediation: string;
|
|
21
|
+
/** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
|
|
22
|
+
docsAnchor?: string;
|
|
23
|
+
/** The log vocabulary event this condition correlates with, if any. */
|
|
24
|
+
logEvent?: CairnLogEvent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REGISTRY: Record<string, CairnCondition> = {
|
|
28
|
+
'edge.https-not-forced': {
|
|
29
|
+
id: 'edge.https-not-forced',
|
|
30
|
+
severity: 'blocker',
|
|
31
|
+
title: 'Always Use HTTPS is off',
|
|
32
|
+
why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
|
|
33
|
+
remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
|
|
34
|
+
logEvent: 'guard.rejected',
|
|
35
|
+
},
|
|
36
|
+
'auth.csrf-token-invalid': {
|
|
37
|
+
id: 'auth.csrf-token-invalid',
|
|
38
|
+
severity: 'blocker',
|
|
39
|
+
title: 'Admin CSRF token check failed',
|
|
40
|
+
why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
|
|
41
|
+
remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
|
|
42
|
+
logEvent: 'guard.rejected',
|
|
43
|
+
},
|
|
44
|
+
'auth.csrf-origin-mismatch': {
|
|
45
|
+
id: 'auth.csrf-origin-mismatch',
|
|
46
|
+
severity: 'blocker',
|
|
47
|
+
title: 'Non-admin form Origin rejected',
|
|
48
|
+
why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
|
|
49
|
+
remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
|
|
50
|
+
logEvent: 'guard.rejected',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
55
|
+
export function condition(id: string): CairnCondition {
|
|
56
|
+
const found = REGISTRY[id];
|
|
57
|
+
if (!found) throw new Error(`unknown cairn condition: ${id}`);
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
62
|
+
export function allConditions(): CairnCondition[] {
|
|
63
|
+
return Object.values(REGISTRY);
|
|
64
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// CairnError: a thrown failure that names a known condition. A catch site narrows on it, logs from
|
|
2
|
+
// the condition, and renders the condition's message in place of an opaque string. Its first
|
|
3
|
+
// throw-site is Pass 2 (the email send mapping); Pass 1 lands and tests the primitive.
|
|
4
|
+
import { condition, type CairnCondition } from './conditions.js';
|
|
5
|
+
|
|
6
|
+
export class CairnError extends Error {
|
|
7
|
+
readonly conditionId: string;
|
|
8
|
+
readonly condition: CairnCondition;
|
|
9
|
+
|
|
10
|
+
constructor(conditionId: string, options?: { cause?: unknown; message?: string }) {
|
|
11
|
+
const resolved = condition(conditionId);
|
|
12
|
+
super(
|
|
13
|
+
options?.message ?? resolved.title,
|
|
14
|
+
options?.cause !== undefined ? { cause: options.cause } : undefined
|
|
15
|
+
);
|
|
16
|
+
this.name = 'CairnError';
|
|
17
|
+
this.conditionId = conditionId;
|
|
18
|
+
this.condition = resolved;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Internal barrel for the diagnostics model. Not re-exported from any public package subpath.
|
|
2
|
+
export { condition, allConditions } from './conditions.js';
|
|
3
|
+
export type { CairnCondition, ConditionSeverity } from './conditions.js';
|
|
4
|
+
export { CairnError } from './error.js';
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/credentials.ts
|
|
2
1
|
// cairn-cms: the bridge from the adapter's backend config and the Worker's secret to the
|
|
3
2
|
// App signer's input. One tested place owns the join and the missing-secret failure, so the
|
|
4
3
|
// save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
|
package/src/lib/github/repo.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/repo.ts
|
|
2
1
|
// cairn-cms: repo reads and the commit, over the GitHub REST API. Listing a concept
|
|
3
2
|
// directory uses the Git Trees API (the contents API silently truncates at 1,000 entries,
|
|
4
3
|
// spec §7.3); a single-file read uses the contents API. An optional token lifts reads to
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/signing.ts
|
|
2
1
|
// cairn-cms: the GitHub App auth path. Mint an RS256 App JWT signed in-Worker with Web
|
|
3
2
|
// Crypto, exchange it for a short-lived installation access token, and self-test the
|
|
4
3
|
// brittle key conversion. GitHub issues PKCS#1 private keys and Web Crypto's importKey
|
package/src/lib/github/types.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/lib/github/types.ts
|
|
2
1
|
// cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
|
|
3
2
|
// reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
|
|
4
3
|
// `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
|
|
@@ -54,7 +54,7 @@ export function serializeComponent(def: ComponentDef, values: ComponentValues):
|
|
|
54
54
|
? (Array.isArray(raw) ? raw : []).filter((i) => i !== '').map((i) => `- ${i}`).join('\n')
|
|
55
55
|
: ((raw as string | undefined) ?? '');
|
|
56
56
|
if (!content) continue;
|
|
57
|
-
if (lines.length > 1) lines.push('');
|
|
57
|
+
if (lines.length > 1) lines.push('');
|
|
58
58
|
lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared response helpers for cairn's admin pages: the baseline security headers and a branded
|
|
2
|
+
// full-document response. Extracted from guard.ts so the guard's resolve path and the condition
|
|
3
|
+
// renderer share one definition.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
7
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
8
|
+
*/
|
|
9
|
+
export function applySecurityHeaders(headers: Headers): void {
|
|
10
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
11
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
12
|
+
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
13
|
+
headers.set('Referrer-Policy', 'no-referrer');
|
|
14
|
+
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
15
|
+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
19
|
+
export function brandedAdminPage(status: number, body: string): Response {
|
|
20
|
+
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
21
|
+
applySecurityHeaders(headers);
|
|
22
|
+
return new Response(body, { status, headers });
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The runtime renderer leg of the diagnostics model: map a condition to the Response the guard
|
|
2
|
+
// serves. Re-homes the three rejection responses guard.ts built inline, keyed by condition id, so
|
|
3
|
+
// the guard's reason, the registered condition, and the served page stay in step.
|
|
4
|
+
import { brandedAdminPage } from './admin-response.js';
|
|
5
|
+
import { httpsRequiredPage } from './https-required-page.js';
|
|
6
|
+
import { csrfRequiredPage } from './csrf-required-page.js';
|
|
7
|
+
import { condition } from '../diagnostics/index.js';
|
|
8
|
+
|
|
9
|
+
/** The guard.rejected reasons, each mapped to its registered condition id. */
|
|
10
|
+
export const REASON_CONDITION = {
|
|
11
|
+
https: 'edge.https-not-forced',
|
|
12
|
+
csrf: 'auth.csrf-token-invalid',
|
|
13
|
+
origin: 'auth.csrf-origin-mismatch',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type GuardReason = keyof typeof REASON_CONDITION;
|
|
17
|
+
|
|
18
|
+
/** Render the Response the guard serves for a rejection, by its condition id. */
|
|
19
|
+
export function renderConditionResponse(id: string, ctx: { url?: URL } = {}): Response {
|
|
20
|
+
// Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
|
|
21
|
+
condition(id);
|
|
22
|
+
switch (id) {
|
|
23
|
+
case REASON_CONDITION.https: {
|
|
24
|
+
const httpsUrl = new URL(ctx.url!);
|
|
25
|
+
httpsUrl.protocol = 'https:';
|
|
26
|
+
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
27
|
+
}
|
|
28
|
+
case REASON_CONDITION.csrf:
|
|
29
|
+
return brandedAdminPage(403, csrfRequiredPage());
|
|
30
|
+
case REASON_CONDITION.origin:
|
|
31
|
+
return new Response('Cross-site POST form submissions are forbidden', {
|
|
32
|
+
status: 403,
|
|
33
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
34
|
+
});
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`no runtime renderer for condition: ${id}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
-
import { httpsRequiredPage } from './https-required-page.js';
|
|
8
7
|
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
9
|
-
import {
|
|
8
|
+
import { applySecurityHeaders } from './admin-response.js';
|
|
9
|
+
import { renderConditionResponse } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
11
|
import type { Editor } from '../auth/types.js';
|
|
12
12
|
import type { HandleInput, RequestContext } from './types.js';
|
|
@@ -36,46 +36,6 @@ function isLocalHost(hostname: string): boolean {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
41
|
-
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
42
|
-
*/
|
|
43
|
-
function applySecurityHeaders(headers: Headers): void {
|
|
44
|
-
headers.set('X-Content-Type-Options', 'nosniff');
|
|
45
|
-
headers.set('X-Frame-Options', 'DENY');
|
|
46
|
-
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
47
|
-
headers.set('Referrer-Policy', 'no-referrer');
|
|
48
|
-
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
49
|
-
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
50
|
-
}
|
|
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
|
-
|
|
59
|
-
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
60
|
-
function httpsRequiredResponse(url: URL): Response {
|
|
61
|
-
const httpsUrl = new URL(url);
|
|
62
|
-
httpsUrl.protocol = 'https:';
|
|
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' },
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The branded 403 for a failed admin double-submit token check. */
|
|
75
|
-
function csrfRequiredResponse(): Response {
|
|
76
|
-
return brandedAdminPage(403, csrfRequiredPage());
|
|
77
|
-
}
|
|
78
|
-
|
|
79
39
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
80
40
|
export function createAuthGuard() {
|
|
81
41
|
return async function handle({ event, resolve }: HandleInput): Promise<Response> {
|
|
@@ -86,7 +46,7 @@ export function createAuthGuard() {
|
|
|
86
46
|
if (!isAdminPath(pathname)) {
|
|
87
47
|
if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
|
|
88
48
|
log.warn('guard.rejected', { reason: 'origin', path: pathname });
|
|
89
|
-
return
|
|
49
|
+
return renderConditionResponse('auth.csrf-origin-mismatch');
|
|
90
50
|
}
|
|
91
51
|
return resolve(event);
|
|
92
52
|
}
|
|
@@ -97,14 +57,14 @@ export function createAuthGuard() {
|
|
|
97
57
|
// posts. Local http (wrangler dev) is exempt.
|
|
98
58
|
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
99
59
|
log.warn('guard.rejected', { reason: 'https', path: pathname });
|
|
100
|
-
return
|
|
60
|
+
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
101
61
|
}
|
|
102
62
|
|
|
103
63
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
104
64
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
105
65
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
106
66
|
log.warn('guard.rejected', { reason: 'csrf', path: pathname });
|
|
107
|
-
return
|
|
67
|
+
return renderConditionResponse('auth.csrf-token-invalid');
|
|
108
68
|
}
|
|
109
69
|
|
|
110
70
|
if (!isPublicAdminPath(pathname)) {
|