@glw907/cairn-cms 0.40.0 → 0.41.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/components/ConceptList.svelte +14 -5
  4. package/dist/components/EditPage.svelte +34 -10
  5. package/dist/components/EditorToolbar.svelte +4 -0
  6. package/dist/components/link-completion.js +10 -3
  7. package/dist/diagnostics/conditions.d.ts +8 -1
  8. package/dist/diagnostics/conditions.js +68 -1
  9. package/dist/doctor/bin.d.ts +2 -0
  10. package/dist/doctor/bin.js +44 -0
  11. package/dist/doctor/check-send.d.ts +3 -0
  12. package/dist/doctor/check-send.js +43 -0
  13. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  14. package/dist/doctor/checks-cloudflare.js +200 -0
  15. package/dist/doctor/checks-github.d.ts +2 -0
  16. package/dist/doctor/checks-github.js +57 -0
  17. package/dist/doctor/checks-local.d.ts +5 -0
  18. package/dist/doctor/checks-local.js +112 -0
  19. package/dist/doctor/cloudflare-api.d.ts +7 -0
  20. package/dist/doctor/cloudflare-api.js +24 -0
  21. package/dist/doctor/index.d.ts +23 -0
  22. package/dist/doctor/index.js +68 -0
  23. package/dist/doctor/report.d.ts +5 -0
  24. package/dist/doctor/report.js +21 -0
  25. package/dist/doctor/run.d.ts +8 -0
  26. package/dist/doctor/run.js +20 -0
  27. package/dist/doctor/types.d.ts +41 -0
  28. package/dist/doctor/types.js +10 -0
  29. package/dist/doctor/wrangler-config.d.ts +12 -0
  30. package/dist/doctor/wrangler-config.js +125 -0
  31. package/dist/github/signing.d.ts +3 -1
  32. package/dist/github/signing.js +13 -5
  33. package/dist/log/events.d.ts +1 -1
  34. package/dist/sveltekit/content-routes.js +19 -11
  35. package/package.json +6 -4
  36. package/src/lib/components/ConceptList.svelte +14 -5
  37. package/src/lib/components/EditPage.svelte +34 -10
  38. package/src/lib/components/EditorToolbar.svelte +4 -0
  39. package/src/lib/components/link-completion.ts +10 -3
  40. package/src/lib/diagnostics/conditions.ts +75 -2
  41. package/src/lib/doctor/bin.ts +45 -0
  42. package/src/lib/doctor/check-send.ts +43 -0
  43. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  44. package/src/lib/doctor/checks-github.ts +63 -0
  45. package/src/lib/doctor/checks-local.ts +119 -0
  46. package/src/lib/doctor/cloudflare-api.ts +33 -0
  47. package/src/lib/doctor/index.ts +93 -0
  48. package/src/lib/doctor/report.ts +30 -0
  49. package/src/lib/doctor/run.ts +23 -0
  50. package/src/lib/doctor/types.ts +52 -0
  51. package/src/lib/doctor/wrangler-config.ts +142 -0
  52. package/src/lib/github/signing.ts +13 -6
  53. package/src/lib/log/events.ts +1 -0
  54. package/src/lib/sveltekit/content-routes.ts +19 -10
@@ -0,0 +1,57 @@
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
+ const API = 'https://api.github.com';
11
+ export const githubApp = {
12
+ id: 'github.app',
13
+ conditionId: 'github.app-unreachable',
14
+ title: 'GitHub App',
15
+ async run(ctx) {
16
+ if (!ctx.github) {
17
+ return skip('set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 to run this check');
18
+ }
19
+ if (!ctx.repo) {
20
+ return skip('pass --repo or set GITHUB_REPO to run this check');
21
+ }
22
+ const creds = appCredentials({ appId: ctx.github.appId, installationId: ctx.github.installationId }, { GITHUB_APP_PRIVATE_KEY_B64: ctx.github.privateKeyB64 });
23
+ // Stage 1: the key parse and sign, through the deploy-time self-test. Its detail is a
24
+ // fixed classifier, so a bad key can never echo key bytes into the report.
25
+ const signed = await signingSelfTest(creds.appId, creds.privateKeyB64);
26
+ if (!signed.ok) {
27
+ return fail(`the App key failed to parse or sign: ${signed.detail}`);
28
+ }
29
+ // Stage 2: the token mint, through the uncached primitive. A one-shot CLI gains nothing
30
+ // from the Worker-lifecycle cache, and the probe should reach GitHub for real.
31
+ let token;
32
+ try {
33
+ token = await installationToken(creds);
34
+ }
35
+ catch (err) {
36
+ return fail(`App authentication failed: ${String(err)}`);
37
+ }
38
+ // Stage 3: the repo read, with the engine's standard GitHub headers.
39
+ try {
40
+ const res = await ctx.fetch(`${API}/repos/${ctx.repo}`, {
41
+ headers: {
42
+ Accept: 'application/vnd.github+json',
43
+ Authorization: `Bearer ${token}`,
44
+ 'User-Agent': 'cairn-cms',
45
+ 'X-GitHub-Api-Version': '2022-11-28',
46
+ },
47
+ });
48
+ if (!res.ok) {
49
+ return fail(`repo ${ctx.repo} returned ${res.status}`);
50
+ }
51
+ return pass(`the App reads ${ctx.repo}`);
52
+ }
53
+ catch (err) {
54
+ return fail(String(err));
55
+ }
56
+ },
57
+ };
@@ -0,0 +1,5 @@
1
+ import type { DoctorCheck } from './types.js';
2
+ export declare const configBindings: DoctorCheck;
3
+ export declare const configObservability: DoctorCheck;
4
+ export declare const configCsrfDisable: DoctorCheck;
5
+ export declare const configSiteConfig: DoctorCheck;
@@ -0,0 +1,112 @@
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 { readWranglerConfig } from './wrangler-config.js';
6
+ import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
7
+ import { normalizeConcepts } from '../content/concepts.js';
8
+ import { defineFields } from '../content/schema.js';
9
+ const NO_WRANGLER = skip('no wrangler.jsonc or wrangler.toml found');
10
+ export const configBindings = {
11
+ id: 'config.bindings',
12
+ conditionId: 'config.bindings-missing',
13
+ title: 'Wrangler bindings',
14
+ async run(ctx) {
15
+ const facts = await readWranglerConfig(ctx.readFile);
16
+ if (facts === null)
17
+ return NO_WRANGLER;
18
+ const missing = [];
19
+ if (!facts.hasEmailBinding)
20
+ missing.push('EMAIL (send_email)');
21
+ if (!facts.hasAuthDb)
22
+ missing.push('AUTH_DB (d1_databases)');
23
+ if (missing.length)
24
+ return fail(`missing ${missing.join(' and ')}`);
25
+ return pass('EMAIL and AUTH_DB are declared');
26
+ },
27
+ };
28
+ export const configObservability = {
29
+ id: 'config.observability',
30
+ conditionId: 'config.observability-off',
31
+ title: 'Workers Logs sink',
32
+ async run(ctx) {
33
+ const facts = await readWranglerConfig(ctx.readFile);
34
+ if (facts === null)
35
+ 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
+ // A line whose trimmed start is a comment marker cannot disable anything, so a commented-out
43
+ // checkOrigin: false never green-lights the handoff.
44
+ function hasUncommentedDisable(text) {
45
+ return text.split('\n').some((line) => {
46
+ const trimmed = line.trimStart();
47
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
48
+ return false;
49
+ }
50
+ return /checkOrigin\s*:\s*false/.test(trimmed);
51
+ });
52
+ }
53
+ // The guard-wiring heuristic. The tutorial's hooks file imports createAuthGuard from
54
+ // @glw907/cairn-cms/sveltekit and hands it the exported handle, which the first clause matches
55
+ // directly; a site that wraps the guard in its own module still mentions cairn beside a handle.
56
+ function wiresCairnGuard(text) {
57
+ if (text.includes('@glw907/cairn-cms'))
58
+ return true;
59
+ return /cairn/i.test(text) && /handle/.test(text);
60
+ }
61
+ export const configCsrfDisable = {
62
+ id: 'config.csrf-disable',
63
+ conditionId: 'config.csrf-disable-missing',
64
+ title: 'Framework CSRF handoff',
65
+ async run(ctx) {
66
+ const text = await ctx.readFile('svelte.config.js');
67
+ if (text === null)
68
+ return skip('svelte.config.js not found');
69
+ if (!hasUncommentedDisable(text)) {
70
+ return fail('no checkOrigin: false found (heuristic text read)');
71
+ }
72
+ // The disable alone proves nothing: with the framework check off and no cairn guard in
73
+ // the hooks, the admin form POSTs have no CSRF protection at all. The pair is the check.
74
+ const hooks = (await ctx.readFile('src/hooks.server.ts')) ?? (await ctx.readFile('src/hooks.server.js'));
75
+ if (hooks === null || !wiresCairnGuard(hooks)) {
76
+ return fail('checkOrigin is off but no cairn guard found in src/hooks.server.ts; the site may have no CSRF protection');
77
+ }
78
+ return pass('checkOrigin: false found and the hooks file wires the cairn guard (heuristic text read)');
79
+ },
80
+ };
81
+ // Where sites keep site.config.yaml. The adapter's configPath is TypeScript the CLI cannot
82
+ // evaluate, so the check probes the conventional spots instead (the repo root and the two
83
+ // src locations the production sites use).
84
+ const SITE_CONFIG_PATHS = ['site.config.yaml', 'src/lib/site.config.yaml', 'src/site.config.yaml'];
85
+ export const configSiteConfig = {
86
+ id: 'config.site-config',
87
+ conditionId: 'config.site-config-invalid',
88
+ title: 'Site config',
89
+ async run(ctx) {
90
+ let text = null;
91
+ for (const path of SITE_CONFIG_PATHS) {
92
+ text = await ctx.readFile(path);
93
+ if (text !== null)
94
+ break;
95
+ }
96
+ if (text === null)
97
+ return skip(`no site.config.yaml found (looked in ${SITE_CONFIG_PATHS.join(', ')})`);
98
+ try {
99
+ const policy = urlPolicyFrom(parseSiteConfig(text));
100
+ // Run the engine's own URL-policy validation by declaring a synthetic empty concept
101
+ // per policy key. Routing is concept-fixed in the engine (CONCEPT_ROUTING, never the
102
+ // adapter), so the dated rules apply faithfully here. What a CLI cannot check without
103
+ // evaluating the adapter is whether each policy key names a concept the site declares.
104
+ const synthetic = Object.fromEntries(Object.keys(policy).map((id) => [id, { dir: '', schema: defineFields([]) }]));
105
+ normalizeConcepts(synthetic, policy);
106
+ return pass('parsed and URL policy validated (the adapter concept set is not checkable from the CLI)');
107
+ }
108
+ catch (err) {
109
+ return fail(err instanceof Error ? err.message : String(err));
110
+ }
111
+ },
112
+ };
@@ -0,0 +1,7 @@
1
+ import type { CheckResult, DoctorContext } from './types.js';
2
+ export declare const CF_API = "https://api.cloudflare.com/client/v4";
3
+ export declare const NO_TOKEN: CheckResult;
4
+ export declare const NO_FROM: CheckResult;
5
+ export declare const NO_ACCOUNT: CheckResult;
6
+ export declare function cfGet(ctx: DoctorContext, path: string): Promise<Response>;
7
+ export declare function cfPost(ctx: DoctorContext, path: string, body: unknown): Promise<Response>;
@@ -0,0 +1,24 @@
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
+ export const CF_API = 'https://api.cloudflare.com/client/v4';
7
+ export const NO_TOKEN = skip('set CLOUDFLARE_API_TOKEN to run this check');
8
+ export const NO_FROM = skip('pass --from or set CAIRN_FROM to run this check');
9
+ export const NO_ACCOUNT = skip('set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to run this check');
10
+ export function cfGet(ctx, path) {
11
+ return ctx.fetch(`${CF_API}${path}`, {
12
+ headers: { authorization: `Bearer ${ctx.cfToken}` },
13
+ });
14
+ }
15
+ export function cfPost(ctx, path, body) {
16
+ return ctx.fetch(`${CF_API}${path}`, {
17
+ method: 'POST',
18
+ headers: {
19
+ authorization: `Bearer ${ctx.cfToken}`,
20
+ 'content-type': 'application/json',
21
+ },
22
+ body: JSON.stringify(body),
23
+ });
24
+ }
@@ -0,0 +1,23 @@
1
+ import type { DoctorCheck, DoctorContext } from './types.js';
2
+ export { runDoctor } from './run.js';
3
+ export { formatReport } from './report.js';
4
+ export interface DoctorArgs {
5
+ from?: string;
6
+ repo?: string;
7
+ sendTest?: string;
8
+ }
9
+ /** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
10
+ export declare function parseArgs(argv: string[]): DoctorArgs;
11
+ /**
12
+ * Build the doctor's context from the environment and the parsed flags. A flag beats its env
13
+ * variable, and github assembles only when the whole credential trio is present, so the GitHub
14
+ * check skips with one remediation line instead of failing on a partial setup. fetch and
15
+ * readFile stay with the bin, which injects the real ones.
16
+ */
17
+ export declare function contextFromEnv(env: Record<string, string | undefined>, args: DoctorArgs, cwd: string): Omit<DoctorContext, 'fetch' | 'readFile'>;
18
+ /**
19
+ * The default registry: the four local-config checks, the four Cloudflare checks, and the
20
+ * GitHub App chain. The live send is opt-in (--send-test) and never sits here; the bin appends
21
+ * it. A fresh array per call, so that append mutates nothing shared.
22
+ */
23
+ export declare function defaultChecks(): DoctorCheck[];
@@ -0,0 +1,68 @@
1
+ import { configBindings, configObservability, configCsrfDisable, configSiteConfig, } from './checks-local.js';
2
+ import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
3
+ import { githubApp } from './checks-github.js';
4
+ export { runDoctor } from './run.js';
5
+ export { formatReport } from './report.js';
6
+ const USAGE = 'Usage: cairn-doctor [--from <address>] [--repo <owner/name>] [--send-test <address>]';
7
+ const FLAGS = {
8
+ '--from': 'from',
9
+ '--repo': 'repo',
10
+ '--send-test': 'sendTest',
11
+ };
12
+ /** Parse the bin's argv (long flags only). Throws with a usage line on anything unexpected. */
13
+ export function parseArgs(argv) {
14
+ const args = {};
15
+ for (let i = 0; i < argv.length; i += 2) {
16
+ const flag = argv[i];
17
+ const key = FLAGS[flag];
18
+ if (!key)
19
+ throw new Error(`unknown argument ${flag}\n${USAGE}`);
20
+ const value = argv[i + 1];
21
+ if (value === undefined || value.startsWith('--')) {
22
+ throw new Error(`${flag} needs a value\n${USAGE}`);
23
+ }
24
+ args[key] = value;
25
+ }
26
+ return args;
27
+ }
28
+ /**
29
+ * Build the doctor's context from the environment and the parsed flags. A flag beats its env
30
+ * variable, and github assembles only when the whole credential trio is present, so the GitHub
31
+ * check skips with one remediation line instead of failing on a partial setup. fetch and
32
+ * readFile stay with the bin, which injects the real ones.
33
+ */
34
+ export function contextFromEnv(env, args, cwd) {
35
+ const { GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY_B64 } = env;
36
+ return {
37
+ cwd,
38
+ from: args.from ?? env.CAIRN_FROM,
39
+ repo: args.repo ?? env.GITHUB_REPO,
40
+ cfToken: env.CLOUDFLARE_API_TOKEN,
41
+ cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
42
+ github: GITHUB_APP_ID && GITHUB_APP_INSTALLATION_ID && GITHUB_APP_PRIVATE_KEY_B64
43
+ ? {
44
+ appId: GITHUB_APP_ID,
45
+ installationId: GITHUB_APP_INSTALLATION_ID,
46
+ privateKeyB64: GITHUB_APP_PRIVATE_KEY_B64,
47
+ }
48
+ : undefined,
49
+ };
50
+ }
51
+ /**
52
+ * The default registry: the four local-config checks, the four Cloudflare checks, and the
53
+ * GitHub App chain. The live send is opt-in (--send-test) and never sits here; the bin appends
54
+ * it. A fresh array per call, so that append mutates nothing shared.
55
+ */
56
+ export function defaultChecks() {
57
+ return [
58
+ configBindings,
59
+ configObservability,
60
+ configCsrfDisable,
61
+ configSiteConfig,
62
+ emailSenderOnboarded,
63
+ edgeHttpsForced,
64
+ edgeHsts,
65
+ authStore,
66
+ githubApp,
67
+ ];
68
+ }
@@ -0,0 +1,5 @@
1
+ import type { CheckResult, DoctorCheck } from './types.js';
2
+ export declare function formatReport(results: {
3
+ check: DoctorCheck;
4
+ result: CheckResult;
5
+ }[]): string;
@@ -0,0 +1,21 @@
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
+ const TAG = {
7
+ pass: 'PASS',
8
+ fail: 'FAIL',
9
+ skip: 'SKIP',
10
+ };
11
+ export function formatReport(results) {
12
+ const lines = results.map(({ check, result }) => `${TAG[result.status]} ${check.title}: ${result.detail}`);
13
+ const failures = results.filter(({ result }) => result.status === 'fail');
14
+ for (const { check } of failures) {
15
+ const entry = condition(check.conditionId);
16
+ lines.push('', `${check.title} failed.`, ` Why: ${entry.why}`, ` Fix: ${entry.remediation}`);
17
+ }
18
+ const count = (status) => results.filter(({ result }) => result.status === status).length;
19
+ lines.push('', `${count('pass')} passed, ${count('fail')} failed, ${count('skip')} skipped`);
20
+ return lines.join('\n');
21
+ }
@@ -0,0 +1,8 @@
1
+ import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
2
+ export declare function runDoctor(checks: DoctorCheck[], ctx: DoctorContext): Promise<{
3
+ results: {
4
+ check: DoctorCheck;
5
+ result: CheckResult;
6
+ }[];
7
+ failed: number;
8
+ }>;
@@ -0,0 +1,20 @@
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
+ export async function runDoctor(checks, ctx) {
5
+ const results = [];
6
+ let failed = 0;
7
+ for (const check of checks) {
8
+ let result;
9
+ try {
10
+ result = await check.run(ctx);
11
+ }
12
+ catch (err) {
13
+ result = fail(String(err));
14
+ }
15
+ if (result.status === 'fail')
16
+ failed += 1;
17
+ results.push({ check, result });
18
+ }
19
+ return { results, failed };
20
+ }
@@ -0,0 +1,41 @@
1
+ export type CheckStatus = 'pass' | 'fail' | 'skip';
2
+ export interface CheckResult {
3
+ status: CheckStatus;
4
+ /** One line of evidence ("sending subdomain enabled", "wrangler.jsonc not found"). */
5
+ detail: string;
6
+ }
7
+ /** Result constructors, so a check body reads one outcome per line instead of object literals. */
8
+ export declare function pass(detail: string): CheckResult;
9
+ export declare function fail(detail: string): CheckResult;
10
+ export declare function skip(detail: string): CheckResult;
11
+ export interface DoctorCheck {
12
+ /** Stable id, e.g. 'email.sender-onboarded'. */
13
+ id: string;
14
+ /** The registry condition this check probes; the report prints its remediation on failure. */
15
+ conditionId: string;
16
+ title: string;
17
+ run: (ctx: DoctorContext) => Promise<CheckResult>;
18
+ }
19
+ /** Everything a check may read, resolved once by the bin. Absent fields make checks skip. */
20
+ export interface DoctorContext {
21
+ /** The site directory the doctor runs in. */
22
+ cwd: string;
23
+ /** The from-address (--from / CAIRN_FROM). */
24
+ from?: string;
25
+ /** owner/name (--repo / GITHUB_REPO). */
26
+ repo?: string;
27
+ /** CLOUDFLARE_API_TOKEN. */
28
+ cfToken?: string;
29
+ /** CLOUDFLARE_ACCOUNT_ID. */
30
+ cfAccountId?: string;
31
+ /** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
32
+ github?: {
33
+ appId: string;
34
+ installationId: string;
35
+ privateKeyB64: string;
36
+ };
37
+ /** Injected fetch for tests; defaults to global fetch. */
38
+ fetch: typeof fetch;
39
+ /** Read a file under cwd, or null when absent. Injected for tests. */
40
+ readFile: (relPath: string) => Promise<string | null>;
41
+ }
@@ -0,0 +1,10 @@
1
+ /** Result constructors, so a check body reads one outcome per line instead of object literals. */
2
+ export function pass(detail) {
3
+ return { status: 'pass', detail };
4
+ }
5
+ export function fail(detail) {
6
+ return { status: 'fail', detail };
7
+ }
8
+ export function skip(detail) {
9
+ return { status: 'skip', detail };
10
+ }
@@ -0,0 +1,12 @@
1
+ import type { DoctorContext } from './types.js';
2
+ export interface WranglerFacts {
3
+ /** A send_email binding named EMAIL is declared. */
4
+ hasEmailBinding: boolean;
5
+ /** A d1_databases binding named AUTH_DB is declared. */
6
+ hasAuthDb: boolean;
7
+ /** The AUTH_DB database_id, when declared; the D1 check queries it. */
8
+ authDbId?: string;
9
+ /** observability.enabled is true. */
10
+ observabilityEnabled: boolean;
11
+ }
12
+ export declare function readWranglerConfig(readFile: DoctorContext['readFile']): Promise<WranglerFacts | null>;
@@ -0,0 +1,125 @@
1
+ export async function readWranglerConfig(readFile) {
2
+ const jsonc = await readFile('wrangler.jsonc');
3
+ if (jsonc !== null)
4
+ return factsFromJsonc(jsonc);
5
+ const toml = await readFile('wrangler.toml');
6
+ if (toml !== null)
7
+ return factsFromToml(toml);
8
+ return null;
9
+ }
10
+ // Strip // and /* */ comments outside string literals, character by character, so a URL
11
+ // inside a string survives. Trailing commas go by regex afterward; a string containing
12
+ // ",}" would be mangled, an accepted gap in a tolerant reader.
13
+ function stripJsonc(text) {
14
+ let out = '';
15
+ let inString = false;
16
+ let i = 0;
17
+ while (i < text.length) {
18
+ const ch = text[i];
19
+ if (inString) {
20
+ out += ch;
21
+ if (ch === '\\') {
22
+ out += text[i + 1] ?? '';
23
+ i += 2;
24
+ continue;
25
+ }
26
+ if (ch === '"')
27
+ inString = false;
28
+ i += 1;
29
+ continue;
30
+ }
31
+ if (ch === '"') {
32
+ inString = true;
33
+ out += ch;
34
+ i += 1;
35
+ continue;
36
+ }
37
+ if (ch === '/' && text[i + 1] === '/') {
38
+ const end = text.indexOf('\n', i);
39
+ i = end === -1 ? text.length : end;
40
+ continue;
41
+ }
42
+ if (ch === '/' && text[i + 1] === '*') {
43
+ const end = text.indexOf('*/', i + 2);
44
+ i = end === -1 ? text.length : end + 2;
45
+ continue;
46
+ }
47
+ out += ch;
48
+ i += 1;
49
+ }
50
+ return out.replace(/,(\s*[}\]])/g, '$1');
51
+ }
52
+ function factsFromJsonc(text) {
53
+ let config;
54
+ try {
55
+ config = JSON.parse(stripJsonc(text));
56
+ }
57
+ catch {
58
+ // V8's SyntaxError embeds a source snippet, which would land verbatim in the report;
59
+ // a file that exists but does not parse is a fail with a clean message instead.
60
+ throw new Error('wrangler.jsonc did not parse');
61
+ }
62
+ const sendEmail = Array.isArray(config.send_email) ? config.send_email : [];
63
+ const hasEmailBinding = sendEmail.some((entry) => typeof entry === 'object' && entry !== null && entry.name === 'EMAIL');
64
+ const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
65
+ const authDb = databases.find((entry) => typeof entry === 'object' && entry !== null && entry.binding === 'AUTH_DB');
66
+ const observability = config.observability;
67
+ const facts = {
68
+ hasEmailBinding,
69
+ hasAuthDb: authDb !== undefined,
70
+ observabilityEnabled: observability?.enabled === true,
71
+ };
72
+ if (typeof authDb?.database_id === 'string')
73
+ facts.authDbId = authDb.database_id;
74
+ return facts;
75
+ }
76
+ // The toml read is deliberately shallow: line-anchored matching for the three facts, not a
77
+ // TOML parser. The remediation tells the operator exactly what to add, so full fidelity
78
+ // buys nothing here. A table header opens a section; the relevant key lines are matched
79
+ // within it and the d1 table flushes on the next header.
80
+ function factsFromToml(text) {
81
+ const facts = {
82
+ hasEmailBinding: false,
83
+ hasAuthDb: false,
84
+ observabilityEnabled: false,
85
+ };
86
+ let section = '';
87
+ let d1Binding;
88
+ let d1Id;
89
+ const flushD1 = () => {
90
+ if (d1Binding === 'AUTH_DB') {
91
+ facts.hasAuthDb = true;
92
+ if (d1Id !== undefined)
93
+ facts.authDbId = d1Id;
94
+ }
95
+ d1Binding = undefined;
96
+ d1Id = undefined;
97
+ };
98
+ for (const line of text.split('\n')) {
99
+ const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
100
+ if (header) {
101
+ flushD1();
102
+ section = header[1];
103
+ continue;
104
+ }
105
+ const kv = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
106
+ if (!kv)
107
+ continue;
108
+ const [, key, value] = kv;
109
+ const str = value.match(/^["'](.*)["']/)?.[1];
110
+ if (section === '[[send_email]]' && key === 'name' && str === 'EMAIL') {
111
+ facts.hasEmailBinding = true;
112
+ }
113
+ else if (section === '[[d1_databases]]') {
114
+ if (key === 'binding')
115
+ d1Binding = str;
116
+ if (key === 'database_id')
117
+ d1Id = str;
118
+ }
119
+ else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
120
+ facts.observabilityEnabled = true;
121
+ }
122
+ }
123
+ flushD1();
124
+ return facts;
125
+ }
@@ -9,7 +9,9 @@ export declare function installationToken(creds: AppCredentials): Promise<string
9
9
  * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
10
10
  * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
11
11
  * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
12
- * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
12
+ * lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
13
+ * promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
14
+ * a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
13
15
  * cache is testable with no network call and no real clock.
14
16
  */
15
17
  export declare function createInstallationTokenCache(mint?: (creds: AppCredentials) => Promise<string>, now?: () => number, ttlMs?: number): (creds: AppCredentials) => Promise<string>;
@@ -65,18 +65,26 @@ export async function installationToken(creds) {
65
65
  * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
66
66
  * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
67
67
  * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
68
- * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
68
+ * lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
69
+ * promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
70
+ * a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
69
71
  * cache is testable with no network call and no real clock.
70
72
  */
71
73
  export function createInstallationTokenCache(mint = installationToken, now = () => Date.now(), ttlMs = 55 * 60 * 1000) {
72
74
  const cache = new Map();
73
- return async function get(creds) {
75
+ return function get(creds) {
74
76
  const hit = cache.get(creds.installationId);
75
77
  if (hit && hit.expiresAt > now())
76
78
  return hit.token;
77
- const token = await mint(creds);
78
- cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
79
- return token;
79
+ const entry = { token: mint(creds), expiresAt: now() + ttlMs };
80
+ cache.set(creds.installationId, entry);
81
+ // Evict only this entry on rejection: a newer entry that replaced it must survive. The
82
+ // caller's await surfaces the rejection itself, so this side handler swallows nothing.
83
+ entry.token.catch(() => {
84
+ if (cache.get(creds.installationId) === entry)
85
+ cache.delete(creds.installationId);
86
+ });
87
+ return entry.token;
80
88
  };
81
89
  }
82
90
  /** The shared installation-token cache, one instance per Worker isolate. */
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'guard.rejected';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';