Ctrl K
ComunidadGuías…

scripts/setup-routing.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env tsx
/**
 * `pnpm setup:routing` — wires Cloudflare Email Routing to deliver a recipient to this
 * Worker, using the parts Wrangler can automate and falling back to the Cloudflare REST API
 * for catch-all -> Worker because Wrangler currently rejects that rule shape client-side.
 * The irreducible part (MX/SPF/DKIM DNS + verification) is surfaced via `email routing dns get`,
 * not hidden.
 *
 * SAFETY: dry-run by default. Prints the exact commands and changes nothing. Pass `--apply`
 * to run them against the zone in the Cloudflare account your `wrangler` is logged into.
 *
 * Usage:
 *   pnpm setup:routing --domain example.com                          # dry run (address defaults to inbox@<domain>)
 *   pnpm setup:routing --domain example.com --address hi@example.com # dry run, explicit recipient
 *   pnpm setup:routing --domain example.com --catch-all              # dry run, catch-all *@example.com -> Worker
 *   pnpm setup:routing --domain example.com --env dev --apply        # enable + create rule -> reccado-dev
 */
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { canonicalPrimaryAddress } from "../src/lib/mailbox-id";
import { literalRoutingRuleArgs } from "./lib/routing";

type WranglerConfig = {
	name?: string;
	env?: Record<string, { name?: string }>;
};

type CloudflareEnvelope<T> = {
	success?: boolean;
	errors?: Array<{ code?: number; message?: string }>;
	messages?: Array<{ code?: number; message?: string }>;
	result?: T;
};

type ZoneResult = {
	id?: string;
	name?: string;
	account?: { id?: string; name?: string };
};

type CatchAllRule = {
	enabled?: boolean;
	name?: string;
	actions?: Array<{ type?: string; value?: string[] }>;
	matchers?: Array<{ type?: string }>;
	source?: string;
};

function parseArgs(argv: string[]): Record<string, string> {
	const args: Record<string, string> = {};
	for (let i = 0; i < argv.length; i += 1) {
		const arg = argv[i];
		if (!arg?.startsWith("--")) continue;
		const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
		if (!rawKey) continue;
		if (inlineValue !== undefined) {
			args[rawKey] = inlineValue;
			continue;
		}
		const next = argv[i + 1];
		if (!next || next.startsWith("--")) {
			args[rawKey] = "true";
			continue;
		}
		args[rawKey] = next;
		i += 1;
	}
	return args;
}

function stripJsonc(input: string): string {
	return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}

const args = parseArgs(process.argv.slice(2));
const apply = args.apply === "true";
const targetEnv = args.env;
const domain = args.domain?.trim().toLowerCase();
const catchAll = args["catch-all"] === "true";
if (!domain) {
	console.error("setup:routing: --domain is required (e.g. --domain example.com).");
	process.exit(1);
}
const resolvedDomain = domain;
let address: string;
try {
	address = canonicalPrimaryAddress(args.address?.trim() || `inbox@${resolvedDomain}`);
} catch {
	console.error(`setup:routing: invalid --address "${args.address}".`);
	process.exit(1);
}
if (!address.endsWith(`@${resolvedDomain}`)) {
	console.error(`setup:routing: --address ${address} is not on --domain ${resolvedDomain}.`);
	process.exit(1);
}

// Resolve the Worker name (the rule's action target) from wrangler.jsonc.
const config = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as WranglerConfig;
const worker = (targetEnv ? config.env?.[targetEnv]?.name : config.name) ?? config.name;
if (!worker) {
	console.error(
		`setup:routing: could not resolve the Worker name for env "${targetEnv ?? "production"}".`,
	);
	process.exit(1);
}

// stderr PIPED so error.stderr is populated for the idempotency check; printed on real failures.
function wrangler(argv: string[]): void {
	execFileSync("pnpm", ["wrangler", ...argv], {
		encoding: "utf8",
		stdio: ["ignore", "inherit", "pipe"],
	});
}

function wranglerCapture(argv: string[]): string {
	return execFileSync("pnpm", ["wrangler", ...argv], {
		encoding: "utf8",
		stdio: ["ignore", "pipe", "pipe"],
	});
}

function formatApiErrors(payload: {
	errors?: Array<{ code?: number; message?: string }>;
	messages?: Array<{ code?: number; message?: string }>;
}): string {
	const items = [...(payload.errors ?? []), ...(payload.messages ?? [])]
		.map((item) => {
			const code = item.code ? ` [${item.code}]` : "";
			return `${item.message ?? "unknown Cloudflare API error"}${code}`;
		})
		.filter(Boolean);
	return items.length > 0 ? items.join("; ") : "unknown Cloudflare API error";
}

async function cfApi<T>(token: string, path: string, init?: RequestInit): Promise<T> {
	const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
		...init,
		headers: {
			Authorization: `Bearer ${token}`,
			"Content-Type": "application/json",
			...(init?.headers ?? {}),
		},
	});
	const payload = (await response.json()) as CloudflareEnvelope<T>;
	if (!response.ok || payload.success !== true || payload.result === undefined) {
		throw new Error(formatApiErrors(payload));
	}
	return payload.result;
}

function matchersAreCatchAll(matchers: CatchAllRule["matchers"]): boolean {
	return (matchers ?? []).some((matcher) => matcher.type === "all");
}

function actionsTargetWorker(actions: CatchAllRule["actions"], workerName: string): boolean {
	return (actions ?? []).some(
		(action) => action.type === "worker" && (action.value ?? []).includes(workerName),
	);
}

function parseWranglerAccountInfo(output: string): { id?: string; name?: string } | null {
	try {
		const payload = JSON.parse(output) as {
			account?: { id?: string; name?: string };
			accounts?: Array<{ id?: string; name?: string }>;
		};
		if (payload.account?.id || payload.account?.name) {
			return payload.account;
		}
		if ((payload.accounts?.length ?? 0) > 0) {
			return payload.accounts?.[0] ?? null;
		}
	} catch {
		return null;
	}
	return null;
}

async function ensureCatchAllRule(workerName: string): Promise<void> {
	const token = process.env.CLOUDFLARE_API_TOKEN?.trim();
	if (!token) {
		console.error(
			"setup:routing: --catch-all --apply requires CLOUDFLARE_API_TOKEN.\n" +
				"Set a token with Zone Read + Email Routing Write on the target zone, then re-run.",
		);
		process.exit(1);
	}

	let wranglerAccount: { id?: string; name?: string } | null = null;
	try {
		wranglerAccount = parseWranglerAccountInfo(wranglerCapture(["whoami", "--json"]));
	} catch {
		console.error(
			"setup:routing: could not read Wrangler account context (`pnpm wrangler whoami --json`).\n" +
				"Log in with Wrangler before running --apply so Email Routing enable/DNS checks can run.",
		);
		process.exit(1);
	}
	if (!wranglerAccount?.id) {
		console.error(
			"setup:routing: Wrangler is logged in but no Cloudflare account id was available.\n" +
				"Re-authenticate with Wrangler, then re-run `pnpm wrangler whoami` to confirm account access.",
		);
		process.exit(1);
	}

	let zone: ZoneResult;
	try {
		const zones = await cfApi<ZoneResult[]>(
			token,
			`/zones?name=${encodeURIComponent(resolvedDomain)}&status=active&match=all`,
		);
		const exact = zones.filter((item) => item.name?.toLowerCase() === resolvedDomain);
		if (exact.length !== 1 || !exact[0]?.id) {
			console.error(
				`setup:routing: could not resolve zone "${resolvedDomain}" from the Cloudflare API.\n` +
					"Check that CLOUDFLARE_API_TOKEN can read the zone and that the domain is in this account.",
			);
			process.exit(1);
		}
		zone = exact[0];
	} catch (error) {
		console.error(
			`setup:routing: zone lookup failed for ${resolvedDomain}: ${error instanceof Error ? error.message : error}`,
		);
		process.exit(1);
	}

	const accountLabel = zone.account?.name
		? `${zone.account.name} (${zone.account.id ?? "unknown id"})`
		: (zone.account?.id ?? `wrangler account ${wranglerAccount.id}`);
	console.log(`  Cloudflare account: ${accountLabel}`);
	console.log(`  Zone: ${resolvedDomain} (${zone.id})`);

	let current: CatchAllRule | undefined;
	try {
		current = await cfApi<CatchAllRule>(token, `/zones/${zone.id}/email/routing/rules/catch_all`);
	} catch (error) {
		console.error(
			`setup:routing: failed to read the current catch-all rule for ${resolvedDomain}: ${
				error instanceof Error ? error.message : error
			}`,
		);
		process.exit(1);
	}

	if (
		current.enabled === true &&
		matchersAreCatchAll(current.matchers) &&
		actionsTargetWorker(current.actions, workerName)
	) {
		console.log("  (catch-all already routes to this Worker — skipping)");
		return;
	}

	const payload = {
		enabled: true,
		name: `Catch-all -> ${workerName}`,
		matchers: [{ type: "all" }],
		actions: [{ type: "worker", value: [workerName] }],
	};
	console.log("  Applying Cloudflare API update for catch-all routing");
	try {
		await cfApi<CatchAllRule>(token, `/zones/${zone.id}/email/routing/rules/catch_all`, {
			method: "PUT",
			body: JSON.stringify(payload),
		});
	} catch (error) {
		console.error(
			`setup:routing: failed to update the catch-all rule for ${resolvedDomain}: ${
				error instanceof Error ? error.message : error
			}`,
		);
		process.exit(1);
	}
}

/** Runs a step, treating an "already exists/enabled" failure as success (idempotency). */
function runIdempotent(title: string, argv: string[]): void {
	console.log(`\n▸ ${title}\n  $ pnpm wrangler ${argv.join(" ")}`);
	if (!apply) return;
	try {
		wrangler(argv);
	} catch (error) {
		const stderr =
			typeof (error as { stderr?: unknown })?.stderr === "string"
				? (error as { stderr: string }).stderr
				: "";
		const haystack = `${error instanceof Error ? error.message : String(error)}\n${stderr}`;
		if (/already exists|already enabled|already created|duplicate|409/i.test(haystack)) {
			console.log("  (already in place — skipping)");
		} else {
			if (stderr) console.error(stderr);
			throw error;
		}
	}
}

console.log(
	`\nReccado setup:routing — domain: ${resolvedDomain} · target: ${
		catchAll ? `*@${resolvedDomain}` : address
	} · worker: ${worker}` +
		`\nmode: ${apply ? "APPLY (mutating Cloudflare)" : "dry run (no changes)"}\n`,
);

runIdempotent("Enable Email Routing on the zone", ["email", "routing", "enable", resolvedDomain]);

if (catchAll) {
	const payload = JSON.stringify(
		{
			enabled: true,
			name: `Catch-all -> ${worker}`,
			matchers: [{ type: "all" }],
			actions: [{ type: "worker", value: [worker] }],
		},
		null,
		2,
	);
	console.log(
		`\n▸ Configure catch-all *@${resolvedDomain} -> Worker via Cloudflare API\n` +
			"  Wrangler currently rejects catch-all worker rules client-side, so this path uses the\n" +
			"  documented REST endpoint instead of `wrangler email routing rules create`.\n" +
			`  PUT https://api.cloudflare.com/client/v4/zones/<zone-id>/email/routing/rules/catch_all\n` +
			`  payload:\n${payload
				.split("\n")
				.map((line) => `    ${line}`)
				.join("\n")}`,
	);
	if (apply) {
		await ensureCatchAllRule(worker);
	}
} else {
	runIdempotent(
		"Create the send-to-Worker rule",
		literalRoutingRuleArgs({ zone: resolvedDomain, address, worker }),
	);
}

// DNS is the irreducible part — show the records the user must add on their zone.
console.log(
	`\n▸ Show required DNS (MX/SPF/DKIM)\n  $ pnpm wrangler email routing dns get ${domain}`,
);
if (apply) {
	wrangler(["email", "routing", "dns", "get", resolvedDomain]);
}

console.log(`\n${"─".repeat(72)}`);
console.log("Still required (only DNS + verification — not automatable here):\n");
console.log(
	`- Add the MX/SPF/DKIM records above on ${resolvedDomain} and let Email Routing verify them.`,
);
console.log(`- Check status any time:  pnpm wrangler email routing settings ${resolvedDomain}`);
if (catchAll) {
	console.log(
		"- Catch-all note: Cloudflare supports catch-all -> Worker via API; Wrangler currently rejects that specific rule client-side, so this script uses the REST API for `--catch-all`.",
	);
}
console.log(`- Seed the mailbox that receives it (if not done):`);
console.log(
	`    pnpm setup:mailbox --domain ${resolvedDomain} --address ${address}` +
		`${targetEnv ? ` --env ${targetEnv}` : ""} --apply`,
);
console.log(`\nRe-check anytime:  pnpm doctor --env ${targetEnv ?? "production"} --cloud\n`);

if (!apply) {
	console.log("Dry run only. Re-run with --apply to execute against Cloudflare.\n");
}