diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..1953f38 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,86 @@ +"""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]