Upload app/core/config.py
This commit is contained in:
@@ -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]
|
||||
Reference in New Issue
Block a user