"""Typed application configuration loader. Loads environment variables (with `.env` support for local development) into a strongly-typed :class:`Settings` instance via `pydantic-settings`. The full environment contract lives here so every consumer of configuration can import :func:`get_settings` and get a validated object. Values that aren't needed until later roadmap phases are declared as :data:`Optional` so the app boots cleanly in Phase 0 without mandating yet-unused secrets. """ from __future__ import annotations from functools import lru_cache from typing import Literal, Optional from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Sentinel value for the SECRET_KEY in .env.example. The production # validator below refuses to start the app if this sentinel survives into # a production deployment. Keeping it as a named constant makes the # security control explicit and searchable. _DEV_SECRET_KEY_SENTINEL: str = "dev-insecure-change-me" class Settings(BaseSettings): """Strongly-typed runtime configuration. Populated from process environment variables and (for local dev) an optional `.env` file. All fields correspond 1:1 with the env-var contract documented in ``docs/ROADMAP.md`` and ``.env.example``. """ # Pydantic-settings config: read from .env when present, ignore unknown # keys (so adding new vars to .env ahead of code changes is non-fatal), # and treat env var names case-insensitively. model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # --- Runtime mode ------------------------------------------------------ app_env: Literal["development", "production"] = Field( default="development", description="Runtime mode; gates debug behavior and logging renderer.", ) # --- Signing / sessions ----------------------------------------------- # Used by itsdangerous for signed cookies and CSRF tokens. The default # is intentionally a well-known sentinel so local dev just works; the # model validator below blocks it from reaching production. secret_key: str = Field( default=_DEV_SECRET_KEY_SENTINEL, description="itsdangerous signer key; MUST be overridden in production.", ) # --- Database ---------------------------------------------------------- database_url: str = Field( default="sqlite:///data/app.db", description="SQLAlchemy URL for the application database.", ) # --- Email (Resend) ---------------------------------------------------- # Optional at Phase 0: contact form and magic-link delivery come online # in Phases 3 and 5 respectively. resend_api_key: Optional[str] = Field(default=None) resend_from: Optional[str] = Field(default=None) # --- Admin allowlist / contact routing --------------------------------- # Comma-separated list; stored raw so we preserve exact input for audit, # and split on demand via the `admin_emails_list` property. admin_emails: str = Field( default="", description="Comma-separated allowlist of admin email addresses.", ) admin_contact_email: Optional[str] = Field(default=None) # --- hCaptcha ---------------------------------------------------------- hcaptcha_site_key: Optional[str] = Field(default=None) hcaptcha_secret: Optional[str] = Field(default=None) # --- Reverse proxy ----------------------------------------------------- # Only requests whose peer IP is in this list will have their # X-Forwarded-* headers trusted by Uvicorn's proxy-headers support. forwarded_allow_ips: str = Field(default="127.0.0.1") # --- Session / auth tuning -------------------------------------------- session_max_days: int = Field(default=30, ge=1, le=365) magic_link_ttl_min: int = Field(default=15, ge=1, le=60) # --- Build metadata ---------------------------------------------------- # Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so # operators can confirm which build is live. git_commit_sha: str = Field(default="unknown") # ---------------------------------------------------------------------- # Derived helpers # ---------------------------------------------------------------------- @property def admin_emails_list(self) -> list[str]: """Return the admin allowlist as a clean list of lowercase strings. Splits ``admin_emails`` on commas, strips whitespace, drops empty tokens, and lowercases — matching typical email canonicalization for allowlist comparisons. The raw string is preserved on the instance for audit / diagnostic purposes. """ return [ part.strip().lower() for part in self.admin_emails.split(",") if part.strip() ] # ---------------------------------------------------------------------- # Validators # ---------------------------------------------------------------------- @model_validator(mode="after") def _refuse_dev_secret_in_production(self) -> "Settings": """Fail fast if the dev sentinel SECRET_KEY reaches production. Security control: prevents accidental deployment with the publicly-documented placeholder secret, which would make every signed cookie / CSRF token forgeable by anyone with the source. """ if ( self.app_env == "production" and self.secret_key == _DEV_SECRET_KEY_SENTINEL ): raise ValueError( "SECRET_KEY must be overridden in production; " "the dev sentinel value is not permitted." ) return self @lru_cache(maxsize=1) def get_settings() -> Settings: """Return a process-wide cached :class:`Settings` instance. Using :func:`functools.lru_cache` ensures the environment is parsed exactly once per process, which is both cheaper and — more importantly — guarantees every caller sees the same validated values. Tests that need a fresh parse can call ``get_settings.cache_clear()``. """ return Settings()