import { AUTH_MODES } from "@/lib/auth-mode"; import { looksLikeDataForSeoKey, MIN_BETTER_AUTH_SECRET_LENGTH, validateTeamDomain, } from "@/shared/selfhost-checks"; // Startup preflight for self-host containers: validate the environment BEFORE // the multi-minute build/boot so misconfiguration fails in seconds with the // exact fix, instead of surfacing minutes later as a generic in-app error. // "fail" aborts startup; "warn" degrades a feature; "info" is orientation. type PreflightLevel = "ok" | "info" | "warn" | "fail"; type PreflightItem = { // Stable identifier shared with /api/health's check map. key: "auth" | "dataforseo" | "gsc" | "ai" | "runtime"; name: string; level: PreflightLevel; message: string; }; type PreflightResult = { items: PreflightItem[]; failed: boolean; }; type EnvRecord = Record; function get(env: EnvRecord, name: string): string | undefined { const value = env[name]?.trim(); return value ? value : undefined; } function checkAuthMode(env: EnvRecord, items: PreflightItem[]): void { const rawMode = get(env, "AUTH_MODE"); if (rawMode && !(AUTH_MODES as readonly string[]).includes(rawMode)) { items.push({ key: "auth", name: "AUTH_MODE", level: "fail", message: `"${rawMode}" is not a valid AUTH_MODE. Valid values: ${AUTH_MODES.join(", ")}.`, }); return; } const mode = rawMode ?? "cloudflare_access"; if (mode === "local_noauth") { items.push({ key: "auth", name: "AUTH_MODE", level: "ok", message: "local_noauth — no auth, single admin user. Do not expose publicly without your own auth in front.", }); return; } if (mode === "hosted") { const missing = [ "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", ].filter((name) => !get(env, name)); items.push( missing.length ? { key: "auth", name: "AUTH_MODE", level: "fail", message: `hosted mode requires ${missing.join(", ")}.`, } : { key: "auth", name: "AUTH_MODE", level: "ok", message: "hosted" }, ); return; } // cloudflare_access (explicit or defaulted) const teamDomain = get(env, "TEAM_DOMAIN"); const policyAud = get(env, "POLICY_AUD"); const modeLabel = rawMode ? "cloudflare_access" : "cloudflare_access (default — AUTH_MODE is unset)"; if (!teamDomain || !policyAud) { const missing = [ teamDomain ? null : "TEAM_DOMAIN", policyAud ? null : "POLICY_AUD", ] .filter(Boolean) .join(" and "); items.push({ key: "auth", name: "AUTH_MODE", level: "fail", message: `${modeLabel} requires ${missing}. See docs/SELF_HOSTING_CLOUDFLARE.md — or set AUTH_MODE=local_noauth for a private, no-auth deployment.`, }); return; } const teamDomainResult = validateTeamDomain(teamDomain); if (!teamDomainResult.ok) { items.push({ key: "auth", name: "TEAM_DOMAIN", level: "fail", message: teamDomainResult.message, }); return; } items.push({ key: "auth", name: "AUTH_MODE", level: "ok", message: modeLabel, }); } function checkDataForSeo(env: EnvRecord, items: PreflightItem[]): void { const key = get(env, "DATAFORSEO_API_KEY"); if (!key) { items.push({ key: "dataforseo", name: "DATAFORSEO_API_KEY", level: "warn", message: "Not set — all SEO data features will be unavailable until it is. It is the base64 of your DataForSEO login:password (NOT the dashboard API key). See docs/DATAFORSEO_API_KEY.md.", }); return; } if (!looksLikeDataForSeoKey(key)) { items.push({ key: "dataforseo", name: "DATAFORSEO_API_KEY", level: "warn", message: "Set, but does not decode as base64 of login:password. If DataForSEO rejects it, encode your account email and API password: printf 'email:password' | base64.", }); return; } items.push({ key: "dataforseo", name: "DATAFORSEO_API_KEY", level: "ok", message: "Set", }); } function checkOptionalFeatures(env: EnvRecord, items: PreflightItem[]): void { const clientId = get(env, "GOOGLE_CLIENT_ID"); const clientSecret = get(env, "GOOGLE_CLIENT_SECRET"); const betterAuthSecret = get(env, "BETTER_AUTH_SECRET"); if (clientId || clientSecret) { if (!clientId || !clientSecret) { items.push({ key: "gsc", name: "Search Console", level: "warn", message: "Only one of GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET is set — both are required.", }); } else if ( !betterAuthSecret || betterAuthSecret.length < MIN_BETTER_AUTH_SECRET_LENGTH ) { items.push({ key: "gsc", name: "Search Console", level: "warn", message: `Google credentials are set, but Search Console stays DISABLED until BETTER_AUTH_SECRET is at least ${MIN_BETTER_AUTH_SECRET_LENGTH} characters (it encrypts stored OAuth tokens).`, }); } else { items.push({ key: "gsc", name: "Search Console", level: "ok", message: "Configured", }); } } else { items.push({ key: "gsc", name: "Search Console", level: "info", message: "Not configured (optional). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md.", }); } items.push( get(env, "OPENROUTER_API_KEY") ? { key: "ai", name: "AI features", level: "ok", message: "OPENROUTER_API_KEY set", } : { key: "ai", name: "AI features", level: "info", message: "OPENROUTER_API_KEY not set (optional) — SAM, the in-app SEO agent, is disabled.", }, ); } // Shared per-feature checks: the Docker preflight prints these at boot and // /api/health (setup-status.ts) serves the same results at runtime, so the // two can never drift. export function runSelfhostChecks(env: EnvRecord): PreflightItem[] { const items: PreflightItem[] = []; checkAuthMode(env, items); checkDataForSeo(env, items); checkOptionalFeatures(env, items); return items; } export function runSelfhostPreflight(env: EnvRecord): PreflightResult { const items = runSelfhostChecks(env); items.push( get(env, "ALLOWED_HOST") ? { key: "runtime", name: "ALLOWED_HOST", level: "ok", message: `Requests allowed for host ${get(env, "ALLOWED_HOST")}`, } : { key: "runtime", name: "ALLOWED_HOST", level: "info", message: 'Not set — only localhost access will work. Behind a reverse proxy or tunnel, set ALLOWED_HOST=yourdomain.com or requests are blocked with Vite\'s "Blocked request" page.', }, ); items.push({ key: "runtime", name: "Scheduled checks", level: "info", message: "Rank-tracking schedules do not run in Docker mode — trigger checks from the Rank Tracking page.", }); return { items, failed: items.some((item) => item.level === "fail") }; } const LEVEL_BADGES: Record = { ok: "[ ok ]", info: "[info]", warn: "[warn]", fail: "[FAIL]", }; export function formatPreflightReport(result: PreflightResult): string { const lines = result.items.map( (item) => `${LEVEL_BADGES[item.level]} ${item.name}: ${item.message}`, ); lines.push( result.failed ? "\nPreflight failed — fix the [FAIL] items above and restart. Nothing was started." : "\nPreflight passed. The app now builds inside the container (~1-2 minutes on first start before it serves).", ); return lines.join("\n"); }