@glw907/cairn-cms 0.40.0 → 0.50.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 +76 -0
- package/README.md +3 -3
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +18 -10
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +47 -19
- package/dist/components/EditPage.svelte.d.ts +4 -9
- package/dist/components/EditorToolbar.svelte +4 -0
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +2 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -0
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +1 -1
- package/dist/env.js +3 -2
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +34 -14
- package/dist/sveltekit/content-routes.js +78 -44
- package/dist/sveltekit/guard.js +15 -3
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +19 -10
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/package.json +10 -4
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +18 -10
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +47 -19
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +2 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -0
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +3 -2
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/github/types.ts +5 -0
- package/src/lib/log/events.ts +2 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +121 -55
- package/src/lib/sveltekit/guard.ts +16 -3
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +21 -11
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The doctor's GitHub App check: the full reachability chain (the key parses and signs, the
|
|
2
|
+
// installation token mints, the repository answers a read), built from the engine's own
|
|
3
|
+
// credential and signing path so the doctor proves the exact code the save action runs. The
|
|
4
|
+
// signing chain is Web Crypto plus atob/btoa, all Node 20+ globals, so it runs in a CLI
|
|
5
|
+
// unchanged. One wrinkle the tests mirror: installationToken fetches through the global
|
|
6
|
+
// fetch, so only the repo read routes through ctx.fetch.
|
|
7
|
+
import { appCredentials } from '../github/credentials.js';
|
|
8
|
+
import { installationToken, signingSelfTest } from '../github/signing.js';
|
|
9
|
+
import { fail, pass, skip } from './types.js';
|
|
10
|
+
import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
|
|
11
|
+
|
|
12
|
+
const API = 'https://api.github.com';
|
|
13
|
+
|
|
14
|
+
export const githubApp: DoctorCheck = {
|
|
15
|
+
id: 'github.app',
|
|
16
|
+
conditionId: 'github.app-unreachable',
|
|
17
|
+
title: 'GitHub App',
|
|
18
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
19
|
+
if (!ctx.github) {
|
|
20
|
+
return skip(
|
|
21
|
+
'set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 to run this check'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (!ctx.repo) {
|
|
25
|
+
return skip('pass --repo or set GITHUB_REPO to run this check');
|
|
26
|
+
}
|
|
27
|
+
const creds = appCredentials(
|
|
28
|
+
{ appId: ctx.github.appId, installationId: ctx.github.installationId },
|
|
29
|
+
{ GITHUB_APP_PRIVATE_KEY_B64: ctx.github.privateKeyB64 }
|
|
30
|
+
);
|
|
31
|
+
// Stage 1: the key parse and sign, through the deploy-time self-test. Its detail is a
|
|
32
|
+
// fixed classifier, so a bad key can never echo key bytes into the report.
|
|
33
|
+
const signed = await signingSelfTest(creds.appId, creds.privateKeyB64);
|
|
34
|
+
if (!signed.ok) {
|
|
35
|
+
return fail(`the App key failed to parse or sign: ${signed.detail}`);
|
|
36
|
+
}
|
|
37
|
+
// Stage 2: the token mint, through the uncached primitive. A one-shot CLI gains nothing
|
|
38
|
+
// from the Worker-lifecycle cache, and the probe should reach GitHub for real.
|
|
39
|
+
let token: string;
|
|
40
|
+
try {
|
|
41
|
+
token = await installationToken(creds);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return fail(`App authentication failed: ${String(err)}`);
|
|
44
|
+
}
|
|
45
|
+
// Stage 3: the repo read, with the engine's standard GitHub headers.
|
|
46
|
+
try {
|
|
47
|
+
const res = await ctx.fetch(`${API}/repos/${ctx.repo}`, {
|
|
48
|
+
headers: {
|
|
49
|
+
Accept: 'application/vnd.github+json',
|
|
50
|
+
Authorization: `Bearer ${token}`,
|
|
51
|
+
'User-Agent': 'cairn-cms',
|
|
52
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return fail(`repo ${ctx.repo} returned ${res.status}`);
|
|
57
|
+
}
|
|
58
|
+
return pass(`the App reads ${ctx.repo}`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return fail(String(err));
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The doctor's local-config checks: the wrangler bindings, the observability sink, the
|
|
2
|
+
// svelte.config CSRF handoff, and the site-config validation. Every read goes through the
|
|
3
|
+
// injected ctx.readFile, so the tests pass fixtures and the bin passes node:fs.
|
|
4
|
+
import { fail, pass, skip } from './types.js';
|
|
5
|
+
import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
|
|
6
|
+
import { readWranglerConfig } from './wrangler-config.js';
|
|
7
|
+
import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
|
|
8
|
+
import { normalizeConcepts } from '../content/concepts.js';
|
|
9
|
+
import { defineFields } from '../content/schema.js';
|
|
10
|
+
import type { ConceptConfig } from '../content/types.js';
|
|
11
|
+
|
|
12
|
+
const NO_WRANGLER: CheckResult = skip('no wrangler.jsonc or wrangler.toml found');
|
|
13
|
+
|
|
14
|
+
export const configBindings: DoctorCheck = {
|
|
15
|
+
id: 'config.bindings',
|
|
16
|
+
conditionId: 'config.bindings-missing',
|
|
17
|
+
title: 'Wrangler bindings',
|
|
18
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
19
|
+
const facts = await readWranglerConfig(ctx.readFile);
|
|
20
|
+
if (facts === null) return NO_WRANGLER;
|
|
21
|
+
const missing: string[] = [];
|
|
22
|
+
if (!facts.hasEmailBinding) missing.push('EMAIL (send_email)');
|
|
23
|
+
if (!facts.hasAuthDb) missing.push('AUTH_DB (d1_databases)');
|
|
24
|
+
if (missing.length) return fail(`missing ${missing.join(' and ')}`);
|
|
25
|
+
return pass('EMAIL and AUTH_DB are declared');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const configObservability: DoctorCheck = {
|
|
30
|
+
id: 'config.observability',
|
|
31
|
+
conditionId: 'config.observability-off',
|
|
32
|
+
title: 'Workers Logs sink',
|
|
33
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
34
|
+
const facts = await readWranglerConfig(ctx.readFile);
|
|
35
|
+
if (facts === null) return NO_WRANGLER;
|
|
36
|
+
if (!facts.observabilityEnabled) {
|
|
37
|
+
return fail('observability.enabled is not true');
|
|
38
|
+
}
|
|
39
|
+
return pass('observability.enabled is true');
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// A line whose trimmed start is a comment marker cannot disable anything, so a commented-out
|
|
44
|
+
// checkOrigin: false never green-lights the handoff.
|
|
45
|
+
function hasUncommentedDisable(text: string): boolean {
|
|
46
|
+
return text.split('\n').some((line) => {
|
|
47
|
+
const trimmed = line.trimStart();
|
|
48
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return /checkOrigin\s*:\s*false/.test(trimmed);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The guard-wiring heuristic. The tutorial's hooks file imports createAuthGuard from
|
|
56
|
+
// @glw907/cairn-cms/sveltekit and hands it the exported handle, which the first clause matches
|
|
57
|
+
// directly; a site that wraps the guard in its own module still mentions cairn beside a handle.
|
|
58
|
+
function wiresCairnGuard(text: string): boolean {
|
|
59
|
+
if (text.includes('@glw907/cairn-cms')) return true;
|
|
60
|
+
return /cairn/i.test(text) && /handle/.test(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const configCsrfDisable: DoctorCheck = {
|
|
64
|
+
id: 'config.csrf-disable',
|
|
65
|
+
conditionId: 'config.csrf-disable-missing',
|
|
66
|
+
title: 'Framework CSRF handoff',
|
|
67
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
68
|
+
const text = await ctx.readFile('svelte.config.js');
|
|
69
|
+
if (text === null) return skip('svelte.config.js not found');
|
|
70
|
+
if (!hasUncommentedDisable(text)) {
|
|
71
|
+
return fail('no checkOrigin: false found (heuristic text read)');
|
|
72
|
+
}
|
|
73
|
+
// The disable alone proves nothing: with the framework check off and no cairn guard in
|
|
74
|
+
// the hooks, the admin form POSTs have no CSRF protection at all. The pair is the check.
|
|
75
|
+
const hooks =
|
|
76
|
+
(await ctx.readFile('src/hooks.server.ts')) ?? (await ctx.readFile('src/hooks.server.js'));
|
|
77
|
+
if (hooks === null || !wiresCairnGuard(hooks)) {
|
|
78
|
+
return fail(
|
|
79
|
+
'checkOrigin is off but no cairn guard found in src/hooks.server.ts; the site may have no CSRF protection'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return pass(
|
|
83
|
+
'checkOrigin: false found and the hooks file wires the cairn guard (heuristic text read)'
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Where sites keep site.config.yaml. The adapter's configPath is TypeScript the CLI cannot
|
|
89
|
+
// evaluate, so the check probes the conventional spots instead (the repo root and the two
|
|
90
|
+
// src locations the production sites use).
|
|
91
|
+
const SITE_CONFIG_PATHS = ['site.config.yaml', 'src/lib/site.config.yaml', 'src/site.config.yaml'];
|
|
92
|
+
|
|
93
|
+
export const configSiteConfig: DoctorCheck = {
|
|
94
|
+
id: 'config.site-config',
|
|
95
|
+
conditionId: 'config.site-config-invalid',
|
|
96
|
+
title: 'Site config',
|
|
97
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
98
|
+
let text: string | null = null;
|
|
99
|
+
for (const path of SITE_CONFIG_PATHS) {
|
|
100
|
+
text = await ctx.readFile(path);
|
|
101
|
+
if (text !== null) break;
|
|
102
|
+
}
|
|
103
|
+
if (text === null) return skip(`no site.config.yaml found (looked in ${SITE_CONFIG_PATHS.join(', ')})`);
|
|
104
|
+
try {
|
|
105
|
+
const policy = urlPolicyFrom(parseSiteConfig(text));
|
|
106
|
+
// Run the engine's own URL-policy validation by declaring a synthetic empty concept
|
|
107
|
+
// per policy key. Routing is concept-fixed in the engine (CONCEPT_ROUTING, never the
|
|
108
|
+
// adapter), so the dated rules apply faithfully here. What a CLI cannot check without
|
|
109
|
+
// evaluating the adapter is whether each policy key names a concept the site declares.
|
|
110
|
+
const synthetic = Object.fromEntries(
|
|
111
|
+
Object.keys(policy).map((id): [string, ConceptConfig] => [id, { dir: '', schema: defineFields([]) }])
|
|
112
|
+
);
|
|
113
|
+
normalizeConcepts(synthetic, policy);
|
|
114
|
+
return pass('parsed and URL policy validated (the adapter concept set is not checkable from the CLI)');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared plumbing for the doctor's Cloudflare API probes: the API base, the bearer-token
|
|
2
|
+
// request helpers, and the skip results for the credentials the checks share. Every request
|
|
3
|
+
// goes through ctx.fetch with the operator's CLOUDFLARE_API_TOKEN, so the tests script the
|
|
4
|
+
// API and the bin passes global fetch.
|
|
5
|
+
import { skip } from './types.js';
|
|
6
|
+
import type { CheckResult, DoctorContext } from './types.js';
|
|
7
|
+
|
|
8
|
+
export const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
9
|
+
|
|
10
|
+
export const NO_TOKEN: CheckResult = skip('set CLOUDFLARE_API_TOKEN to run this check');
|
|
11
|
+
|
|
12
|
+
export const NO_FROM: CheckResult = skip('pass --from or set CAIRN_FROM to run this check');
|
|
13
|
+
|
|
14
|
+
export const NO_ACCOUNT: CheckResult = skip(
|
|
15
|
+
'set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to run this check'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export function cfGet(ctx: DoctorContext, path: string): Promise<Response> {
|
|
19
|
+
return ctx.fetch(`${CF_API}${path}`, {
|
|
20
|
+
headers: { authorization: `Bearer ${ctx.cfToken}` },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cfPost(ctx: DoctorContext, path: string, body: unknown): Promise<Response> {
|
|
25
|
+
return ctx.fetch(`${CF_API}${path}`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${ctx.cfToken}`,
|
|
29
|
+
'content-type': 'application/json',
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// cairn-doctor's assembly: the flag parser, the context builder, and the default check
|
|
2
|
+
// registry, all pure functions so the bin shell stays a thin wrapper like cairn-manifest's.
|
|
3
|
+
// The module is internal; no public subpath exports it, and the bin is its only consumer.
|
|
4
|
+
import type { DoctorCheck, DoctorContext } from './types.js';
|
|
5
|
+
import {
|
|
6
|
+
configBindings,
|
|
7
|
+
configObservability,
|
|
8
|
+
configCsrfDisable,
|
|
9
|
+
configSiteConfig,
|
|
10
|
+
} from './checks-local.js';
|
|
11
|
+
import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
|
|
12
|
+
import { githubApp } from './checks-github.js';
|
|
13
|
+
|
|
14
|
+
export { runDoctor } from './run.js';
|
|
15
|
+
export { formatReport } from './report.js';
|
|
16
|
+
|
|
17
|
+
const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>]';
|
|
18
|
+
|
|
19
|
+
export interface DoctorArgs {
|
|
20
|
+
from?: string;
|
|
21
|
+
repo?: string;
|
|
22
|
+
sendTest?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FLAGS: Record<string, keyof DoctorArgs> = {
|
|
26
|
+
'--from': 'from',
|
|
27
|
+
'--repo': 'repo',
|
|
28
|
+
'--send-test': 'sendTest',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
|
|
32
|
+
export function parseArgs(argv: string[]): DoctorArgs {
|
|
33
|
+
const args: DoctorArgs = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
35
|
+
const flag = argv[i];
|
|
36
|
+
const key = FLAGS[flag];
|
|
37
|
+
if (!key) throw new Error(`unknown argument ${flag}\n${USAGE}`);
|
|
38
|
+
const value = argv[i + 1];
|
|
39
|
+
if (value === undefined || value.startsWith('--')) {
|
|
40
|
+
throw new Error(`${flag} needs a value\n${USAGE}`);
|
|
41
|
+
}
|
|
42
|
+
args[key] = value;
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the doctor's context from the environment and the parsed flags. A flag beats its env
|
|
49
|
+
* variable, and github assembles only when the whole credential trio is present, so the GitHub
|
|
50
|
+
* check skips with one remediation line instead of failing on a partial setup. fetch and
|
|
51
|
+
* readFile stay with the bin, which injects the real ones.
|
|
52
|
+
*/
|
|
53
|
+
export function contextFromEnv(
|
|
54
|
+
env: Record<string, string | undefined>,
|
|
55
|
+
args: DoctorArgs,
|
|
56
|
+
cwd: string
|
|
57
|
+
): Omit<DoctorContext, 'fetch' | 'readFile'> {
|
|
58
|
+
const { GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY_B64 } = env;
|
|
59
|
+
return {
|
|
60
|
+
cwd,
|
|
61
|
+
from: args.from ?? env.CAIRN_FROM,
|
|
62
|
+
repo: args.repo ?? env.GITHUB_REPO,
|
|
63
|
+
cfToken: env.CLOUDFLARE_API_TOKEN,
|
|
64
|
+
cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
|
|
65
|
+
github:
|
|
66
|
+
GITHUB_APP_ID && GITHUB_APP_INSTALLATION_ID && GITHUB_APP_PRIVATE_KEY_B64
|
|
67
|
+
? {
|
|
68
|
+
appId: GITHUB_APP_ID,
|
|
69
|
+
installationId: GITHUB_APP_INSTALLATION_ID,
|
|
70
|
+
privateKeyB64: GITHUB_APP_PRIVATE_KEY_B64,
|
|
71
|
+
}
|
|
72
|
+
: undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The default registry: the four local-config checks, the four Cloudflare checks, and the
|
|
78
|
+
* GitHub App chain. The live send is opt-in (--send-test) and never sits here; the bin appends
|
|
79
|
+
* it. A fresh array per call, so that append mutates nothing shared.
|
|
80
|
+
*/
|
|
81
|
+
export function defaultChecks(): DoctorCheck[] {
|
|
82
|
+
return [
|
|
83
|
+
configBindings,
|
|
84
|
+
configObservability,
|
|
85
|
+
configCsrfDisable,
|
|
86
|
+
configSiteConfig,
|
|
87
|
+
emailSenderOnboarded,
|
|
88
|
+
edgeHttpsForced,
|
|
89
|
+
edgeHsts,
|
|
90
|
+
authStore,
|
|
91
|
+
githubApp,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The doctor's report: one aligned line per check, then a why/remediation block per failure
|
|
2
|
+
// resolved from the condition registry, then a count summary. Plain text, no ANSI color, so the
|
|
3
|
+
// output reads the same in a terminal and a CI log. An unknown conditionId is a programming
|
|
4
|
+
// error; condition() throws and the report does not paper over it.
|
|
5
|
+
import { condition } from '../diagnostics/index.js';
|
|
6
|
+
import type { CheckResult, DoctorCheck } from './types.js';
|
|
7
|
+
|
|
8
|
+
const TAG: Record<CheckResult['status'], string> = {
|
|
9
|
+
pass: 'PASS',
|
|
10
|
+
fail: 'FAIL',
|
|
11
|
+
skip: 'SKIP',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function formatReport(results: { check: DoctorCheck; result: CheckResult }[]): string {
|
|
15
|
+
const lines = results.map(
|
|
16
|
+
({ check, result }) => `${TAG[result.status]} ${check.title}: ${result.detail}`
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const failures = results.filter(({ result }) => result.status === 'fail');
|
|
20
|
+
for (const { check } of failures) {
|
|
21
|
+
const entry = condition(check.conditionId);
|
|
22
|
+
lines.push('', `${check.title} failed.`, ` Why: ${entry.why}`, ` Fix: ${entry.remediation}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const count = (status: CheckResult['status']) =>
|
|
26
|
+
results.filter(({ result }) => result.status === status).length;
|
|
27
|
+
lines.push('', `${count('pass')} passed, ${count('fail')} failed, ${count('skip')} skipped`);
|
|
28
|
+
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The doctor's runner: every check executes, every result lands in the table. A throwing check
|
|
2
|
+
// records a fail and the run continues, so one broken probe never hides the rest of the picture.
|
|
3
|
+
import { fail } from './types.js';
|
|
4
|
+
import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
|
|
5
|
+
|
|
6
|
+
export async function runDoctor(
|
|
7
|
+
checks: DoctorCheck[],
|
|
8
|
+
ctx: DoctorContext
|
|
9
|
+
): Promise<{ results: { check: DoctorCheck; result: CheckResult }[]; failed: number }> {
|
|
10
|
+
const results: { check: DoctorCheck; result: CheckResult }[] = [];
|
|
11
|
+
let failed = 0;
|
|
12
|
+
for (const check of checks) {
|
|
13
|
+
let result: CheckResult;
|
|
14
|
+
try {
|
|
15
|
+
result = await check.run(ctx);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
result = fail(String(err));
|
|
18
|
+
}
|
|
19
|
+
if (result.status === 'fail') failed += 1;
|
|
20
|
+
results.push({ check, result });
|
|
21
|
+
}
|
|
22
|
+
return { results, failed };
|
|
23
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The doctor's check model (diagnostics spec, Arm B). Each check is isolated: no check reads
|
|
2
|
+
// another's result. The conditionId ties the check to the registry entry whose why/remediation
|
|
3
|
+
// the report prints, keeping the doctor, the runtime errors, and the checklist on one identity.
|
|
4
|
+
export type CheckStatus = 'pass' | 'fail' | 'skip';
|
|
5
|
+
|
|
6
|
+
export interface CheckResult {
|
|
7
|
+
status: CheckStatus;
|
|
8
|
+
/** One line of evidence ("sending subdomain enabled", "wrangler.jsonc not found"). */
|
|
9
|
+
detail: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Result constructors, so a check body reads one outcome per line instead of object literals. */
|
|
13
|
+
export function pass(detail: string): CheckResult {
|
|
14
|
+
return { status: 'pass', detail };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fail(detail: string): CheckResult {
|
|
18
|
+
return { status: 'fail', detail };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function skip(detail: string): CheckResult {
|
|
22
|
+
return { status: 'skip', detail };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DoctorCheck {
|
|
26
|
+
/** Stable id, e.g. 'email.sender-onboarded'. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** The registry condition this check probes; the report prints its remediation on failure. */
|
|
29
|
+
conditionId: string;
|
|
30
|
+
title: string;
|
|
31
|
+
run: (ctx: DoctorContext) => Promise<CheckResult>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Everything a check may read, resolved once by the bin. Absent fields make checks skip. */
|
|
35
|
+
export interface DoctorContext {
|
|
36
|
+
/** The site directory the doctor runs in. */
|
|
37
|
+
cwd: string;
|
|
38
|
+
/** The from-address (--from / CAIRN_FROM). */
|
|
39
|
+
from?: string;
|
|
40
|
+
/** owner/name (--repo / GITHUB_REPO). */
|
|
41
|
+
repo?: string;
|
|
42
|
+
/** CLOUDFLARE_API_TOKEN. */
|
|
43
|
+
cfToken?: string;
|
|
44
|
+
/** CLOUDFLARE_ACCOUNT_ID. */
|
|
45
|
+
cfAccountId?: string;
|
|
46
|
+
/** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
|
|
47
|
+
github?: { appId: string; installationId: string; privateKeyB64: string };
|
|
48
|
+
/** Injected fetch for tests; defaults to global fetch. */
|
|
49
|
+
fetch: typeof fetch;
|
|
50
|
+
/** Read a file under cwd, or null when absent. Injected for tests. */
|
|
51
|
+
readFile: (relPath: string) => Promise<string | null>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// A tolerant reader for the few wrangler-config facts the local checks need. It reads
|
|
2
|
+
// wrangler.jsonc or wrangler.toml through the injected readFile (jsonc wins when both exist)
|
|
3
|
+
// and returns null when neither file is present, which the checks report as a skip.
|
|
4
|
+
import type { DoctorContext } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface WranglerFacts {
|
|
7
|
+
/** A send_email binding named EMAIL is declared. */
|
|
8
|
+
hasEmailBinding: boolean;
|
|
9
|
+
/** A d1_databases binding named AUTH_DB is declared. */
|
|
10
|
+
hasAuthDb: boolean;
|
|
11
|
+
/** The AUTH_DB database_id, when declared; the D1 check queries it. */
|
|
12
|
+
authDbId?: string;
|
|
13
|
+
/** observability.enabled is true. */
|
|
14
|
+
observabilityEnabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readWranglerConfig(
|
|
18
|
+
readFile: DoctorContext['readFile']
|
|
19
|
+
): Promise<WranglerFacts | null> {
|
|
20
|
+
const jsonc = await readFile('wrangler.jsonc');
|
|
21
|
+
if (jsonc !== null) return factsFromJsonc(jsonc);
|
|
22
|
+
const toml = await readFile('wrangler.toml');
|
|
23
|
+
if (toml !== null) return factsFromToml(toml);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Strip // and /* */ comments outside string literals, character by character, so a URL
|
|
28
|
+
// inside a string survives. Trailing commas go by regex afterward; a string containing
|
|
29
|
+
// ",}" would be mangled, an accepted gap in a tolerant reader.
|
|
30
|
+
function stripJsonc(text: string): string {
|
|
31
|
+
let out = '';
|
|
32
|
+
let inString = false;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < text.length) {
|
|
35
|
+
const ch = text[i];
|
|
36
|
+
if (inString) {
|
|
37
|
+
out += ch;
|
|
38
|
+
if (ch === '\\') {
|
|
39
|
+
out += text[i + 1] ?? '';
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (ch === '"') inString = false;
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"') {
|
|
48
|
+
inString = true;
|
|
49
|
+
out += ch;
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
54
|
+
const end = text.indexOf('\n', i);
|
|
55
|
+
i = end === -1 ? text.length : end;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
59
|
+
const end = text.indexOf('*/', i + 2);
|
|
60
|
+
i = end === -1 ? text.length : end + 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out += ch;
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function factsFromJsonc(text: string): WranglerFacts {
|
|
70
|
+
let config: Record<string, unknown>;
|
|
71
|
+
try {
|
|
72
|
+
config = JSON.parse(stripJsonc(text)) as Record<string, unknown>;
|
|
73
|
+
} catch {
|
|
74
|
+
// V8's SyntaxError embeds a source snippet, which would land verbatim in the report;
|
|
75
|
+
// a file that exists but does not parse is a fail with a clean message instead.
|
|
76
|
+
throw new Error('wrangler.jsonc did not parse');
|
|
77
|
+
}
|
|
78
|
+
const sendEmail = Array.isArray(config.send_email) ? config.send_email : [];
|
|
79
|
+
const hasEmailBinding = sendEmail.some(
|
|
80
|
+
(entry) => typeof entry === 'object' && entry !== null && (entry as { name?: unknown }).name === 'EMAIL'
|
|
81
|
+
);
|
|
82
|
+
const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
|
|
83
|
+
const authDb = databases.find(
|
|
84
|
+
(entry): entry is { binding: string; database_id?: unknown } =>
|
|
85
|
+
typeof entry === 'object' && entry !== null && (entry as { binding?: unknown }).binding === 'AUTH_DB'
|
|
86
|
+
);
|
|
87
|
+
const observability = config.observability as { enabled?: unknown } | undefined;
|
|
88
|
+
const facts: WranglerFacts = {
|
|
89
|
+
hasEmailBinding,
|
|
90
|
+
hasAuthDb: authDb !== undefined,
|
|
91
|
+
observabilityEnabled: observability?.enabled === true,
|
|
92
|
+
};
|
|
93
|
+
if (typeof authDb?.database_id === 'string') facts.authDbId = authDb.database_id;
|
|
94
|
+
return facts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The toml read is deliberately shallow: line-anchored matching for the three facts, not a
|
|
98
|
+
// TOML parser. The remediation tells the operator exactly what to add, so full fidelity
|
|
99
|
+
// buys nothing here. A table header opens a section; the relevant key lines are matched
|
|
100
|
+
// within it and the d1 table flushes on the next header.
|
|
101
|
+
function factsFromToml(text: string): WranglerFacts {
|
|
102
|
+
const facts: WranglerFacts = {
|
|
103
|
+
hasEmailBinding: false,
|
|
104
|
+
hasAuthDb: false,
|
|
105
|
+
observabilityEnabled: false,
|
|
106
|
+
};
|
|
107
|
+
let section = '';
|
|
108
|
+
let d1Binding: string | undefined;
|
|
109
|
+
let d1Id: string | undefined;
|
|
110
|
+
|
|
111
|
+
const flushD1 = () => {
|
|
112
|
+
if (d1Binding === 'AUTH_DB') {
|
|
113
|
+
facts.hasAuthDb = true;
|
|
114
|
+
if (d1Id !== undefined) facts.authDbId = d1Id;
|
|
115
|
+
}
|
|
116
|
+
d1Binding = undefined;
|
|
117
|
+
d1Id = undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const line of text.split('\n')) {
|
|
121
|
+
const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
|
|
122
|
+
if (header) {
|
|
123
|
+
flushD1();
|
|
124
|
+
section = header[1];
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const kv = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
|
|
128
|
+
if (!kv) continue;
|
|
129
|
+
const [, key, value] = kv;
|
|
130
|
+
const str = value.match(/^["'](.*)["']/)?.[1];
|
|
131
|
+
if (section === '[[send_email]]' && key === 'name' && str === 'EMAIL') {
|
|
132
|
+
facts.hasEmailBinding = true;
|
|
133
|
+
} else if (section === '[[d1_databases]]') {
|
|
134
|
+
if (key === 'binding') d1Binding = str;
|
|
135
|
+
if (key === 'database_id') d1Id = str;
|
|
136
|
+
} else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
|
|
137
|
+
facts.observabilityEnabled = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
flushD1();
|
|
141
|
+
return facts;
|
|
142
|
+
}
|
package/src/lib/email.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (Cloudflare Email Sending, arbitrary recipients).
|
|
4
4
|
import type { AuthEnv } from './auth/types.js';
|
|
5
5
|
import { CairnError } from './diagnostics/index.js';
|
|
6
|
+
import { escapeHtml } from './escape.js';
|
|
6
7
|
|
|
7
8
|
export type { AuthEnv };
|
|
8
9
|
|
|
@@ -27,16 +28,6 @@ export interface AuthBranding {
|
|
|
27
28
|
* the message body or the magic link in what it throws. */
|
|
28
29
|
export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
|
|
29
30
|
|
|
30
|
-
/** Escape the five HTML-significant characters. */
|
|
31
|
-
function escapeHtml(value: string): string {
|
|
32
|
-
return value
|
|
33
|
-
.replaceAll('&', '&')
|
|
34
|
-
.replaceAll('<', '<')
|
|
35
|
-
.replaceAll('>', '>')
|
|
36
|
-
.replaceAll('"', '"')
|
|
37
|
-
.replaceAll("'", ''');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
31
|
/** Build the confirmation email. The link is the only action; the copy stays plain. */
|
|
41
32
|
export function buildMagicLinkMessage(input: {
|
|
42
33
|
to: string;
|
|
@@ -54,7 +45,9 @@ export function buildMagicLinkMessage(input: {
|
|
|
54
45
|
|
|
55
46
|
/** The production send: Cloudflare Email Sending through the EMAIL binding. */
|
|
56
47
|
export const cloudflareSend: SendMagicLink = async (env, message) => {
|
|
57
|
-
if (!env.EMAIL)
|
|
48
|
+
if (!env.EMAIL) {
|
|
49
|
+
throw new CairnError('config.bindings-missing', { message: 'EMAIL binding is not configured' });
|
|
50
|
+
}
|
|
58
51
|
await env.EMAIL.send(message);
|
|
59
52
|
};
|
|
60
53
|
|
package/src/lib/env.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
import { CairnError } from './diagnostics/index.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Returns the site's public origin from configuration.
|
|
@@ -35,11 +36,11 @@ export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
|
|
|
35
36
|
* The handlers read D1 off `event.platform.env`; without this a misconfigured binding
|
|
36
37
|
* surfaces as a raw `TypeError` deep in a store call. This gives the failure a name.
|
|
37
38
|
*
|
|
38
|
-
* @throws
|
|
39
|
+
* @throws CairnError (`config.bindings-missing`) when `AUTH_DB` is missing.
|
|
39
40
|
*/
|
|
40
41
|
export function requireDb(env: { AUTH_DB?: D1Database }): D1Database {
|
|
41
42
|
if (!env.AUTH_DB) {
|
|
42
|
-
throw new
|
|
43
|
+
throw new CairnError('config.bindings-missing', { message: 'AUTH_DB binding is not configured' });
|
|
43
44
|
}
|
|
44
45
|
return env.AUTH_DB;
|
|
45
46
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// cairn-cms: the one HTML text escape. A leaf module with no imports, so the email builder and
|
|
2
|
+
// the edge-served admin pages share it without either arm reaching into the other.
|
|
3
|
+
|
|
4
|
+
/** Escape the five HTML-significant characters for text and quoted attribute values. */
|
|
5
|
+
export function escapeHtml(value: string): string {
|
|
6
|
+
return value
|
|
7
|
+
.replaceAll('&', '&')
|
|
8
|
+
.replaceAll('<', '<')
|
|
9
|
+
.replaceAll('>', '>')
|
|
10
|
+
.replaceAll('"', '"')
|
|
11
|
+
.replaceAll("'", ''');
|
|
12
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// App signer's input. One tested place owns the join and the missing-secret failure, so the
|
|
3
3
|
// save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
|
|
4
4
|
// TypeError. Mirrors requireDb/requireOrigin in env.ts.
|
|
5
|
+
import { CairnError } from '../diagnostics/index.js';
|
|
5
6
|
import type { BackendConfig } from '../content/types.js';
|
|
6
7
|
import type { AppCredentials } from './types.js';
|
|
7
8
|
|
|
@@ -12,7 +13,8 @@ export interface GithubKeyEnv {
|
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
|
|
15
|
-
* installation) and the Worker's private-key secret. Throws
|
|
16
|
+
* installation) and the Worker's private-key secret. Throws a CairnError naming
|
|
17
|
+
* `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
|
|
16
18
|
*/
|
|
17
19
|
export function appCredentials(
|
|
18
20
|
backend: Pick<BackendConfig, 'appId' | 'installationId'>,
|
|
@@ -20,7 +22,9 @@ export function appCredentials(
|
|
|
20
22
|
): AppCredentials {
|
|
21
23
|
const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
|
|
22
24
|
if (!privateKeyB64) {
|
|
23
|
-
throw new
|
|
25
|
+
throw new CairnError('github.app-unreachable', {
|
|
26
|
+
message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
|
|
27
|
+
});
|
|
24
28
|
}
|
|
25
29
|
return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
|
|
26
30
|
}
|