278 lines
7.7 KiB
TypeScript
278 lines
7.7 KiB
TypeScript
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<string, string | undefined>;
|
|
|
|
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<PreflightLevel, string> = {
|
|
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");
|
|
}
|