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.
150 lines
6.1 KiB
Python
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()
|