"""Runtime configuration loaded from environment variables.""" from __future__ import annotations from typing import Literal from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Sentinel values that .env.example ships with. If the runtime is configured # in a non-dev environment with one of these still in place, we refuse to boot. _DEV_JWT_SECRET = "change-me-to-a-long-random-string" _DEV_DB_PASSWORD = "change-me-strong" class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore", ) # === Runtime mode === # Set to "dev" to allow the default placeholder secrets. Anything else # (e.g. "prod", "staging") forces strong values. APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod" # === Postgres === POSTGRES_DB: str = "metamorph" POSTGRES_USER: str = "metamorph" POSTGRES_PASSWORD: str = "" POSTGRES_HOST: str = "db" POSTGRES_PORT: int = 5432 # === API === JWT_SECRET: str = Field(default="", min_length=0) LOG_LEVEL: str = "INFO" FRONT_ORIGIN: str = "http://localhost:8080" EVIDENCE_DIR: str = "/data/evidence" @property def cors_origins(self) -> list[str]: return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()] @property def database_url(self) -> str: """SQLAlchemy URL using the psycopg3 driver.""" return ( f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" ) @model_validator(mode="after") def _enforce_secret_strength(self) -> "Settings": """Refuse to boot in prod/staging if secrets are missing or default. `dev` and `test` are explicitly exempted so workstations and the ephemeral test container don't need real secrets. """ if self.APP_ENV in ("dev", "test"): return self if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32: raise ValueError( "JWT_SECRET is missing, default, or shorter than 32 chars. " "Set APP_ENV=dev to bypass for local development." ) if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD: raise ValueError( "POSTGRES_PASSWORD is missing or default. " "Set APP_ENV=dev to bypass for local development." ) return self settings = Settings()