87 lines
2.8 KiB
Python
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]
|