Files
chicken_babies_site/app/config.py
Phillip Tarrant 78dd1ac243 feat: phase 0 foundation — FastAPI scaffold + /healthz
Scaffold app/ package, pinned requirements.txt, multi-stage Dockerfile,
docker-compose.yml, and .env.example. Add typed pydantic-settings loader
with full env contract and a production validator that refuses the
dev-sentinel SECRET_KEY. Wire structlog with an APP_ENV-driven renderer
(console in dev, JSON in prod). Ship a minimal unauthenticated /healthz
returning {status, version, commit_sha} with commit SHA fed through a
GIT_COMMIT_SHA build arg.

Also mark Phase 0 complete in docs/ROADMAP.md.
2026-04-21 14:44:41 -05:00

150 lines
6.1 KiB
Python

"""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()