Files

87 lines
2.8 KiB
Python

"""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]