"""Application configuration via Pydantic-Settings v2. Loads from .env file and environment variables. Hard-fails if AUTH_SECRET is missing or shorter than 32 characters (per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback). """ from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings loaded from environment.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # === Database === DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db" # === JWT / Auth === # NO DEFAULT — hard-fail if missing (R-5) AUTH_SECRET: str = Field(..., min_length=32) JWT_ALGORITHM: str = "HS256" JWT_EXPIRY_HOURS: int = 24 # === Password Hashing === BCRYPT_ROUNDS: int = 12 # === CORS === # Comma-separated list of allowed origins, NO wildcards CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000" # === Runtime === ENVIRONMENT: Literal["development", "production", "test"] = "development" LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" @field_validator("AUTH_SECRET") @classmethod def validate_auth_secret(cls, v: str) -> str: """Ensure AUTH_SECRET is at least 32 characters and not a known dev default.""" if len(v) < 32: raise ValueError( f"AUTH_SECRET must be at least 32 characters (got {len(v)}). " "Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'" ) # Reject obvious placeholders lowered = v.lower() if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered: raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.") return v @property def cors_origins_list(self) -> list[str]: """Parse CORS_ORIGINS into a list of trimmed, non-empty origins.""" return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] @property def is_production(self) -> bool: """Check if running in production mode.""" return self.ENVIRONMENT == "production" @property def is_test(self) -> bool: """Check if running in test mode.""" return self.ENVIRONMENT == "test" @property def jwt_expiry_seconds(self) -> int: """JWT expiry in seconds (for response `expires_in` field).""" return self.JWT_EXPIRY_HOURS * 3600 @lru_cache(maxsize=1) def get_settings() -> Settings: """Cached settings instance.""" return Settings() # type: ignore[call-arg]