@glw907/cairn-cms 0.37.0 → 0.38.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 +30 -1
- package/README.md +69 -48
- package/dist/components/LoginPage.svelte +49 -28
- package/dist/components/LoginPage.svelte.d.ts +3 -1
- package/dist/components/cairn-admin.css +39 -14
- package/dist/diagnostics/conditions.d.ts +21 -0
- package/dist/diagnostics/conditions.js +53 -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/email.d.ts +20 -1
- package/dist/email.js +25 -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/auth-routes.d.ts +16 -3
- package/dist/sveltekit/auth-routes.js +47 -28
- 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/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/LoginPage.svelte +49 -28
- package/src/lib/diagnostics/conditions.ts +80 -0
- package/src/lib/diagnostics/error.ts +20 -0
- package/src/lib/diagnostics/index.ts +4 -0
- package/src/lib/email.ts +31 -1
- 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/auth-routes.ts +59 -29
- package/src/lib/sveltekit/condition-response.ts +38 -0
- package/src/lib/sveltekit/guard.ts +5 -45
- package/src/lib/sveltekit/index.ts +1 -1
|
@@ -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
|
+
}
|
|
@@ -4,15 +4,28 @@ export interface AuthRoutesConfig {
|
|
|
4
4
|
branding: AuthBranding;
|
|
5
5
|
send?: SendMagicLink;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
|
|
9
|
+
* own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
|
|
10
|
+
* identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
|
|
11
|
+
*/
|
|
12
|
+
export type RequestResult = {
|
|
13
|
+
status: 'sent';
|
|
14
|
+
sent: true;
|
|
15
|
+
} | {
|
|
16
|
+
status: 'send_error';
|
|
17
|
+
sent: false;
|
|
18
|
+
} | {
|
|
19
|
+
status: 'throttled';
|
|
20
|
+
sent: false;
|
|
21
|
+
};
|
|
7
22
|
export declare function createAuthRoutes(config: AuthRoutesConfig): {
|
|
8
23
|
loginLoad: (event: RequestContext) => {
|
|
9
24
|
siteName: string;
|
|
10
25
|
error: string | null;
|
|
11
26
|
csrf: string;
|
|
12
27
|
};
|
|
13
|
-
requestAction: (event: RequestContext) => Promise<
|
|
14
|
-
sent: true;
|
|
15
|
-
}>;
|
|
28
|
+
requestAction: (event: RequestContext) => Promise<RequestResult>;
|
|
16
29
|
confirmLoad: (event: RequestContext) => {
|
|
17
30
|
token: string;
|
|
18
31
|
siteName: string;
|
|
@@ -5,15 +5,26 @@ import { redirect } from '@sveltejs/kit';
|
|
|
5
5
|
import { requireOrigin, requireDb } from '../env.js';
|
|
6
6
|
import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
|
|
7
7
|
import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
|
|
8
|
-
import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
|
|
8
|
+
import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode } from '../email.js';
|
|
9
9
|
import { issueCsrfToken } from './csrf.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
|
|
13
|
+
* an injection seam, and a custom sender's thrown error may embed the failed message and with it
|
|
14
|
+
* the magic link. Scrub any token query value and cap the length, so the documented "records never
|
|
15
|
+
* carry a token" guarantee holds for the seam too.
|
|
16
|
+
*/
|
|
17
|
+
function scrubSendError(err) {
|
|
18
|
+
return String(err)
|
|
19
|
+
.replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
|
|
20
|
+
.slice(0, 300);
|
|
21
|
+
}
|
|
11
22
|
export function createAuthRoutes(config) {
|
|
12
23
|
const send = config.send ?? cloudflareSend;
|
|
13
24
|
/**
|
|
14
|
-
* POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
|
|
15
|
-
*
|
|
16
|
-
*
|
|
25
|
+
* POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
|
|
26
|
+
* emails the confirmation link, and awaits the send so the status reflects its outcome. The
|
|
27
|
+
* neutral and send-ok responses are identical, so the common case never leaks membership.
|
|
17
28
|
*/
|
|
18
29
|
async function requestAction(event) {
|
|
19
30
|
const env = event.platform?.env ?? {};
|
|
@@ -26,31 +37,39 @@ export function createAuthRoutes(config) {
|
|
|
26
37
|
// fits well under this; only a junk payload is truncated.
|
|
27
38
|
log.info('auth.link.requested', { email: email.slice(0, 320) });
|
|
28
39
|
const editor = email ? await findEditor(db, email) : null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
// Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
|
|
41
|
+
// membership. Response timing still differs (the editor path awaits the send), the side-channel
|
|
42
|
+
// the design accepts as strictly weaker than the explicit throttled signal below.
|
|
43
|
+
if (!editor)
|
|
44
|
+
return { status: 'sent', sent: true };
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
// Per-email cooldown: an editor who requested within the window gets the throttled signal rather
|
|
47
|
+
// than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
|
|
48
|
+
if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
|
|
49
|
+
return { status: 'throttled', sent: false };
|
|
50
|
+
}
|
|
51
|
+
const token = generateToken();
|
|
52
|
+
await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
|
|
53
|
+
log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
|
|
54
|
+
const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
|
|
55
|
+
// The token row is the security-critical write the email depends on, so it is awaited first.
|
|
56
|
+
// The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
|
|
57
|
+
// confirm the link went out before telling an editor to check their inbox. The cost is one
|
|
58
|
+
// email-API round trip on the login POST, the right trade for a login flow.
|
|
59
|
+
try {
|
|
60
|
+
await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// Map the binding failure to its registered condition (carried as a CairnError with the
|
|
64
|
+
// original as cause), and log the greppable code plus the conditionId so the next onboarding
|
|
65
|
+
// gap reads straight to its fix. The editor sees only a generic message, never this detail.
|
|
66
|
+
const failure = emailSendFailure(err);
|
|
67
|
+
log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
|
|
68
|
+
// A plain 200 with a status field, not fail(): the result stays one uniform union for the
|
|
69
|
+
// page, and the failure is already observable through the error-level log record.
|
|
70
|
+
return { status: 'send_error', sent: false };
|
|
52
71
|
}
|
|
53
|
-
return { sent: true };
|
|
72
|
+
return { status: 'sent', sent: true };
|
|
54
73
|
}
|
|
55
74
|
/** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
|
|
56
75
|
function loginLoad(event) {
|
|
@@ -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 ?? {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createAuthGuard, requireSession, requireOwner } from './guard.js';
|
|
2
|
-
export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
|
|
2
|
+
export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
|
|
3
3
|
export { createEditorRoutes } from './editors-routes.js';
|
|
4
4
|
export { createContentRoutes } from './content-routes.js';
|
|
5
5
|
export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, } from './content-routes.js';
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
8
8
|
import './cairn-admin.css';
|
|
9
9
|
import { onMount } from 'svelte';
|
|
10
10
|
import MailCheckIcon from '@lucide/svelte/icons/mail-check';
|
|
11
|
+
import InfoIcon from '@lucide/svelte/icons/info';
|
|
11
12
|
import CairnLogo from './CairnLogo.svelte';
|
|
12
13
|
import CsrfField from './CsrfField.svelte';
|
|
13
14
|
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
@@ -16,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
16
17
|
interface Props {
|
|
17
18
|
/** The login load's data: the site name, an optional error, and the CSRF token. */
|
|
18
19
|
data: { siteName: string; error: string | null; csrf: string };
|
|
19
|
-
/** The action result
|
|
20
|
-
|
|
20
|
+
/** The action result. `sent` is true once a request was accepted; `status` discriminates the
|
|
21
|
+
* neutral, send-error, and throttled outcomes. */
|
|
22
|
+
form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
let { data, form }: Props = $props();
|
|
@@ -37,47 +39,66 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
37
39
|
<meta name="robots" content="noindex, nofollow" />
|
|
38
40
|
</svelte:head>
|
|
39
41
|
|
|
42
|
+
<!-- The brand mark renders in both states; the parent container sets its alignment (left for the
|
|
43
|
+
form, centered for the confirmation), so one snippet covers both. -->
|
|
44
|
+
{#snippet brand()}
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
|
+
<CairnLogo class="h-8 w-8 text-primary" />
|
|
47
|
+
<span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
|
|
48
|
+
</div>
|
|
49
|
+
{/snippet}
|
|
50
|
+
|
|
40
51
|
<!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
|
|
41
52
|
level in (a class on the theme element itself would not match). -->
|
|
42
53
|
<div data-theme="cairn-admin" bind:this={rootEl}>
|
|
43
54
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
|
|
44
55
|
<div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
51
|
-
|
|
52
|
-
{#if form?.sent && !dismissed}
|
|
53
|
-
<div role="status" class="mt-5 flex flex-col items-center text-center">
|
|
56
|
+
{#if (form?.status === 'sent' || form?.sent) && !dismissed}
|
|
57
|
+
<!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
|
|
58
|
+
instruction. The fallback help sits in a gentle inset note below. -->
|
|
59
|
+
<div role="status" class="flex flex-col items-center text-center">
|
|
60
|
+
<div class="mb-7">{@render brand()}</div>
|
|
54
61
|
<div
|
|
55
|
-
class="
|
|
56
|
-
style="background-color: color-mix(in oklch, var(--color-success)
|
|
62
|
+
class="flex h-12 w-12 items-center justify-center rounded-xl text-[var(--color-success)]"
|
|
63
|
+
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);"
|
|
57
64
|
>
|
|
58
65
|
<MailCheckIcon class="h-6 w-6" />
|
|
59
66
|
</div>
|
|
60
|
-
<
|
|
61
|
-
<p class="mt-
|
|
67
|
+
<h1 class="mt-5 text-xl font-semibold tracking-tight">Check your email</h1>
|
|
68
|
+
<p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">
|
|
62
69
|
We sent a sign-in link to your inbox. Open it within 10 minutes to finish signing in.
|
|
63
70
|
</p>
|
|
64
|
-
<div class="mt-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
<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">
|
|
72
|
+
<InfoIcon class="mt-px h-4 w-4 shrink-0 text-[var(--color-muted)]" />
|
|
73
|
+
<p class="text-[0.8125rem] leading-relaxed text-[var(--color-subtle)]">
|
|
74
|
+
No link after a minute or two? Check your spam folder first. If it still hasn't arrived, the
|
|
75
|
+
address may not match the one your site owner added.
|
|
68
76
|
</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
77
|
</div>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="mt-5 cursor-pointer appearance-none border-none bg-transparent p-0 text-sm font-medium text-primary hover:underline"
|
|
81
|
+
onclick={() => (dismissed = true)}
|
|
82
|
+
>
|
|
83
|
+
Use a different email
|
|
84
|
+
</button>
|
|
77
85
|
</div>
|
|
78
86
|
{:else}
|
|
79
|
-
<
|
|
80
|
-
{
|
|
87
|
+
<div class="mb-6 flex justify-center">{@render brand()}</div>
|
|
88
|
+
<h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
89
|
+
<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>
|
|
90
|
+
{#if form?.status === 'send_error'}
|
|
91
|
+
<div role="alert" class="alert alert-warning mb-3 text-sm">
|
|
92
|
+
We're having trouble sending sign-in links right now. Please contact the site owner.
|
|
93
|
+
</div>
|
|
94
|
+
{:else if form?.status === 'throttled'}
|
|
95
|
+
<div role="status" class="alert mb-3 text-sm">
|
|
96
|
+
You requested a link recently. Check your inbox, or wait a minute and try again.
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
<!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
|
|
100
|
+
send failure never shows the stale expired-link alert alongside the new state. -->
|
|
101
|
+
{#if data.error && !form?.status}
|
|
81
102
|
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
|
|
82
103
|
{/if}
|
|
83
104
|
<form method="POST" class="flex flex-col gap-3">
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
'email.sender-not-onboarded': {
|
|
53
|
+
id: 'email.sender-not-onboarded',
|
|
54
|
+
severity: 'blocker',
|
|
55
|
+
title: 'Email sending domain is not onboarded',
|
|
56
|
+
why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
|
|
57
|
+
remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
|
|
58
|
+
logEvent: 'auth.link.send_failed',
|
|
59
|
+
},
|
|
60
|
+
'email.send-failed': {
|
|
61
|
+
id: 'email.send-failed',
|
|
62
|
+
severity: 'blocker',
|
|
63
|
+
title: 'Magic-link email send failed',
|
|
64
|
+
why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
|
|
65
|
+
remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
|
|
66
|
+
logEvent: 'auth.link.send_failed',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
|
|
71
|
+
export function condition(id: string): CairnCondition {
|
|
72
|
+
const found = REGISTRY[id];
|
|
73
|
+
if (!found) throw new Error(`unknown cairn condition: ${id}`);
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every registered condition, for the checklist generator and coverage tests. */
|
|
78
|
+
export function allConditions(): CairnCondition[] {
|
|
79
|
+
return Object.values(REGISTRY);
|
|
80
|
+
}
|
|
@@ -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';
|
package/src/lib/email.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// send_email binding; production passes `cloudflareSend`, which calls env.EMAIL.send
|
|
3
3
|
// (Cloudflare Email Sending, arbitrary recipients).
|
|
4
4
|
import type { AuthEnv } from './auth/types.js';
|
|
5
|
+
import { CairnError } from './diagnostics/index.js';
|
|
5
6
|
|
|
6
7
|
export type { AuthEnv };
|
|
7
8
|
|
|
@@ -21,7 +22,9 @@ export interface AuthBranding {
|
|
|
21
22
|
replyTo?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** The injected send. Production uses `cloudflareSend`; tests pass a sink.
|
|
25
|
+
/** The injected send. Production uses `cloudflareSend`; tests pass a sink. A thrown error's
|
|
26
|
+
* text reaches the structured log (scrubbed and truncated), so a custom sender must not embed
|
|
27
|
+
* the message body or the magic link in what it throws. */
|
|
25
28
|
export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
|
|
26
29
|
|
|
27
30
|
/** Escape the five HTML-significant characters. */
|
|
@@ -54,3 +57,30 @@ export const cloudflareSend: SendMagicLink = async (env, message) => {
|
|
|
54
57
|
if (!env.EMAIL) throw new Error('EMAIL binding is not configured');
|
|
55
58
|
await env.EMAIL.send(message);
|
|
56
59
|
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
|
|
63
|
+
* E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
|
|
64
|
+
* shape, but it is unproven against the live binding, so a code embedded in the message is read as
|
|
65
|
+
* a fallback. A custom injected sender that throws a plain Error has neither, so this returns
|
|
66
|
+
* undefined and the record still logs cleanly.
|
|
67
|
+
*/
|
|
68
|
+
export function errorCode(err: unknown): string | undefined {
|
|
69
|
+
if (typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string') {
|
|
70
|
+
return err.code;
|
|
71
|
+
}
|
|
72
|
+
return String(err).match(/\bE_[A-Z][A-Z_]*\b/)?.[0];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Map a magic-link send failure to its registered diagnostic condition, carrying the original error
|
|
77
|
+
* as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
|
|
78
|
+
* also been observed throwing the bare "not a verified address" string with no code, so that
|
|
79
|
+
* message maps to the same condition. Everything else is the generic send failure. The caller logs
|
|
80
|
+
* the conditionId and code, and returns a send_error status.
|
|
81
|
+
*/
|
|
82
|
+
export function emailSendFailure(err: unknown): CairnError {
|
|
83
|
+
const onboarding =
|
|
84
|
+
errorCode(err) === 'E_SENDER_NOT_VERIFIED' || String(err).includes('not a verified address');
|
|
85
|
+
return new CairnError(onboarding ? 'email.sender-not-onboarded' : 'email.send-failed', { cause: err });
|
|
86
|
+
}
|
|
@@ -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
|
|