diff --git a/.env.example b/.env.example index 2a85985..d9d8c6b 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ API_KEY= # API_KEY= # Docker Compose sets these automatically for the frontend container. -# ── Rate limiting ───────────────────────────────────────────────────────────── +# ── CORS (production) ───────────────────────────────────────────────────────── +# Comma-separated or single URL. Example: +# ALLOWED_ORIGINS=https://autodev-one.vercel.app +# Or JSON: ALLOWED_ORIGINS=["https://autodev-one.vercel.app"] + RATE_LIMIT_ENABLED=true RATE_LIMIT_DEFAULT=120/minute diff --git a/backend/app/config.py b/backend/app/config.py index b9cac02..9a57f14 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -2,11 +2,26 @@ Centralised configuration — all secrets come from environment variables. Copy .env.example → .env and fill in your values. """ +import json from functools import lru_cache -from typing import List +from typing import List, Union + +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +def _normalize_redis_url(url: str) -> str: + """Railway/Heroku often provide redis://; keep as-is (redis client accepts it).""" + return url + + +def _normalize_database_url(url: str) -> str: + """Railway provides postgres://; SQLAlchemy needs postgresql://.""" + if url.startswith("postgres://"): + return "postgresql://" + url[len("postgres://") :] + return url + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") @@ -15,6 +30,16 @@ class Settings(BaseSettings): # ── Database ────────────────────────────────────────────────────────────── DATABASE_URL: str = "postgresql://autodev:autodev@localhost:5432/autodev" + @field_validator("DATABASE_URL", mode="before") + @classmethod + def normalize_database_url(cls, v: str) -> str: + return _normalize_database_url(v) + + @field_validator("REDIS_URL", "CELERY_BROKER_URL", "CELERY_RESULT_BACKEND", mode="before") + @classmethod + def normalize_redis_urls(cls, v: str) -> str: + return _normalize_redis_url(v) + # ── Redis / Celery ──────────────────────────────────────────────────────── REDIS_URL: str = "redis://localhost:6379/0" CELERY_BROKER_URL: str = "redis://localhost:6379/0" @@ -43,8 +68,24 @@ class Settings(BaseSettings): REPOS_BASE_PATH: str = "/tmp/autodev_repos" # ── CORS ────────────────────────────────────────────────────────────────── + # Env: comma-separated URLs, JSON array, or a single URL (Railway-friendly). ALLOWED_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] + @field_validator("ALLOWED_ORIGINS", mode="before") + @classmethod + def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]: + if isinstance(v, list): + return v + if not isinstance(v, str) or not v.strip(): + return [] + raw = v.strip() + if raw.startswith("["): + parsed = json.loads(raw) + if not isinstance(parsed, list): + raise ValueError("ALLOWED_ORIGINS JSON must be an array of strings") + return [str(item).strip() for item in parsed if str(item).strip()] + return [part.strip() for part in raw.split(",") if part.strip()] + # ── Logging ─────────────────────────────────────────────────────────────── LOG_LEVEL: str = "INFO" LOG_FORMAT: str = "json" # "json" | "console" diff --git a/backend/app/db_migrate.py b/backend/app/db_migrate.py index 8921dc9..08c63ab 100644 --- a/backend/app/db_migrate.py +++ b/backend/app/db_migrate.py @@ -18,7 +18,13 @@ def _run_alembic(*args: str) -> None: def bootstrap() -> None: - engine = create_engine(settings.DATABASE_URL) + db_url = settings.DATABASE_URL + if settings.ENV == "production" and ("localhost" in db_url or "127.0.0.1" in db_url): + raise RuntimeError( + "DATABASE_URL points to localhost in production. On Railway, reference your " + "Postgres service variable (DATABASE_URL=${{Postgres.DATABASE_URL}}) and redeploy." + ) + engine = create_engine(db_url) tables = set(inspect(engine).get_table_names()) if "alembic_version" in tables: