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.
This commit is contained in:
149
app/config.py
Normal file
149
app/config.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user