@glw907/cairn-cms 0.50.0 → 0.51.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 +40 -0
- package/dist/components/EditPage.svelte +94 -16
- package/dist/components/EditPage.svelte.d.ts +4 -1
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- package/dist/components/MarkdownEditor.svelte +20 -2
- package/dist/components/cairn-admin.css +57 -9
- package/dist/components/editor-highlight.d.ts +1 -0
- package/dist/components/editor-highlight.js +31 -8
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/preview-doc.d.ts +27 -0
- package/dist/components/preview-doc.js +64 -0
- package/dist/content/compose.js +1 -0
- package/dist/content/types.d.ts +33 -0
- package/dist/diagnostics/conditions.js +24 -0
- package/dist/doctor/bin.js +30 -12
- package/dist/doctor/check-floors.d.ts +15 -0
- package/dist/doctor/check-floors.js +107 -0
- package/dist/doctor/check-probe.d.ts +3 -0
- package/dist/doctor/check-probe.js +123 -0
- package/dist/doctor/checks-github.js +1 -1
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +28 -2
- package/dist/doctor/cloudflare-api.js +2 -2
- package/dist/doctor/index.d.ts +28 -3
- package/dist/doctor/index.js +47 -6
- package/dist/doctor/types.d.ts +2 -0
- package/dist/doctor/wrangler-config.d.ts +4 -0
- package/dist/doctor/wrangler-config.js +11 -0
- package/dist/env.d.ts +2 -1
- package/dist/env.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +5 -1
- package/dist/sveltekit/content-routes.js +25 -17
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +3 -1
- package/dist/sveltekit/nav-routes.js +3 -9
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +2 -2
- package/src/lib/components/EditPage.svelte +94 -16
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/MarkdownEditor.svelte +20 -2
- package/src/lib/components/cairn-admin.css +59 -0
- package/src/lib/components/editor-highlight.ts +32 -7
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/types.ts +32 -0
- package/src/lib/diagnostics/conditions.ts +24 -0
- package/src/lib/doctor/bin.ts +35 -10
- package/src/lib/doctor/check-floors.ts +124 -0
- package/src/lib/doctor/check-probe.ts +138 -0
- package/src/lib/doctor/checks-github.ts +3 -1
- package/src/lib/doctor/checks-local.ts +28 -2
- package/src/lib/doctor/cloudflare-api.ts +4 -2
- package/src/lib/doctor/index.ts +67 -6
- package/src/lib/doctor/types.ts +2 -0
- package/src/lib/doctor/wrangler-config.ts +11 -0
- package/src/lib/env.ts +9 -4
- package/src/lib/index.ts +2 -0
- package/src/lib/sveltekit/content-routes.ts +29 -17
- package/src/lib/sveltekit/guard.ts +4 -2
- package/src/lib/sveltekit/nav-routes.ts +3 -10
- package/src/lib/vite/index.ts +71 -17
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The doctor's opt-in live probe (--probe): one GET and one POST against a deployed admin,
|
|
2
|
+
// asserting the envelope a working sign-in presents. Zero side effects by construction: the
|
|
3
|
+
// POST submits a random non-editor address, and the engine's non-leak design answers a
|
|
4
|
+
// non-editor with the identical sent body while sending no email and minting no token, so the
|
|
5
|
+
// probe leaves nothing behind on the site. A factory rather than a check constant, the same
|
|
6
|
+
// shape as the live send: the check exists only when the bin receives --probe.
|
|
7
|
+
import { fail, pass, skip } from './types.js';
|
|
8
|
+
import { csrfCookieName } from '../auth/crypto.js';
|
|
9
|
+
import { readWranglerConfig } from './wrangler-config.js';
|
|
10
|
+
const NO_URL = skip('pass --probe <url>, set PUBLIC_ORIGIN in the wrangler vars, or set PUBLIC_ORIGIN in the environment');
|
|
11
|
+
/** Build the live-probe check. A missing url falls back to the PUBLIC_ORIGIN input at run time. */
|
|
12
|
+
export function liveProbeCheck(url) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'admin.login-probe',
|
|
15
|
+
conditionId: 'admin.login-probe-failed',
|
|
16
|
+
title: 'Live admin login probe',
|
|
17
|
+
async run(ctx) {
|
|
18
|
+
// The wrangler vars hold the value the deployed Worker reads, so they beat the local
|
|
19
|
+
// environment, the same precedence the public-origin check applies.
|
|
20
|
+
const base = url ?? (await readWranglerConfig(ctx.readFile))?.publicOrigin ?? ctx.publicOrigin;
|
|
21
|
+
if (base === undefined)
|
|
22
|
+
return NO_URL;
|
|
23
|
+
let origin;
|
|
24
|
+
try {
|
|
25
|
+
origin = new URL(base);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return fail(`probe URL does not parse: ${base}`);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await probe(ctx, origin);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return fail(String(err));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** GET /admin/login and assert the sign-in envelope, then hand the harvested token pair on. */
|
|
40
|
+
async function probe(ctx, origin) {
|
|
41
|
+
const res = await ctx.fetch(String(new URL('/admin/login', origin)));
|
|
42
|
+
if (res.status !== 200) {
|
|
43
|
+
return fail(`GET /admin/login returned ${res.status}, expected 200`);
|
|
44
|
+
}
|
|
45
|
+
const cookieName = csrfCookieName(origin.protocol === 'https:');
|
|
46
|
+
const cookieValue = setCookieValue(res.headers.getSetCookie(), cookieName);
|
|
47
|
+
if (cookieValue === undefined) {
|
|
48
|
+
return fail(`GET /admin/login set no ${cookieName} cookie`);
|
|
49
|
+
}
|
|
50
|
+
const html = await res.text();
|
|
51
|
+
const field = csrfFieldValue(html);
|
|
52
|
+
if (field === undefined) {
|
|
53
|
+
return fail('the login page carries no name="csrf" hidden field with a value');
|
|
54
|
+
}
|
|
55
|
+
if (!/<form[^>]*action="[^"]*\?\/request"/.test(html)) {
|
|
56
|
+
return fail('the login page carries no form posting the ?/request action');
|
|
57
|
+
}
|
|
58
|
+
return postRequestAction(ctx, origin, `${cookieName}=${cookieValue}`, field);
|
|
59
|
+
}
|
|
60
|
+
/** The named cookie's value from the Set-Cookie lines, or undefined when no line names it. */
|
|
61
|
+
function setCookieValue(lines, name) {
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const eq = line.indexOf('=');
|
|
64
|
+
if (eq === -1 || line.slice(0, eq).trim() !== name)
|
|
65
|
+
continue;
|
|
66
|
+
const rest = line.slice(eq + 1);
|
|
67
|
+
const semi = rest.indexOf(';');
|
|
68
|
+
return semi === -1 ? rest : rest.slice(0, semi);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** The csrf hidden field's value, tolerant of attribute order, or undefined when absent or empty. */
|
|
73
|
+
function csrfFieldValue(html) {
|
|
74
|
+
const input = (html.match(/<input[^>]*>/g) ?? []).find((tag) => /name="csrf"/.test(tag));
|
|
75
|
+
if (input === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
return /value="([^"]+)"/.exec(input)?.[1];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* POST the request action and read its serialized result. The address is random and non-editor
|
|
81
|
+
* at the reserved example.invalid domain, so even a delivery bug could send nothing anywhere,
|
|
82
|
+
* and the engine's non-leak design makes the response indistinguishable from a real send.
|
|
83
|
+
*/
|
|
84
|
+
async function postRequestAction(ctx, origin, cookie, csrf) {
|
|
85
|
+
const email = `cairn-doctor-probe-${Math.random().toString(36).slice(2, 10)}@example.invalid`;
|
|
86
|
+
const res = await ctx.fetch(String(new URL('/admin/login?/request', origin)), {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
90
|
+
cookie,
|
|
91
|
+
},
|
|
92
|
+
body: new URLSearchParams({ email, csrf }).toString(),
|
|
93
|
+
});
|
|
94
|
+
if (res.status !== 200) {
|
|
95
|
+
return fail(`POST ?/request returned ${res.status}, expected 200`);
|
|
96
|
+
}
|
|
97
|
+
// A no-Accept action POST answers with SvelteKit's serialized form-action JSON, shaped
|
|
98
|
+
// {"type":"success","status":200,"data":"<devalue array string>"}. The data field is a
|
|
99
|
+
// devalue encoding the probe reads by containment for the status literals, tolerant of
|
|
100
|
+
// encoding details it does not own, instead of pulling in a devalue parser.
|
|
101
|
+
let envelope;
|
|
102
|
+
try {
|
|
103
|
+
envelope = (await res.json());
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return fail('POST ?/request did not answer with the serialized action JSON');
|
|
107
|
+
}
|
|
108
|
+
if (envelope.type !== 'success') {
|
|
109
|
+
return fail(`POST ?/request answered type ${String(envelope.type)}, expected success`);
|
|
110
|
+
}
|
|
111
|
+
const data = typeof envelope.data === 'string' ? envelope.data : '';
|
|
112
|
+
if (data.includes('"send_error"')) {
|
|
113
|
+
return fail('the request action answered send_error; the magic-link send path is failing (see the email checks and the auth.link.send_failed log records)');
|
|
114
|
+
}
|
|
115
|
+
// Every payload carries the "sent" field name, so the distinct status spellings go first.
|
|
116
|
+
if (data.includes('"throttled"')) {
|
|
117
|
+
return pass(`sign-in envelope verified at ${origin.origin}; the request action answered throttled (a real cooldown window is active), which still proves the path`);
|
|
118
|
+
}
|
|
119
|
+
if (data.includes('"sent"')) {
|
|
120
|
+
return pass(`sign-in envelope verified at ${origin.origin}; the request action answered sent for a non-editor probe address`);
|
|
121
|
+
}
|
|
122
|
+
return fail('POST ?/request answered success with an unrecognized payload');
|
|
123
|
+
}
|
|
@@ -17,7 +17,7 @@ export const githubApp = {
|
|
|
17
17
|
return skip('set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 to run this check');
|
|
18
18
|
}
|
|
19
19
|
if (!ctx.repo) {
|
|
20
|
-
return skip('pass --repo
|
|
20
|
+
return skip('pass --repo, set GITHUB_REPO, or configure the cairnManifest plugin so the doctor can read the adapter');
|
|
21
21
|
}
|
|
22
22
|
const creds = appCredentials({ appId: ctx.github.appId, installationId: ctx.github.installationId }, { GITHUB_APP_PRIVATE_KEY_B64: ctx.github.privateKeyB64 });
|
|
23
23
|
// Stage 1: the key parse and sign, through the deploy-time self-test. Its detail is a
|
|
@@ -2,4 +2,5 @@ import type { DoctorCheck } from './types.js';
|
|
|
2
2
|
export declare const configBindings: DoctorCheck;
|
|
3
3
|
export declare const configObservability: DoctorCheck;
|
|
4
4
|
export declare const configCsrfDisable: DoctorCheck;
|
|
5
|
+
export declare const configPublicOrigin: DoctorCheck;
|
|
5
6
|
export declare const configSiteConfig: DoctorCheck;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The doctor's local-config checks: the wrangler bindings, the observability sink, the
|
|
2
|
-
// svelte.config CSRF handoff,
|
|
3
|
-
// injected ctx.readFile, so the tests pass fixtures and the bin passes node:fs.
|
|
2
|
+
// svelte.config CSRF handoff, the site-config validation, and the public origin. Every read
|
|
3
|
+
// goes through the injected ctx.readFile, so the tests pass fixtures and the bin passes node:fs.
|
|
4
4
|
import { fail, pass, skip } from './types.js';
|
|
5
5
|
import { readWranglerConfig } from './wrangler-config.js';
|
|
6
|
+
import { requireOrigin } from '../env.js';
|
|
6
7
|
import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
|
|
7
8
|
import { normalizeConcepts } from '../content/concepts.js';
|
|
8
9
|
import { defineFields } from '../content/schema.js';
|
|
@@ -78,6 +79,31 @@ export const configCsrfDisable = {
|
|
|
78
79
|
return pass('checkOrigin: false found and the hooks file wires the cairn guard (heuristic text read)');
|
|
79
80
|
},
|
|
80
81
|
};
|
|
82
|
+
export const configPublicOrigin = {
|
|
83
|
+
id: 'config.public-origin',
|
|
84
|
+
conditionId: 'config.public-origin-invalid',
|
|
85
|
+
title: 'Public origin',
|
|
86
|
+
async run(ctx) {
|
|
87
|
+
// The wrangler vars hold the value the deployed Worker reads, so they beat the local
|
|
88
|
+
// environment; the env fallback covers a dashboard-set var the file never carries.
|
|
89
|
+
const facts = await readWranglerConfig(ctx.readFile);
|
|
90
|
+
const fromVars = facts?.publicOrigin;
|
|
91
|
+
const origin = fromVars ?? ctx.publicOrigin;
|
|
92
|
+
if (facts === null && origin === undefined) {
|
|
93
|
+
return skip('no wrangler config found and PUBLIC_ORIGIN is not in the environment');
|
|
94
|
+
}
|
|
95
|
+
// requireOrigin is the runtime rule (unset, not a URL, http off localhost); reusing it
|
|
96
|
+
// keeps the doctor and the Worker on one judgment.
|
|
97
|
+
try {
|
|
98
|
+
requireOrigin({ PUBLIC_ORIGIN: origin });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
102
|
+
}
|
|
103
|
+
const source = fromVars !== undefined ? 'wrangler vars' : 'environment';
|
|
104
|
+
return pass(`PUBLIC_ORIGIN is ${origin} (${source})`);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
81
107
|
// Where sites keep site.config.yaml. The adapter's configPath is TypeScript the CLI cannot
|
|
82
108
|
// evaluate, so the check probes the conventional spots instead (the repo root and the two
|
|
83
109
|
// src locations the production sites use).
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { skip } from './types.js';
|
|
6
6
|
export const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
7
7
|
export const NO_TOKEN = skip('set CLOUDFLARE_API_TOKEN to run this check');
|
|
8
|
-
export const NO_FROM = skip('pass --from
|
|
9
|
-
export const NO_ACCOUNT = skip('set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to run this check');
|
|
8
|
+
export const NO_FROM = skip('pass --from, set CAIRN_FROM, or configure the cairnManifest plugin so the doctor can read the adapter');
|
|
9
|
+
export const NO_ACCOUNT = skip('set CLOUDFLARE_API_TOKEN, and CLOUDFLARE_ACCOUNT_ID or a wrangler account_id, to run this check');
|
|
10
10
|
export function cfGet(ctx, path) {
|
|
11
11
|
return ctx.fetch(`${CF_API}${path}`, {
|
|
12
12
|
headers: { authorization: `Bearer ${ctx.cfToken}` },
|
package/dist/doctor/index.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface DoctorArgs {
|
|
|
5
5
|
from?: string;
|
|
6
6
|
repo?: string;
|
|
7
7
|
sendTest?: string;
|
|
8
|
+
/** The live admin probe: a URL when --probe carried one, true for the bare flag (probe the
|
|
9
|
+
* PUBLIC_ORIGIN input), absent when the flag never appeared (the probe does not run). */
|
|
10
|
+
probe?: string | true;
|
|
8
11
|
}
|
|
9
12
|
/** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
|
|
10
13
|
export declare function parseArgs(argv: string[]): DoctorArgs;
|
|
@@ -15,9 +18,31 @@ export declare function parseArgs(argv: string[]): DoctorArgs;
|
|
|
15
18
|
* readFile stay with the bin, which injects the real ones.
|
|
16
19
|
*/
|
|
17
20
|
export declare function contextFromEnv(env: Record<string, string | undefined>, args: DoctorArgs, cwd: string): Omit<DoctorContext, 'fetch' | 'readFile'>;
|
|
21
|
+
/** The lazy derivation sources the bin wires up: the adapter read through the consumer's own
|
|
22
|
+
* Vite resolution and the wrangler config's account_id. Each runs only when an input it feeds
|
|
23
|
+
* is still missing, so a doctor run with full flags touches neither. */
|
|
24
|
+
export interface DerivationSources {
|
|
25
|
+
/** Returns { owner, repo, from } off the adapter, or null when nothing is derivable. */
|
|
26
|
+
adapterFacts: () => Promise<{
|
|
27
|
+
owner?: string;
|
|
28
|
+
repo?: string;
|
|
29
|
+
from?: string;
|
|
30
|
+
} | null>;
|
|
31
|
+
/** Returns the wrangler config's account_id, or undefined when none is declared. */
|
|
32
|
+
wranglerAccountId: () => Promise<string | undefined>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fill the context's missing inputs from the repo the doctor runs in: from and repo off the
|
|
36
|
+
* adapter, the account id off the wrangler config. An explicit flag or env value always wins
|
|
37
|
+
* (contextFromEnv already resolved those into ctx), each source runs lazily and only for
|
|
38
|
+
* inputs still missing, and a derivation failure leaves the input absent so its check skips
|
|
39
|
+
* with the usual remediation line instead of the doctor crashing. The API token is never
|
|
40
|
+
* derived; it stays env-only.
|
|
41
|
+
*/
|
|
42
|
+
export declare function deriveMissingInputs(ctx: Omit<DoctorContext, 'fetch' | 'readFile'>, sources: DerivationSources): Promise<Omit<DoctorContext, 'fetch' | 'readFile'>>;
|
|
18
43
|
/**
|
|
19
|
-
* The default registry: the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
44
|
+
* The default registry: the six local checks, the four Cloudflare checks, and the GitHub App
|
|
45
|
+
* chain. The live send is opt-in (--send-test) and never sits here; the bin appends it. A
|
|
46
|
+
* fresh array per call, so that append mutates nothing shared.
|
|
22
47
|
*/
|
|
23
48
|
export declare function defaultChecks(): DoctorCheck[];
|
package/dist/doctor/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { configBindings, configObservability, configCsrfDisable, configSiteConfig, } from './checks-local.js';
|
|
1
|
+
import { configBindings, configObservability, configCsrfDisable, configSiteConfig, configPublicOrigin, } from './checks-local.js';
|
|
2
|
+
import { configDependencyFloors } from './check-floors.js';
|
|
2
3
|
import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
|
|
3
4
|
import { githubApp } from './checks-github.js';
|
|
4
5
|
export { runDoctor } from './run.js';
|
|
5
6
|
export { formatReport } from './report.js';
|
|
6
|
-
const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>]';
|
|
7
|
+
const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>] [--probe [url]]';
|
|
7
8
|
const FLAGS = {
|
|
8
9
|
'--from': 'from',
|
|
9
10
|
'--repo': 'repo',
|
|
@@ -12,8 +13,16 @@ const FLAGS = {
|
|
|
12
13
|
/** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
|
|
13
14
|
export function parseArgs(argv) {
|
|
14
15
|
const args = {};
|
|
15
|
-
for (let i = 0; i < argv.length;
|
|
16
|
+
for (let i = 0; i < argv.length;) {
|
|
16
17
|
const flag = argv[i];
|
|
18
|
+
// --probe alone is meaningful (probe the PUBLIC_ORIGIN input), so its value is optional.
|
|
19
|
+
if (flag === '--probe') {
|
|
20
|
+
const value = argv[i + 1];
|
|
21
|
+
const bare = value === undefined || value.startsWith('--');
|
|
22
|
+
args.probe = bare ? true : value;
|
|
23
|
+
i += bare ? 1 : 2;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
17
26
|
const key = FLAGS[flag];
|
|
18
27
|
if (!key)
|
|
19
28
|
throw new Error(`unknown argument ${flag}\n${USAGE}`);
|
|
@@ -22,6 +31,7 @@ export function parseArgs(argv) {
|
|
|
22
31
|
throw new Error(`${flag} needs a value\n${USAGE}`);
|
|
23
32
|
}
|
|
24
33
|
args[key] = value;
|
|
34
|
+
i += 2;
|
|
25
35
|
}
|
|
26
36
|
return args;
|
|
27
37
|
}
|
|
@@ -39,6 +49,7 @@ export function contextFromEnv(env, args, cwd) {
|
|
|
39
49
|
repo: args.repo ?? env.GITHUB_REPO,
|
|
40
50
|
cfToken: env.CLOUDFLARE_API_TOKEN,
|
|
41
51
|
cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
|
|
52
|
+
publicOrigin: env.PUBLIC_ORIGIN,
|
|
42
53
|
github: GITHUB_APP_ID && GITHUB_APP_INSTALLATION_ID && GITHUB_APP_PRIVATE_KEY_B64
|
|
43
54
|
? {
|
|
44
55
|
appId: GITHUB_APP_ID,
|
|
@@ -49,9 +60,37 @@ export function contextFromEnv(env, args, cwd) {
|
|
|
49
60
|
};
|
|
50
61
|
}
|
|
51
62
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
63
|
+
* Fill the context's missing inputs from the repo the doctor runs in: from and repo off the
|
|
64
|
+
* adapter, the account id off the wrangler config. An explicit flag or env value always wins
|
|
65
|
+
* (contextFromEnv already resolved those into ctx), each source runs lazily and only for
|
|
66
|
+
* inputs still missing, and a derivation failure leaves the input absent so its check skips
|
|
67
|
+
* with the usual remediation line instead of the doctor crashing. The API token is never
|
|
68
|
+
* derived; it stays env-only.
|
|
69
|
+
*/
|
|
70
|
+
export async function deriveMissingInputs(ctx, sources) {
|
|
71
|
+
const out = { ...ctx };
|
|
72
|
+
if (out.from === undefined || out.repo === undefined) {
|
|
73
|
+
const facts = await sources.adapterFacts().catch(() => null);
|
|
74
|
+
if (out.from === undefined && typeof facts?.from === 'string') {
|
|
75
|
+
out.from = facts.from;
|
|
76
|
+
}
|
|
77
|
+
if (out.repo === undefined &&
|
|
78
|
+
typeof facts?.owner === 'string' &&
|
|
79
|
+
typeof facts?.repo === 'string') {
|
|
80
|
+
out.repo = `${facts.owner}/${facts.repo}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (out.cfAccountId === undefined) {
|
|
84
|
+
const accountId = await sources.wranglerAccountId().catch(() => undefined);
|
|
85
|
+
if (typeof accountId === 'string')
|
|
86
|
+
out.cfAccountId = accountId;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The default registry: the six local checks, the four Cloudflare checks, and the GitHub App
|
|
92
|
+
* chain. The live send is opt-in (--send-test) and never sits here; the bin appends it. A
|
|
93
|
+
* fresh array per call, so that append mutates nothing shared.
|
|
55
94
|
*/
|
|
56
95
|
export function defaultChecks() {
|
|
57
96
|
return [
|
|
@@ -59,6 +98,8 @@ export function defaultChecks() {
|
|
|
59
98
|
configObservability,
|
|
60
99
|
configCsrfDisable,
|
|
61
100
|
configSiteConfig,
|
|
101
|
+
configPublicOrigin,
|
|
102
|
+
configDependencyFloors,
|
|
62
103
|
emailSenderOnboarded,
|
|
63
104
|
edgeHttpsForced,
|
|
64
105
|
edgeHsts,
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface DoctorContext {
|
|
|
28
28
|
cfToken?: string;
|
|
29
29
|
/** CLOUDFLARE_ACCOUNT_ID. */
|
|
30
30
|
cfAccountId?: string;
|
|
31
|
+
/** PUBLIC_ORIGIN, the env fallback when the wrangler vars carry none. */
|
|
32
|
+
publicOrigin?: string;
|
|
31
33
|
/** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
|
|
32
34
|
github?: {
|
|
33
35
|
appId: string;
|
|
@@ -8,5 +8,9 @@ export interface WranglerFacts {
|
|
|
8
8
|
authDbId?: string;
|
|
9
9
|
/** observability.enabled is true. */
|
|
10
10
|
observabilityEnabled: boolean;
|
|
11
|
+
/** vars.PUBLIC_ORIGIN, when declared; the public-origin check validates it. */
|
|
12
|
+
publicOrigin?: string;
|
|
13
|
+
/** The top-level account_id, when declared; a fallback for CLOUDFLARE_ACCOUNT_ID. */
|
|
14
|
+
accountId?: string;
|
|
11
15
|
}
|
|
12
16
|
export declare function readWranglerConfig(readFile: DoctorContext['readFile']): Promise<WranglerFacts | null>;
|
|
@@ -71,6 +71,11 @@ function factsFromJsonc(text) {
|
|
|
71
71
|
};
|
|
72
72
|
if (typeof authDb?.database_id === 'string')
|
|
73
73
|
facts.authDbId = authDb.database_id;
|
|
74
|
+
const vars = config.vars;
|
|
75
|
+
if (typeof vars?.PUBLIC_ORIGIN === 'string')
|
|
76
|
+
facts.publicOrigin = vars.PUBLIC_ORIGIN;
|
|
77
|
+
if (typeof config.account_id === 'string')
|
|
78
|
+
facts.accountId = config.account_id;
|
|
74
79
|
return facts;
|
|
75
80
|
}
|
|
76
81
|
// The toml read is deliberately shallow: line-anchored matching for the three facts, not a
|
|
@@ -119,6 +124,12 @@ function factsFromToml(text) {
|
|
|
119
124
|
else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
|
|
120
125
|
facts.observabilityEnabled = true;
|
|
121
126
|
}
|
|
127
|
+
else if (section === '[vars]' && key === 'PUBLIC_ORIGIN' && str !== undefined) {
|
|
128
|
+
facts.publicOrigin = str;
|
|
129
|
+
}
|
|
130
|
+
else if (section === '' && key === 'account_id' && str !== undefined) {
|
|
131
|
+
facts.accountId = str;
|
|
132
|
+
}
|
|
122
133
|
}
|
|
123
134
|
flushD1();
|
|
124
135
|
return facts;
|
package/dist/env.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { D1Database } from '@cloudflare/workers-types';
|
|
|
5
5
|
* The origin is always config-derived, never read from a request header, so a
|
|
6
6
|
* forged Host header cannot redirect a magic link (spec 7.1, risk H3).
|
|
7
7
|
*
|
|
8
|
-
* @throws
|
|
8
|
+
* @throws CairnError (`config.public-origin-invalid`) when `PUBLIC_ORIGIN` is unset or
|
|
9
|
+
* empty, fails to parse as a URL, or uses http on a non-local host.
|
|
9
10
|
*/
|
|
10
11
|
export declare function requireOrigin(env: {
|
|
11
12
|
PUBLIC_ORIGIN?: string;
|
package/dist/env.js
CHANGED
|
@@ -5,26 +5,31 @@ import { CairnError } from './diagnostics/index.js';
|
|
|
5
5
|
* The origin is always config-derived, never read from a request header, so a
|
|
6
6
|
* forged Host header cannot redirect a magic link (spec 7.1, risk H3).
|
|
7
7
|
*
|
|
8
|
-
* @throws
|
|
8
|
+
* @throws CairnError (`config.public-origin-invalid`) when `PUBLIC_ORIGIN` is unset or
|
|
9
|
+
* empty, fails to parse as a URL, or uses http on a non-local host.
|
|
9
10
|
*/
|
|
10
11
|
export function requireOrigin(env) {
|
|
11
12
|
const origin = env.PUBLIC_ORIGIN;
|
|
12
13
|
if (!origin) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new CairnError('config.public-origin-invalid', { message: 'PUBLIC_ORIGIN is not configured' });
|
|
14
15
|
}
|
|
15
16
|
let hostname;
|
|
16
17
|
try {
|
|
17
18
|
hostname = new URL(origin).hostname;
|
|
18
19
|
}
|
|
19
20
|
catch {
|
|
20
|
-
throw new
|
|
21
|
+
throw new CairnError('config.public-origin-invalid', {
|
|
22
|
+
message: `PUBLIC_ORIGIN is not a valid URL, got ${origin}`,
|
|
23
|
+
});
|
|
21
24
|
}
|
|
22
25
|
// The magic-link origin must be https in production so the link and the __Host- cookie are
|
|
23
26
|
// origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
|
|
24
27
|
// a lookalike host like localhost.example.com cannot skip the https requirement.
|
|
25
28
|
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
|
|
26
29
|
if (!origin.startsWith('https://') && !isLocal) {
|
|
27
|
-
throw new
|
|
30
|
+
throw new CairnError('config.public-origin-invalid', {
|
|
31
|
+
message: `PUBLIC_ORIGIN must be https in production, got ${origin}`,
|
|
32
|
+
});
|
|
28
33
|
}
|
|
29
34
|
return origin;
|
|
30
35
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { requireOrigin } from './env.js';
|
|
|
2
2
|
export type { Role, Editor, AuthEnv } from './auth/types.js';
|
|
3
3
|
export type { AuthBranding, MagicLinkMessage, SendMagicLink } from './email.js';
|
|
4
4
|
export { buildMagicLinkMessage, cloudflareSend } from './email.js';
|
|
5
|
-
export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
|
|
5
|
+
export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, PreviewConfig, ResolvedPreview, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
|
|
6
6
|
export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
|
|
7
7
|
export { composeRuntime } from './content/compose.js';
|
|
8
8
|
export type { ComposeInput } from './content/compose.js';
|
|
@@ -2,7 +2,7 @@ import { fail } from '@sveltejs/kit';
|
|
|
2
2
|
import { type GithubKeyEnv } from '../github/credentials.js';
|
|
3
3
|
import { type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
4
4
|
import type { CookieJar, EventBase } from './types.js';
|
|
5
|
-
import type { CairnRuntime, FrontmatterField } from '../content/types.js';
|
|
5
|
+
import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/types.js';
|
|
6
6
|
import type { Role } from '../auth/types.js';
|
|
7
7
|
/** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
|
|
8
8
|
export interface NavConcept {
|
|
@@ -87,6 +87,10 @@ export interface EditData {
|
|
|
87
87
|
publishedFlash: boolean;
|
|
88
88
|
/** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
|
|
89
89
|
discardedFlash: boolean;
|
|
90
|
+
/** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
|
|
91
|
+
* when one exists, applied over the top-level values); null when the site sets none, which
|
|
92
|
+
* leaves the frame rendering unstyled markup behind a hint. */
|
|
93
|
+
preview: ResolvedPreview | null;
|
|
90
94
|
}
|
|
91
95
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
92
96
|
export interface ContentEvent extends EventBase<GithubKeyEnv> {
|
|
@@ -16,12 +16,19 @@ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest,
|
|
|
16
16
|
import { isConflict } from '../github/types.js';
|
|
17
17
|
import { log } from '../log/index.js';
|
|
18
18
|
import { issueCsrfToken } from './csrf.js';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
import { requireSession } from './guard.js';
|
|
20
|
+
/** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
21
|
+
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
22
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client. */
|
|
23
|
+
function resolvePreview(preview, conceptId) {
|
|
24
|
+
if (!preview)
|
|
25
|
+
return null;
|
|
26
|
+
const override = preview.byConcept?.[conceptId];
|
|
27
|
+
return {
|
|
28
|
+
stylesheets: preview.stylesheets,
|
|
29
|
+
bodyClass: override?.bodyClass ?? preview.bodyClass,
|
|
30
|
+
containerClass: override?.containerClass ?? preview.containerClass,
|
|
31
|
+
};
|
|
25
32
|
}
|
|
26
33
|
/** Look up the concept named by the `[concept]` route param, or a 404. */
|
|
27
34
|
function conceptOf(runtime, params) {
|
|
@@ -53,7 +60,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
53
60
|
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
54
61
|
* and the pending entries behind the topbar's publish-all action. */
|
|
55
62
|
async function layoutLoad(event) {
|
|
56
|
-
const editor =
|
|
63
|
+
const editor = requireSession(event);
|
|
57
64
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
58
65
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
59
66
|
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
@@ -137,7 +144,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
137
144
|
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
138
145
|
* to an inline error, not a thrown 500. */
|
|
139
146
|
async function listLoad(event) {
|
|
140
|
-
|
|
147
|
+
requireSession(event);
|
|
141
148
|
const concept = conceptOf(runtime, event.params);
|
|
142
149
|
const formError = event.url.searchParams.get('error');
|
|
143
150
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
@@ -181,7 +188,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
181
188
|
}
|
|
182
189
|
/** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
|
|
183
190
|
async function createAction(event) {
|
|
184
|
-
|
|
191
|
+
requireSession(event);
|
|
185
192
|
const concept = conceptOf(runtime, event.params);
|
|
186
193
|
const form = await event.request.formData();
|
|
187
194
|
const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
|
|
@@ -228,7 +235,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
228
235
|
}
|
|
229
236
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
230
237
|
async function editLoad(event) {
|
|
231
|
-
|
|
238
|
+
requireSession(event);
|
|
232
239
|
const concept = conceptOf(runtime, event.params);
|
|
233
240
|
const id = event.params.id ?? '';
|
|
234
241
|
if (!isValidId(id))
|
|
@@ -289,6 +296,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
289
296
|
published,
|
|
290
297
|
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
291
298
|
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
299
|
+
preview: resolvePreview(runtime.preview, concept.id),
|
|
292
300
|
};
|
|
293
301
|
}
|
|
294
302
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
@@ -386,7 +394,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
386
394
|
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
387
395
|
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
388
396
|
async function saveAction(event) {
|
|
389
|
-
const editor =
|
|
397
|
+
const editor = requireSession(event);
|
|
390
398
|
const concept = conceptOf(runtime, event.params);
|
|
391
399
|
const id = event.params.id ?? '';
|
|
392
400
|
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
@@ -408,7 +416,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
408
416
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
409
417
|
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
410
418
|
async function publishAction(event) {
|
|
411
|
-
const editor =
|
|
419
|
+
const editor = requireSession(event);
|
|
412
420
|
const concept = conceptOf(runtime, event.params);
|
|
413
421
|
const id = event.params.id ?? '';
|
|
414
422
|
if (!isValidId(id))
|
|
@@ -442,7 +450,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
442
450
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
443
451
|
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
444
452
|
async function publishAllAction(event) {
|
|
445
|
-
const editor =
|
|
453
|
+
const editor = requireSession(event);
|
|
446
454
|
const first = runtime.concepts[0];
|
|
447
455
|
if (!first)
|
|
448
456
|
throw error(404, 'No content types configured');
|
|
@@ -521,7 +529,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
521
529
|
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
522
530
|
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
523
531
|
async function discardAction(event) {
|
|
524
|
-
const editor =
|
|
532
|
+
const editor = requireSession(event);
|
|
525
533
|
const concept = conceptOf(runtime, event.params);
|
|
526
534
|
const id = event.params.id ?? '';
|
|
527
535
|
if (!isValidId(id))
|
|
@@ -588,7 +596,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
588
596
|
}
|
|
589
597
|
/** Delete an entry from its editor. The id comes from the route param. */
|
|
590
598
|
async function deleteAction(event) {
|
|
591
|
-
const editor =
|
|
599
|
+
const editor = requireSession(event);
|
|
592
600
|
const concept = conceptOf(runtime, event.params);
|
|
593
601
|
const id = event.params.id ?? '';
|
|
594
602
|
if (!isValidId(id))
|
|
@@ -597,7 +605,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
597
605
|
}
|
|
598
606
|
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
599
607
|
async function listDeleteAction(event) {
|
|
600
|
-
const editor =
|
|
608
|
+
const editor = requireSession(event);
|
|
601
609
|
const concept = conceptOf(runtime, event.params);
|
|
602
610
|
const form = await event.request.formData();
|
|
603
611
|
const id = String(form.get('id') ?? '');
|
|
@@ -610,7 +618,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
610
618
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
611
619
|
* caught by the build's fail-closed backstop. */
|
|
612
620
|
async function renameAction(event) {
|
|
613
|
-
const editor =
|
|
621
|
+
const editor = requireSession(event);
|
|
614
622
|
const concept = conceptOf(runtime, event.params);
|
|
615
623
|
const id = event.params.id ?? '';
|
|
616
624
|
if (!isValidId(id))
|
|
@@ -2,7 +2,13 @@ import type { Editor } from '../auth/types.js';
|
|
|
2
2
|
import type { HandleInput, RequestContext } from './types.js';
|
|
3
3
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
4
4
|
export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
|
|
5
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
-
|
|
5
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
7
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
8
|
+
export declare function requireSession(event: {
|
|
9
|
+
locals: {
|
|
10
|
+
editor?: Editor | null;
|
|
11
|
+
};
|
|
12
|
+
}): Editor;
|
|
7
13
|
/** For the management surface: a signed-in owner, or 403 for an editor. */
|
|
8
14
|
export declare function requireOwner(event: RequestContext): Editor;
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -80,7 +80,9 @@ export function createAuthGuard() {
|
|
|
80
80
|
return response;
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
83
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
84
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
85
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
84
86
|
export function requireSession(event) {
|
|
85
87
|
const editor = event.locals.editor;
|
|
86
88
|
if (!editor)
|