"""Validate a prod `.env` file before (re)deploying. Usage (from backend/ or repo root): uv run python scripts/check_prod.py --env ../.env uv run python scripts/check_prod.py # defaults to repo-root .env Fails (exit 1) on missing/weak secrets and insecure modes; warns on soft misconfigurations. Safe to run anywhere: reads one file, no imports from app (stdlib only), prints no secret values. """ from __future__ import annotations from pathlib import Path import typer BACKEND = Path(__file__).resolve().parent.parent REPO_ROOT = BACKEND.parent MIN_TOKEN = 32 MIN_PASSWORD = 16 MIN_API_KEY = 16 def parse_env(path: Path) -> dict[str, str]: values: dict[str, str] = {} for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip().strip("'\"") if key.startswith("export "): key = key[len("export ") :].strip() values[key] = value return values def check(values: dict[str, str]) -> tuple[list[str], list[str]]: """(failures, warnings) for a parsed env file.""" failures: list[str] = [] warnings: list[str] = [] def secret(name: str, minimum: int, what: str) -> None: value = values.get(name, "") if not value: failures.append(f"{name} is empty — generate one: openssl rand -hex 32") elif len(value) < minimum: failures.append(f"{name} too short ({len(value)} < {minimum} chars)") def placeholder(name: str, prefixes: tuple[str, ...]) -> None: value = values.get(name, "") if value and any(value == p or value.startswith(p) for p in prefixes): failures.append(f"{name} still holds an example placeholder") # Shared + service API keys. secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token") secret("JWT_SECRET", MIN_TOKEN, "session JWT secret") secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key") placeholder("LANGFLOW_API_KEY", ("sk-",)) secret("LFX_API_KEY", MIN_API_KEY, "lfx api key") placeholder("LFX_API_KEY", ("lfx-",)) # Langflow's own auth (internet-facing authoring UI). secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password") secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key") # Insecure modes. if values.get("FRONTEND_MODE", "preview") == "dev": failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod") if values.get("APP_ENV", "prod") == "dev": failures.append("APP_ENV=dev enables autoreload — unset it in prod") if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true": failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access") # Soft misconfigurations. if not values.get("ADMIN_EMAILS", ""): warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account") if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""): warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail") if not values.get("CONTACT_TO", ""): warnings.append("CONTACT_TO empty — access-request notifications go nowhere") frontend_url = values.get("FRONTEND_URL", "") if frontend_url and not frontend_url.startswith("https://"): warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext") return failures, warnings def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> None: """Validate a prod .env file (typer CLI; check/parse_env stay importable).""" path = Path(env) if not path.is_file(): print(f"no env file at {path}") raise typer.Exit(1) failures, warnings = check(parse_env(path)) for message in warnings: print(f"WARN: {message}") for message in failures: print(f"FAIL: {message}") if failures: print(f"{len(failures)} blocking issue(s) — fix before deploying") raise typer.Exit(1) print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else "")) if __name__ == "__main__": typer.run(main)