scripts/doctor.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env tsx
/**
* `pnpm doctor` — diagnoses a Reccado checkout and tells you the exact command to
* fix whatever is incomplete, instead of failing opaquely at `pnpm dev` or deploy.
*
* Default run is offline and deterministic (toolchain + local dev + config placeholders).
* Pass `--cloud` to add remote checks (auth, D1 exists + id match, every declared Queue exists and
* its DLQs are consumed, every sending domain publishes its lifecycle events to the events queue,
* required secrets, the tg-hq forum topic registry agrees with D1's topic mappings, and — with `--url` — that the deployed login page and /api/health answer).
* Exhaustive R2/queue/Email-Routing *binding* wiring lives in `pnpm verify:cf`.
*
* Usage:
* pnpm doctor # local + config checks for the default (production) config
* pnpm doctor --env dev # inspect the env.dev block instead
* pnpm doctor --env dev --cloud --url https://… # remote: D1, secrets, auth endpoints
* pnpm -s doctor:json # machine-readable output (-s drops pnpm's banner; exit 1 on any fail)
*/
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { generatedConfigPathFor } from "./lib/built-config";
import {
applyGeneratedOverlay,
deployedVars,
type WranglerConfig as DeployWranglerConfig,
effectiveBlock,
parseDomainList,
} from "./lib/deploy-config";
import { classifyDmarc, lookupTxt } from "./lib/dmarc";
import {
type AccountQueue,
crossCheckSendingDomains,
describeFeedbackVerdict,
EMAIL_SENDING_EVENT_TYPES,
evaluateFeedbackSubscription,
MIN_WRANGLER_FOR_EMAIL_SENDING,
parseQueueListTable,
parseSendingDomainsTable,
parseSubscriptionListJson,
} from "./lib/event-subscriptions";
import {
compareTopicRegistry,
parseTopicRegistry,
type TopicMapping,
} from "./lib/telegram-topic-registry";
type Status = "pass" | "warn" | "fail" | "info";
type Check = { id: string; status: Status; message: string; fix?: string };
const SYMBOL: Record<Status, string> = { pass: "✓", warn: "!", fail: "✗", info: "·" };
const PROD_D1_PLACEHOLDER = "<your-prod-d1-database-id>";
const DEV_D1_PLACEHOLDER = "00000000-0000-0000-0000-000000000000";
const EXAMPLE_FROM = "noreply@mail.example.com";
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, "");
}
/** Compares dotted numeric versions. Returns -1/0/1 for a<b / a==b / a>b. */
function compareVersions(a: string, b: string): number {
const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
function parseDotEnv(content: string): Map<string, string> {
const map = new Map<string, string>();
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const sep = trimmed.indexOf("=");
if (sep === -1) continue;
map.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim());
}
return map;
}
const checks: Check[] = [];
const add = (c: Check) => checks.push(c);
const args = parseArgs(process.argv.slice(2));
const targetEnv = args.env; // undefined => top-level (production) config
const asJson = args.json === "true";
// --- Toolchain ---------------------------------------------------------------
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
engines?: { node?: string };
packageManager?: string;
};
const requiredNode = (pkg.engines?.node ?? "").replace(/[^\d.]/g, "");
const currentNode = process.versions.node;
if (requiredNode && compareVersions(currentNode, requiredNode) < 0) {
add({
id: "node.version",
status: "fail",
message: `Node ${currentNode} is below required >=${requiredNode}.`,
fix: `Install Node >=${requiredNode} (see .node-version / your version manager).`,
});
} else {
add({
id: "node.version",
status: "pass",
message: `Node ${currentNode} satisfies engines.node.`,
});
}
const pinnedPnpm = pkg.packageManager?.startsWith("pnpm@")
? pkg.packageManager.slice("pnpm@".length)
: undefined;
try {
const pnpmVersion = execFileSync("pnpm", ["--version"], { encoding: "utf8" }).trim();
if (pinnedPnpm && pnpmVersion !== pinnedPnpm) {
add({
id: "pnpm.version",
status: "warn",
message: `pnpm ${pnpmVersion} differs from pinned pnpm@${pinnedPnpm}.`,
fix: "Run `corepack enable` so the repo-pinned pnpm is used automatically.",
});
} else {
add({
id: "pnpm.version",
status: "pass",
message: `pnpm ${pnpmVersion} matches packageManager.`,
});
}
} catch {
add({
id: "pnpm.version",
status: "fail",
message: "pnpm is not runnable.",
fix: "Enable it with `corepack enable` or install pnpm.",
});
}
try {
const wranglerVersion = execFileSync("pnpm", ["wrangler", "--version"], { encoding: "utf8" })
.trim()
.split(/\s+/)
.find((t) => /^\d+\.\d+/.test(t));
if (wranglerVersion && compareVersions(wranglerVersion, "4.0.0") >= 0) {
add({ id: "wrangler.version", status: "pass", message: `Wrangler ${wranglerVersion} (4.x).` });
} else {
add({
id: "wrangler.version",
status: "warn",
message: `Wrangler ${wranglerVersion ?? "unknown"} — this repo assumes 4.x.`,
fix: "Update the `wrangler` devDependency to ^4.x.",
});
}
} catch {
add({
id: "wrangler.version",
status: "fail",
message: "Wrangler is not runnable.",
fix: "Run `pnpm install` to install the pinned wrangler.",
});
}
// --- Local dev ---------------------------------------------------------------
if (!existsSync(".dev.vars")) {
add({
id: "devvars.present",
status: "info",
message: ".dev.vars is absent — `pnpm dev` will generate a minimal one automatically.",
});
} else {
const devVars = parseDotEnv(readFileSync(".dev.vars", "utf8"));
add({ id: "devvars.present", status: "pass", message: ".dev.vars exists." });
const secret = devVars.get("BETTER_AUTH_SECRET")?.trim();
if (!secret) {
add({
id: "devvars.auth-bypass",
status: "pass",
message: "BETTER_AUTH_SECRET is unset locally — local-dev bypass is active.",
});
} else if (secret.length < 32) {
add({
id: "devvars.auth-bypass",
status: "fail",
message:
"BETTER_AUTH_SECRET is set but shorter than 32 characters — the issuer refuses to start.",
fix: "Generate a real secret: openssl rand -base64 32",
});
} else {
add({
id: "devvars.auth-bypass",
status: "pass",
message: "BETTER_AUTH_SECRET is set — real Better Auth sessions locally.",
});
}
}
const migrationFiles = existsSync("migrations/d1")
? readdirSync("migrations/d1").filter((f) => f.endsWith(".sql"))
: [];
if (migrationFiles.length > 0) {
add({
id: "migrations.present",
status: "pass",
message: `${migrationFiles.length} D1 migration file(s) found.`,
});
} else {
add({
id: "migrations.present",
status: "fail",
message: "No D1 migration files under migrations/d1.",
fix: "Restore migrations/d1/*.sql (the schema source of truth).",
});
}
// --- Config (wrangler.jsonc) -------------------------------------------------
type WranglerConfig = {
name?: string;
workers_dev?: boolean;
routes?: Array<{ pattern: string; custom_domain?: boolean }>;
vars?: { MAIL_FROM_ADDRESS?: string };
queues?: {
producers?: Array<{ binding: string; queue: string }>;
consumers?: Array<{ queue: string; dead_letter_queue?: string }>;
};
d1_databases?: Array<{ binding: string; database_name?: string; database_id: string }>;
env?: Record<string, WranglerConfig>;
};
const wrangler = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as WranglerConfig;
const block: WranglerConfig | undefined = targetEnv ? wrangler.env?.[targetEnv] : wrangler;
const envLabel = targetEnv ?? "production (top-level)";
if (!block) {
add({
id: "config.env",
status: "fail",
message: `No config block for env "${targetEnv}" in wrangler.jsonc.`,
fix: "Pass a valid --env (e.g. dev) or omit it for the default config.",
});
} else {
const d1Id = block.d1_databases?.find((d) => d.binding === "INDEX_DB")?.database_id ?? "";
const isPlaceholder = d1Id === PROD_D1_PLACEHOLDER || d1Id === DEV_D1_PLACEHOLDER || d1Id === "";
if (!isPlaceholder) {
add({
id: "config.d1-id",
status: "pass",
message: `INDEX_DB database_id is set for ${envLabel}.`,
});
} else if (targetEnv) {
add({
id: "config.d1-id",
status: "warn",
message: `INDEX_DB database_id is a placeholder for ${envLabel} — fine locally, not for a remote deploy.`,
fix: `pnpm setup:cloud --env ${targetEnv} --domain <d> --address inbox@<d> --apply (writes a gitignored generated config; don't edit wrangler.jsonc)`,
});
} else {
add({
id: "config.d1-id",
status: "warn",
message: `INDEX_DB database_id is a placeholder for ${envLabel} — local dev is fine, but a remote deploy will fail until it is set.`,
fix: "pnpm setup:cloud --domain <d> --address inbox@<d> --apply (writes a gitignored generated config; don't edit wrangler.jsonc)",
});
}
const from = block.vars?.MAIL_FROM_ADDRESS;
if (from && from !== EXAMPLE_FROM) {
add({
id: "config.mail-from",
status: "pass",
message: `MAIL_FROM_ADDRESS set for ${envLabel}.`,
});
} else {
add({
id: "config.mail-from",
status: "warn",
message: `MAIL_FROM_ADDRESS is still the ${EXAMPLE_FROM} example for ${envLabel}.`,
fix: "Set vars.MAIL_FROM_ADDRESS to a verified Email Sending address.",
});
}
if (block.workers_dev === false) {
add({
id: "config.public-host",
status: "pass",
message: `${envLabel} disables workers.dev; use a custom domain for the deployed UI/API.`,
});
} else if (targetEnv === "dev") {
add({
id: "config.public-host",
status: "info",
message:
"dev keeps workers.dev available for remote smoke tests; use a custom domain for the authenticated UI/API.",
fix: `Attach a custom domain with pnpm setup:domain --env dev --hostname app.<your-domain>`,
});
} else {
add({
id: "config.public-host",
status: "warn",
message: `${envLabel} still exposes workers.dev.`,
fix: `Set workers_dev=false and attach a custom domain with pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app.<your-domain>`,
});
}
const customDomainRoutes =
block.routes?.filter((route) => route.custom_domain).map((route) => route.pattern) ?? [];
if (customDomainRoutes.length > 0) {
add({
id: "config.custom-domain",
status: "pass",
message: `Custom domain route configured: ${customDomainRoutes.join(", ")}.`,
});
} else {
add({
id: "config.custom-domain",
status: targetEnv ? "info" : "warn",
message: "No custom domain route is configured in tracked wrangler.jsonc.",
fix: `Use pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app.<your-domain> (writes a gitignored generated config; doesn't edit wrangler.jsonc).`,
});
}
}
// --- Config that will actually deploy ------------------------------------------
//
// `pnpm run deploy[:dev]` ships wrangler.jsonc with the generated file's owned
// fields overlaid (scripts/lib/deploy-config.ts). Every check below that reasons
// about "the deployed config" reads it through the same function, so doctor and
// the deploy cannot disagree about what ships.
const generatedPath = generatedConfigPathFor(targetEnv);
let generatedConfig: DeployWranglerConfig | undefined;
try {
generatedConfig = existsSync(generatedPath)
? (JSON.parse(stripJsonc(readFileSync(generatedPath, "utf8"))) as DeployWranglerConfig)
: undefined;
} catch {
add({
id: "config.deploy-overlay",
status: "fail",
message: `${generatedPath} exists but is not valid JSON — every deploy of ${envLabel} will refuse to run.`,
fix: `Fix or delete ${generatedPath}, then re-run the setup script that wrote it.`,
});
}
let deployVars: Record<string, unknown> = {};
if (block) {
const trackedFull = wrangler as DeployWranglerConfig;
try {
deployVars = deployedVars(trackedFull, generatedConfig, targetEnv);
if (generatedConfig) {
const overlay = applyGeneratedOverlay({
base: effectiveBlock(trackedFull, targetEnv),
generated: effectiveBlock(generatedConfig, targetEnv),
tracked: effectiveBlock(trackedFull, targetEnv),
});
// A generated var that overrides a DIFFERENT tracked value is the drift a
// reader of wrangler.jsonc cannot see; one that only adds is the normal case.
const conflicting = overlay.applied.filter(
(change) => change.field.startsWith("vars.") && change.from !== undefined,
);
const ignoredNote =
overlay.ignored.length > 0
? ` Ignored there (wrangler.jsonc wins): ${overlay.ignored.join(", ")}.`
: "";
add(
conflicting.length > 0
? {
id: "config.deploy-overlay",
status: "warn",
message: `${generatedPath} overrides wrangler.jsonc on deploy: ${conflicting.map((c) => `${c.field} ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`).join("; ")}.${ignoredNote}`,
fix: "The generated value is what ships. Make the two agree (or delete the var from one file) so wrangler.jsonc does not mislead.",
}
: {
id: "config.deploy-overlay",
status: overlay.ignored.length > 0 ? "info" : "pass",
message: `Deploys overlay ${generatedPath} (${overlay.applied.length} owned field(s) applied, none contradicting wrangler.jsonc).${ignoredNote}`,
},
);
} else {
add({
id: "config.deploy-overlay",
status: "info",
message: `No ${generatedPath} — deploys ship wrangler.jsonc as built.`,
});
}
} catch (error) {
add({
id: "config.deploy-overlay",
status: "fail",
message: `Could not compute the deployed config: ${error instanceof Error ? error.message : error}.`,
});
}
}
function addAll(items: Check[]): void {
for (const c of items) add(c);
}
/** Confirms the target env's INDEX_DB database exists in the account and its id matches config. */
function checkD1Remote(): Check[] {
const entry = block?.d1_databases?.find((d) => d.binding === "INDEX_DB");
if (!entry?.database_name) return [];
try {
const list = JSON.parse(
execFileSync("pnpm", ["wrangler", "d1", "list", "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
) as Array<{ name: string; uuid?: string; database_id?: string }>;
const found = list.find((d) => d.name === entry.database_name);
if (!found) {
return [
{
id: "cloud.d1",
status: "fail",
message: `D1 "${entry.database_name}" not found in this account.`,
fix: `pnpm setup:cloud${targetEnv ? ` --env ${targetEnv}` : ""} --domain <d> --address inbox@<d> --apply`,
},
];
}
const realId = found.uuid ?? found.database_id;
const isPlaceholder =
entry.database_id === PROD_D1_PLACEHOLDER || entry.database_id === DEV_D1_PLACEHOLDER;
if (!isPlaceholder && realId && realId !== entry.database_id) {
return [
{
id: "cloud.d1",
status: "warn",
message: `D1 "${entry.database_name}" exists but its id (${realId}) differs from wrangler.jsonc (${entry.database_id}).`,
fix: "Update the INDEX_DB database_id, or deploy with the generated wrangler config.",
},
];
}
return [
{
id: "cloud.d1",
status: "pass",
message: `D1 "${entry.database_name}" exists in the account.`,
},
];
} catch {
return [{ id: "cloud.d1", status: "warn", message: "Could not list D1 databases (auth?)." }];
}
}
// `queues list` may or may not accept --json depending on the wrangler in play, so
// the first page decides which surface to read and the rest follow it: no repeated
// failures.
let queueListJson = true;
function runQueueList(page: number): string | null {
const argv = ["wrangler", "queues", "list", "--page", String(page)];
if (queueListJson) {
try {
return execFileSync("pnpm", [...argv, "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
queueListJson = false;
}
}
try {
return execFileSync("pnpm", argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
} catch {
return null;
}
}
function parseQueueListJson(raw: string): AccountQueue[] | null {
const start = raw.indexOf("[");
if (start === -1) return null;
try {
const parsed = JSON.parse(raw.slice(start)) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed.map((entry: Record<string, unknown>) => ({
id: String(entry.queue_id ?? entry.id ?? ""),
name: String(entry.queue_name ?? entry.name ?? ""),
consumers: Array.isArray(entry.consumers)
? entry.consumers.length
: Number(entry.consumers_total_count ?? 0),
}));
} catch {
return null;
}
}
/**
* Queue name → the account's row for it, or null if it can't be read. The id
* matters as much as the consumer count: an event subscription's destination is
* a queue id, so nothing can tell "subscribed to us" from "subscribed to the
* other environment" without this mapping.
*/
function listAccountQueues(): Map<string, AccountQueue> | null {
const byName = new Map<string, AccountQueue>();
// Paginated: a queue that merely sits on page 2 would otherwise be reported as
// a missing one, which is a deploy-breaking verdict.
for (let page = 1; page <= 10; page += 1) {
const raw = runQueueList(page);
if (raw === null) return page === 1 ? null : byName;
const rows = parseQueueListJson(raw) ?? parseQueueListTable(raw);
if (rows.length === 0) break;
for (const row of rows) {
if (row.name) byName.set(row.name, row);
}
}
return byName;
}
/**
* Queues are the one binding wrangler never creates for you: deploy fails outright
* on a queue the config names and the account lacks, and `pnpm doctor` existed
* precisely so that failure arrives here instead. The declared set is the same
* union setup:cloud provisions — producers, consumers and their dead-letter queues,
* deduplicated — read the same way so the two cannot drift.
*
* A DLQ with no consumer is not a deploy failure but is worse in production: dead
* messages sit there unread until Queues retention drops them (~4 days) and the
* evidence of what broke is gone.
*/
function checkQueuesRemote(): Check[] {
const dlqs = (block?.queues?.consumers ?? [])
.map((consumer) => consumer.dead_letter_queue)
.filter((name): name is string => Boolean(name));
const declared = [
...new Set([
...(block?.queues?.producers ?? []).map((producer) => producer.queue),
...(block?.queues?.consumers ?? []).map((consumer) => consumer.queue),
...dlqs,
]),
].filter(Boolean);
if (declared.length === 0) return [];
const account = listAccountQueues();
if (!account) {
return [
{
id: "cloud.queues",
status: "warn",
message: "Could not list Queues (auth?).",
fix: "Run `pnpm wrangler login`, then re-run with --cloud.",
},
];
}
const out: Check[] = [];
const missing = declared.filter((name) => !account.has(name));
if (missing.length > 0) {
out.push({
id: "cloud.queues",
status: "fail",
message: `Queue(s) declared for ${envLabel} but absent from this account: ${missing.join(", ")} — deploy will fail.`,
fix: `pnpm setup:cloud${targetEnv ? ` --env ${targetEnv}` : ""} --domain <d> --address inbox@<d> --apply (or pnpm wrangler queues create ${missing[0]})`,
});
} else {
out.push({
id: "cloud.queues",
status: "pass",
message: `All ${declared.length} queue(s) declared for ${envLabel} exist in the account.`,
});
}
// Only DLQs that exist can be judged on their consumers; the missing ones are
// already a fail above, and saying "all DLQs have a consumer" about queues that
// are not there would be the false reassurance this check exists to remove.
const existingDlqs = dlqs.filter((name) => account.has(name));
const orphanDlqs = existingDlqs.filter((name) => account.get(name)?.consumers === 0);
if (orphanDlqs.length > 0) {
out.push({
id: "cloud.queues.dlq",
status: "warn",
message: `Dead-letter queue(s) with no consumer: ${orphanDlqs.join(", ")} — dead messages expire unread (~4 days) and leave no tombstone.`,
fix: `Declare each DLQ under queues.consumers (the tombstone handler), then deploy — a consumer declared but never deployed looks exactly like this from the account side.`,
});
} else if (existingDlqs.length > 0) {
out.push({
id: "cloud.queues.dlq",
status: "pass",
message: `All ${existingDlqs.length} dead-letter queue(s) have a consumer.`,
});
}
return out;
}
/**
* Whether every sending domain Reccado owns can actually report what happened to
* the mail it sends.
*
* Enabling Email Sending on a domain gives it the ability to send; only an event
* subscription gives it the ability to *answer*. Nothing joined those two facts,
* so the account ran for weeks with four enabled sending domains and one
* subscription, and three features went on reading "no delivery event" as
* evidence about the message rather than about the channel.
*
* Two live provider lists are crossed here, never a flag written at setup time:
* a flag records what someone intended once, and the failure being caught is
* precisely reality drifting from that intention.
*
* Existence alone is a false pass, so three things are checked: the subscription
* exists, its event-type set covers all six, and its destination is this
* environment's queue. A subscription with only `message.delivered` selected
* satisfies a naive check while suppression stays dark.
*
* Scope is the sending domains sitting under a zone Reccado has registered in
* D1. An account may host sending domains for other projects entirely, and
* failing a Reccado diagnostic over someone else's subdomain would be noise that
* teaches the operator to skip the whole check.
*/
function checkSendingFeedbackRemote(): Check[] {
const queueName = block?.queues?.producers?.find(
(producer) => producer.binding === "EMAIL_EVENTS_QUEUE",
)?.queue;
if (!queueName) return [];
const account = listAccountQueues();
const queueId = account?.get(queueName)?.id;
if (!queueId) {
// checkQueuesRemote already reports a missing or unreadable queue; repeating
// it as a second failure would double-count one fault.
return [
{
id: "cloud.sending-feedback",
status: "warn",
message: `Could not resolve the id of queue ${queueName}, so its event subscriptions cannot be checked.`,
fix: "Run `pnpm wrangler login`, then re-run with --cloud.",
},
];
}
const ours = listRegisteredDomains();
if (ours === null) {
return [
{
id: "cloud.sending-feedback",
status: "warn",
message: "Could not read the registered domains from D1, so sending feedback is unchecked.",
fix: `pnpm d1:migrate:${targetEnv ?? "prod"}, or check Cloudflare auth.`,
},
];
}
let sendingDomains: Array<{ zone: string; name: string; enabled: boolean }>;
try {
sendingDomains = parseSendingDomainsTable(
execFileSync("pnpm", ["wrangler", "email", "sending", "list"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch {
return [
{
id: "cloud.sending-feedback",
status: "warn",
message: "Could not list Email Sending domains (auth? open-beta command changed?).",
fix: "pnpm wrangler email sending list",
},
];
}
const mine = sendingDomains.filter(
(domain) => domain.enabled && ours.has(domain.zone.trim().toLowerCase()),
);
if (mine.length === 0) {
return [
{
id: "cloud.sending-feedback",
status: "info",
message:
ours.size === 0
? "No domains are registered yet, so there is no sending feedback to check."
: "No enabled Email Sending domain belongs to a registered domain yet.",
fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain <your-domain> --apply`,
},
];
}
let subscriptions: ReturnType<typeof parseSubscriptionListJson>;
try {
subscriptions = parseSubscriptionListJson(
execFileSync("pnpm", ["wrangler", "queues", "subscription", "list", queueName, "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch {
subscriptions = null;
}
if (!subscriptions) {
return [
{
id: "cloud.sending-feedback",
status: "warn",
message: `Could not read the event subscriptions on ${queueName}.`,
fix: `pnpm wrangler queues subscription list ${queueName} --json — needs wrangler >= ${MIN_WRANGLER_FOR_EMAIL_SENDING}.`,
},
];
}
const broken: string[] = [];
for (const domain of mine) {
const verdict = evaluateFeedbackSubscription({
sendingDomain: domain.name,
expectedQueueId: queueId,
subscriptions,
});
if (verdict.state !== "live") {
broken.push(describeFeedbackVerdict(domain.name, queueName, verdict));
}
}
if (broken.length > 0) {
return [
{
id: "cloud.sending-feedback",
status: "fail",
message: broken.join(" "),
fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain <zone> --subdomain <label> --apply (or: pnpm wrangler queues subscription create ${queueName} --source email.sending --zone-id <zone-id> --domain <sending-domain> --events ${EMAIL_SENDING_EVENT_TYPES.join(",")})`,
},
];
}
return [
{
id: "cloud.sending-feedback",
status: "pass",
message: `All ${mine.length} sending domain(s) publish all ${EMAIL_SENDING_EVENT_TYPES.length} lifecycle events to ${queueName}.`,
},
];
}
/**
* Whether MAIL_SENDING_DOMAINS — as the deploy will actually ship it, not as
* wrangler.jsonc alone says — agrees with what Cloudflare Email Sending honours.
*
* Declared but not enabled is a FAIL: a mailbox on that domain sends as itself
* and the provider refuses the From. Enabled and subscribed to our events queue
* but not declared is only a WARN: the account may host other projects' sending
* domains, and a mailbox there merely relays through MAIL_FROM_ADDRESS.
*/
/** Filled by checkSendingDomainsDeclared so the DMARC check covers them too. */
let subscribedButUndeclared: string[] = [];
function checkSendingDomainsDeclared(): Check[] {
const declared = parseDomainList(deployVars.MAIL_SENDING_DOMAINS);
const queueName = block?.queues?.producers?.find(
(producer) => producer.binding === "EMAIL_EVENTS_QUEUE",
)?.queue;
const queueId = queueName ? listAccountQueues()?.get(queueName)?.id : undefined;
let sendingDomains: ReturnType<typeof parseSendingDomainsTable>;
let subscriptions: ReturnType<typeof parseSubscriptionListJson> = [];
try {
sendingDomains = parseSendingDomainsTable(
execFileSync("pnpm", ["wrangler", "email", "sending", "list"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
if (queueName) {
subscriptions = parseSubscriptionListJson(
execFileSync("pnpm", ["wrangler", "queues", "subscription", "list", queueName, "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
}
} catch {
return [
{
id: "cloud.sending-domains",
status: "warn",
message:
"Could not list Email Sending domains or event subscriptions, so MAIL_SENDING_DOMAINS is unchecked.",
fix: "pnpm wrangler email sending list",
},
];
}
const drift = crossCheckSendingDomains({
declared,
sendingDomains,
subscriptions: subscriptions ?? [],
queueId: queueId ?? "",
});
subscribedButUndeclared = queueId ? drift.undeclared : [];
const out: Check[] = [];
if (drift.unverified.length > 0) {
out.push({
id: "cloud.sending-domains",
status: "fail",
message: `MAIL_SENDING_DOMAINS (deployed) lists ${drift.unverified.join(", ")}, not enabled in Email Sending for this account — a mailbox there sends as itself and Cloudflare refuses the From.`,
fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain <zone> --subdomain <label>|--apex --apply, or drop the entry from ${generatedConfig ? generatedPath : "wrangler.jsonc"} and redeploy.`,
});
} else {
out.push({
id: "cloud.sending-domains",
status: "pass",
message:
declared.length > 0
? `All ${declared.length} deployed MAIL_SENDING_DOMAINS entr${declared.length === 1 ? "y is" : "ies are"} enabled in Email Sending.`
: "MAIL_SENDING_DOMAINS is empty in the deployed config; every mailbox relays through MAIL_FROM_ADDRESS.",
});
}
if (!queueId) {
out.push({
id: "cloud.sending-domains",
status: "warn",
message: `Could not resolve the events queue${queueName ? ` ${queueName}` : ""}, so undeclared sending domains are unchecked.`,
});
} else if (drift.undeclared.length > 0) {
out.push({
id: "cloud.sending-domains",
status: "warn",
message: `Enabled in Email Sending and subscribed to ${queueName}, but not in the deployed MAIL_SENDING_DOMAINS: ${drift.undeclared.join(", ")} — a mailbox on one of them would send via MAIL_FROM_ADDRESS, not as itself. (Fine if they belong to another app or are only a fallback sender.)`,
fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain <zone> --subdomain <label>|--apex --apply adds one to the generated config; then ${targetEnv === "dev" ? "pnpm run deploy:dev" : "pnpm run deploy"}.`,
});
}
return out;
}
/**
* What `_dmarc.<name>` publishes for each deployed MAIL_SENDING_DOMAINS entry,
* plus every sending domain that feeds our events queue without being declared
* — the names Email Sending was enabled on by hand, which is exactly where its
* auto-created `p=reject` survives. Resolved over DNS-over-HTTPS, so it reports
* what receivers see, not what the DNS API was last asked to write.
*/
async function checkDmarcRemote(): Promise<Check[]> {
const names = [
...new Set([...parseDomainList(deployVars.MAIL_SENDING_DOMAINS), ...subscribedButUndeclared]),
];
const out: Check[] = [];
for (const domain of names) {
const records = await lookupTxt(`_dmarc.${domain}`);
if (records === null) {
out.push({
id: "cloud.dmarc",
status: "warn",
message: `_dmarc.${domain}: could not resolve over DNS-over-HTTPS.`,
fix: `dig +short TXT _dmarc.${domain}`,
});
continue;
}
const verdict = classifyDmarc(domain, records);
out.push({
id: "cloud.dmarc",
status: verdict.status,
message: verdict.message,
fix:
verdict.status === "warn"
? `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain <zone> (--subdomain <label> | --apex) --dmarc-policy <none|quarantine|reject> --dmarc-rua dmarc@<zone> --apply`
: undefined,
});
}
return out;
}
/** The zones Reccado has registered, lowercased. Null when D1 can't be read. */
function listRegisteredDomains(): Set<string> | null {
try {
const raw = execFileSync(
"pnpm",
[
"wrangler",
"d1",
"execute",
"INDEX_DB",
"--remote",
"--json",
"--command",
"SELECT domain FROM domains WHERE status = 'active'",
...(targetEnv ? ["--env", targetEnv] : []),
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
const parsed = JSON.parse(raw.slice(raw.indexOf("["))) as Array<{
results: Array<{ domain: string }>;
}>;
return new Set((parsed[0]?.results ?? []).map((row) => row.domain.trim().toLowerCase()));
} catch {
return null;
}
}
/** Mirrors src/telegram/registration.ts: two minutes of clock-skew tolerance. */
const TELEGRAM_CLOCK_SKEW_MS = 120_000;
/**
* Reads the four facts that decide whether the Telegram bridge actually delivers:
* the chat it adopted, the origin it learned to register its webhook on, the
* registration it performed, and what Telegram last observed about it. All live
* in runtime_config because the worker observes them -- so a bridge that is
* configured but mute has somewhere to say so, which is exactly what it lacked
* when it sat dead in production for days.
*
* The last pair catches the mute case the first pair cannot see: a webhook on the
* right URL registered with a secret Telegram's updates no longer match, which
* answers 401 on every delivery while every other signal reads healthy.
*/
function checkTelegramBridge(): Check {
let rows: Array<{ key: string; value: string }>;
try {
const raw = execFileSync(
"pnpm",
[
"wrangler",
"d1",
"execute",
"INDEX_DB",
"--remote",
"--json",
"--command",
"SELECT key, value FROM runtime_config WHERE key IN ('telegram.chat_id', 'deployment.origin', 'telegram.webhook_registration', 'telegram.webhook_observation')",
...(targetEnv ? ["--env", targetEnv] : []),
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
const parsed = JSON.parse(raw.slice(raw.indexOf("["))) as Array<{
results: Array<{ key: string; value: string }>;
}>;
rows = parsed[0]?.results ?? [];
} catch {
return {
id: "cloud.telegram",
status: "warn",
message: "TELEGRAM_BOT_TOKEN is set but runtime_config could not be read.",
fix: `pnpm d1:migrate:${targetEnv ?? "prod"} — the runtime_config table may be missing.`,
};
}
const byKey = new Map(rows.map((row) => [row.key, row.value]));
// The live pairing code, if the bridge is still waiting for its first operator.
// `strftime` and not `datetime('now')`: expires_at is written as toISOString(), and
// the two formats differ at character 11 (T vs space) where the space sorts lower —
// with datetime() every code would look unexpired forever.
let pairingCode: string | null = null;
let operatorCount = 0;
try {
const rawOwners = execFileSync(
"pnpm",
[
"wrangler",
"d1",
"execute",
"INDEX_DB",
"--remote",
"--json",
"--command",
"SELECT (SELECT COUNT(*) FROM owner_identities WHERE kind = 'telegram') AS operators, (SELECT code FROM owner_pairing_codes WHERE consumed_at IS NULL AND expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now') ORDER BY created_at DESC LIMIT 1) AS code",
...(targetEnv ? ["--env", targetEnv] : []),
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
const parsedOwners = JSON.parse(rawOwners.slice(rawOwners.indexOf("["))) as Array<{
results: Array<{ operators: number; code: string | null }>;
}>;
const row = parsedOwners[0]?.results?.[0];
operatorCount = row?.operators ?? 0;
pairingCode = row?.code ?? null;
} catch {
// Registry unreadable (migration 0012 not applied yet); the checks below still
// report what they can rather than failing the whole doctor run.
}
const missing: string[] = [];
if (!byKey.get("deployment.origin")) {
missing.push("no deployment origin recorded (load the authenticated UI once)");
}
if (operatorCount === 0) {
missing.push(
pairingCode
? `no operator linked — send /start ${pairingCode} to the bot`
: "no operator linked (the hourly cron mints a pairing code; re-run once it has)",
);
} else if (!byKey.get("telegram.chat_id")) {
missing.push("no chat adopted (send /start to the bot from a linked account)");
}
if (missing.length > 0) {
return {
id: "cloud.telegram",
status: "warn",
message: `Telegram bridge is on but not delivering: ${missing.join("; ")}.`,
fix: "Both are one-time actions; the hourly cron registers the webhook afterwards.",
};
}
const registration = parseJsonValue(byKey.get("telegram.webhook_registration"));
const observation = parseJsonValue(byKey.get("telegram.webhook_observation"));
const registeredAt = Date.parse(String(registration?.registeredAt ?? ""));
const lastErrorAt = Date.parse(String(observation?.lastErrorAt ?? ""));
// Only the dates are compared; the message is quoted, never parsed -- Telegram
// owns that wording and may reword it whenever it likes.
if (
Number.isFinite(registeredAt) &&
Number.isFinite(lastErrorAt) &&
lastErrorAt > registeredAt + TELEGRAM_CLOCK_SKEW_MS
) {
return {
id: "cloud.telegram",
status: "warn",
message: `Telegram is failing to deliver webhook updates since ${observation?.lastErrorAt}: ${observation?.lastErrorMessage ?? "no message reported"} (${observation?.pendingUpdateCount ?? 0} update(s) pending).`,
fix: "The hourly cron re-registers the webhook; if the error persists, check the Telegram webhook URL and secret.",
};
}
return {
id: "cloud.telegram",
status: "pass",
message: `Telegram bridge delivers to chat ${byKey.get("telegram.chat_id")} via ${byKey.get("deployment.origin")}.`,
};
}
const TG_HQ_ITEM = "op://Personal/Telegram - Santi HQ bot";
/**
* One field of the tg-hq 1Password item. Env first (TG_HQ_TOPICS, TG_HQ_CHAT_ID),
* as tg-hq itself reads it, for machines without `op`. Only the non-secret
* fields are ever read here; the bot token (`credential`) never is.
*/
function readTgHqField(field: "topics" | "chat_id"): { value: string } | { skip: string } {
const fromEnv = process.env[field === "topics" ? "TG_HQ_TOPICS" : "TG_HQ_CHAT_ID"];
if (fromEnv?.trim()) return { value: fromEnv.trim() };
try {
const value = execFileSync("op", ["read", `${TG_HQ_ITEM}/${field}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
}).trim();
return { value };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { skip: "the 1Password CLI (`op`) is not installed" };
if (code === "ETIMEDOUT") return { skip: "`op` did not answer in 30 s (1Password locked?)" };
return {
skip: `could not read "${field}" from the tg-hq 1Password item (missing, or op signed out)`,
};
}
}
/**
* `cloud.telegram-topics`: the tg-hq registry of the owner's HQ forum against
* D1 `telegram_topics` for the bound chat. Read-only on both sides, and it never
* calls Telegram — the Bot API's only topic probe is `editForumTopic`, which
* renames the topic it touches.
*/
function checkTelegramTopicsRemote(): Check[] {
const id = "cloud.telegram-topics";
const topicsField = readTgHqField("topics");
if ("skip" in topicsField) {
return [{ id, status: "info", message: `Skipped: ${topicsField.skip}.` }];
}
const chatField = readTgHqField("chat_id");
if ("skip" in chatField || !chatField.value) {
return [
{
id,
status: "info",
message: `Skipped: ${"skip" in chatField ? chatField.skip : "the tg-hq item has no chat_id"}.`,
},
];
}
const registry = parseTopicRegistry(topicsField.value);
if (!registry.ok) {
return [
{
id,
status: "warn",
message: `tg-hq registry is unreadable: ${registry.error}.`,
fix: "Run `tg-hq topics` to see what tg-hq itself makes of it.",
},
];
}
let bound: string | null;
let mappings: TopicMapping[];
try {
const raw = execFileSync(
"pnpm",
[
"wrangler",
"d1",
"execute",
"INDEX_DB",
"--remote",
"--json",
"--command",
"SELECT value FROM runtime_config WHERE key = 'telegram.chat_id'; SELECT t.chat_id, t.mailbox_id, t.topic_id, m.primary_address FROM telegram_topics t LEFT JOIN mailboxes m ON m.mailbox_id = t.mailbox_id",
...(targetEnv ? ["--env", targetEnv] : []),
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
const parsed = JSON.parse(raw.slice(raw.indexOf("["))) as [
{ results: Array<{ value: string }> },
{
results: Array<{
chat_id: string | number;
mailbox_id: string;
topic_id: number;
primary_address: string | null;
}>;
},
];
bound = parsed[0]?.results?.[0]?.value ?? null;
mappings = (parsed[1]?.results ?? []).map((row) => ({
chatId: String(row.chat_id),
mailboxId: row.mailbox_id,
address: row.primary_address,
topicId: Number(row.topic_id),
}));
} catch {
return [
{
id,
status: "warn",
message: "Could not read runtime_config / telegram_topics from D1.",
fix: `pnpm d1:migrate:${targetEnv ?? "prod"} — the telegram_topics table may be missing.`,
},
];
}
return compareTopicRegistry({
boundChatId: bound,
registryChatId: chatField.value,
registry: registry.topics,
mappings,
}).map((finding) => ({ id, ...finding }));
}
/** Runtime-config JSON values, read leniently: a malformed row is not a diagnosis. */
function parseJsonValue(raw: string | undefined): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** Confirms the Worker's remote auth secret. */
function checkSecretsRemote(): Check[] {
let names: Set<string>;
try {
const secrets = JSON.parse(
execFileSync(
"pnpm",
[
"wrangler",
"secret",
"list",
"--format",
"json",
...(targetEnv ? ["--env", targetEnv] : []),
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
),
) as Array<{ name: string }>;
names = new Set(secrets.map((s) => s.name));
} catch {
return [
{
id: "cloud.secrets",
status: "warn",
message: `Could not list secrets for ${targetEnv ? `env "${targetEnv}"` : "the default Worker"} (deployed?).`,
},
];
}
const out: Check[] = [];
// The bridge is optional, so its absence is a pass, not a warning. What used to
// be invisible -- a bot token set but nothing ever delivered -- is what this
// reports, by reading the state the worker records about itself.
if (names.has("TELEGRAM_BOT_TOKEN")) {
out.push(checkTelegramBridge());
}
const missingAuth = ["BETTER_AUTH_SECRET"].filter((n) => !names.has(n));
out.push(
missingAuth.length === 0
? { id: "cloud.secret.auth", status: "pass", message: "Better Auth secret is set." }
: {
id: "cloud.secret.auth",
status: "warn",
message: `Auth secret(s) missing: ${missingAuth.join(", ")} — the web login cannot issue sessions without it.`,
fix: "openssl rand -base64 32 && pnpm wrangler secret put BETTER_AUTH_SECRET",
},
);
return out;
}
/**
* The web perimeter is now in the worker, so "is the perimeter actually up" is
* two plain HTTP probes instead of an edge-redirect shape: the login page must
* be reachable, and /api/health — deliberately unauthenticated — must answer.
* A 404 on /login or a dead /api/health means the deploy or the route is wrong
* before any question of sessions even arises.
*/
async function checkAuthEndpoints(rawUrl: string): Promise<Check[]> {
const healthUrl = new URL("/api/health", rawUrl).toString();
const loginUrl = new URL("/login", rawUrl).toString();
if (healthUrl.includes(".workers.dev/")) {
return [
{
id: "cloud.auth",
status: "warn",
message: `Auth endpoint check skipped for ${healthUrl}: Reccado's supported public path is a custom domain, not workers.dev.`,
fix: `Attach a custom domain first with pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app.<your-domain>`,
},
];
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const [health, login] = await Promise.all([
fetch(healthUrl, { redirect: "manual", signal: controller.signal }),
fetch(loginUrl, { redirect: "follow", signal: controller.signal }),
]);
const out: Check[] = [];
if (health.status === 200) {
out.push({
id: "cloud.auth.health",
status: "pass",
message: `Unauthenticated ${healthUrl} answers (health is deliberately public).`,
});
} else {
out.push({
id: "cloud.auth.health",
status: "fail",
message: `Unauthenticated ${healthUrl} returned ${health.status} (expected 200).`,
fix: "Check that the Worker is deployed and the /api/health route is reachable.",
});
}
if (login.ok) {
out.push({
id: "cloud.auth.login",
status: "pass",
message: `The login page ${loginUrl} is reachable.`,
});
} else {
out.push({
id: "cloud.auth.login",
status: "fail",
message: `The login page ${loginUrl} returned ${login.status}.`,
fix: "Check that the deployed build includes the /login route (pnpm run build, then redeploy).",
});
}
return out;
} catch {
return [
{
id: "cloud.auth",
status: "warn",
message: `Could not reach ${healthUrl} or ${loginUrl} to check the auth endpoints.`,
fix: "Check the URL and that the Worker is deployed.",
},
];
} finally {
clearTimeout(timeout);
}
}
// --- Cloud (opt-in) ----------------------------------------------------------
//
// Runs here, below every declaration it touches, and not up with the local checks
// where it used to sit: `runQueueList` reads a module-level `let`, and calling it
// from above that declaration threw a TDZ ReferenceError that killed the entire
// process — so `--cloud` diagnosed nothing at all, including the checks that were
// passing before it. A diagnostic that crashes is worse than one that fails.
if (args.cloud === "true") {
try {
const who = execFileSync("pnpm", ["wrangler", "whoami"], { encoding: "utf8" });
const email = who.match(/[\w.+-]+@[\w.-]+/)?.[0];
add({
id: "cloud.auth",
status: "pass",
message: `Authenticated with Cloudflare${email ? ` as ${email}` : ""}.`,
});
} catch {
add({
id: "cloud.auth",
status: "warn",
message: "Not authenticated with Cloudflare (cloud checks skipped).",
fix: "Run `pnpm wrangler login`, then re-run with --cloud.",
});
}
addAll(checkD1Remote());
addAll(checkQueuesRemote());
addAll(checkSendingFeedbackRemote());
addAll(checkSendingDomainsDeclared());
addAll(await checkDmarcRemote());
addAll(checkSecretsRemote());
addAll(checkTelegramTopicsRemote());
if (args.url) {
addAll(await checkAuthEndpoints(args.url));
} else {
add({
id: "cloud.auth",
status: "info",
message:
"Pass --url <deployed-url> to check that the login page and /api/health answer on the deployment.",
});
}
add({
id: "cloud.bindings",
status: "info",
message:
"Exhaustive binding verification (R2/queues/Email Routing wiring) lives in `pnpm verify:cf`.",
});
}
// --- Report ------------------------------------------------------------------
const counts = { pass: 0, warn: 0, fail: 0, info: 0 } as Record<Status, number>;
for (const c of checks) counts[c.status] += 1;
if (asJson) {
console.log(JSON.stringify({ env: envLabel, counts, checks }, null, 2));
} else {
console.log(`\nReccado doctor — config: ${envLabel}\n`);
for (const c of checks) {
console.log(` ${SYMBOL[c.status]} ${c.id.padEnd(24)} ${c.message}`);
if (c.fix && (c.status === "warn" || c.status === "fail")) {
console.log(` → ${c.fix}`);
}
}
console.log(
`\n ${counts.pass} pass · ${counts.warn} warn · ${counts.fail} fail · ${counts.info} info\n`,
);
}
process.exit(counts.fail > 0 ? 1 : 0);