Refuse to start half-configured, and keep failures machine-readable

Startup validation (B-15). An empty JWT_SECRET makes PyJWT raise InvalidKeyError
on every login and an empty XPRV_ENCRYPTION_KEY makes Fernet fail on the first
key derivation — either way the container came up looking healthy and broke the
moment a real user touched it. validate_runtime_secrets() reports every problem
at once and is called from the lifespan (wired in the next commit).

Deviation from the plan in BUGS.md, which proposed a Pydantic field_validator:
Settings is constructed at import time by every module that reads config,
including the test suite, which has no .env and no business holding real secrets
— a validator there would fail a fresh clone at collection. At startup the
guarantee that matters is unchanged (the server won't serve traffic
half-configured) without coupling imports to a gitignored file. An empty
ADMIN_TOKEN is deliberately non-fatal: require_admin already denies everything,
so the effect is a locked panel, not an open one.

Unhandled errors answer the documented shape (B-24). The catch-all returned a
bare-string `detail` while app/api/errors.py documents
{"code", "message", "params"}, leaving clients to special-case exactly the
responses they understand least. It now returns internal_error in that shape,
with the exception text staying in logs/app.log and out of the response body.

GET /guida no longer crashes (B-16). It reads docs/guida-utente.md, which the
Dockerfile doesn't ship, so in every real deployment that navbar link was a 500 —
confirmed in the deployed log, which holds two of them from earlier today ending
in "RuntimeError: File at path docs/guida-utente.md does not exist." It now
checks the file and answers a structured 404 (guide_unavailable) with an error
logged. Shipping docs/ in the image was written and then reverted on request: the
guide is being reworked first, so /guida answers 404 in Docker for now, which is
an accepted state rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:34:33 +02:00
co-authored by Claude Opus 5
parent 85dce221c5
commit 25f4a1c6b6
3 changed files with 113 additions and 5 deletions
+42
View File
@@ -1,5 +1,10 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
# than being shipped by accident.
MIN_JWT_SECRET_LENGTH = 32
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -25,3 +30,40 @@ class Settings(BaseSettings):
settings = Settings()
class ConfigError(Exception):
"""A misconfiguration serious enough that the app must refuse to serve."""
def validate_runtime_secrets(config: Settings | None = None) -> None:
"""Fail fast on secrets that would otherwise only break at first use: an empty
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
the container comes up looking healthy and breaks the moment a real user
touches it.
Called from the app's lifespan (app/main.py) rather than as a Settings
field_validator on purpose: Settings is constructed at import time by every
module that reads config, including the test suite, which has no .env and no
business holding real secrets. At startup the guarantee still holds where it
matters — the server refuses to serve half-configured — without coupling every
import to a gitignored file.
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
request when it's empty, so the effect is a locked admin panel, not an open one.
"""
config = config or settings
problems = []
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
problems.append(
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
)
if not config.xprv_encryption_key.strip():
problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
)
if problems:
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
+20 -5
View File
@@ -1,8 +1,9 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -20,7 +21,8 @@ from app.api.routes.rounds import router as rounds_router
from app.api.routes.users import router as users_router
from app.api.routes.withdrawals import router as withdrawals_router
from app.auth.routes import router as auth_router
from app.config import settings
from app.api.errors import ApiError, http_error
from app.config import settings, validate_runtime_secrets
from app.db.base import AsyncSessionLocal
from app.electrum.client import ElectrumClient
from app.electrum.listener import ElectrumListener
@@ -74,7 +76,10 @@ app.include_router(rounds_router)
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "internal server error"})
return JSONResponse(
status_code=500,
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
)
@app.get("/health")
@@ -99,8 +104,18 @@ async def admin_panel() -> FileResponse:
async def user_guide() -> FileResponse:
"""Serves docs/guida-utente.md as plain text so it opens inline in the
browser (no markdown rendering — keeps this simple), linked from the
navbar's help button in app/static/index.html."""
return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8")
navbar's help button in app/static/index.html.
The Dockerfile must COPY docs/ for this to exist inside the container (it
didn't, which made this a guaranteed 500 in every real deployment — B-16).
Answers a clean 404 if the file is missing rather than an unhandled error."""
if not _USER_GUIDE_PATH.is_file():
logger.error("user guide missing at %s (is docs/ shipped in the image?)", _USER_GUIDE_PATH)
raise http_error(status.HTTP_404_NOT_FOUND, "guide_unavailable", "user guide not available")
return FileResponse(_USER_GUIDE_PATH, media_type="text/plain; charset=utf-8")
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
_USER_GUIDE_PATH = Path("docs/guida-utente.md")
+51
View File
@@ -0,0 +1,51 @@
"""B-15: secrets that would only break at first use must stop the app at startup."""
import pytest
from app.config import MIN_JWT_SECRET_LENGTH, ConfigError, Settings, validate_runtime_secrets
def _settings(**overrides) -> Settings:
base = {
"jwt_secret": "x" * MIN_JWT_SECRET_LENGTH,
"xprv_encryption_key": "a-fernet-key",
}
base.update(overrides)
# _env_file=None so a developer's real .env can't make this test pass or fail.
return Settings(_env_file=None, **base)
def test_valid_secrets_pass():
validate_runtime_secrets(_settings())
def test_empty_jwt_secret_is_refused():
"""An empty JWT_SECRET makes PyJWT raise InvalidKeyError on every single login —
a 500 with no hint about the real cause, on a container that started up healthy."""
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret=""))
def test_short_jwt_secret_is_refused():
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret="x" * (MIN_JWT_SECRET_LENGTH - 1)))
def test_empty_xprv_encryption_key_is_refused():
"""Without it Fernet fails on the first key derivation — i.e. the first time
anyone registers or a transaction needs signing."""
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY"):
validate_runtime_secrets(_settings(xprv_encryption_key=" "))
def test_all_problems_are_reported_at_once():
with pytest.raises(ConfigError) as exc_info:
validate_runtime_secrets(_settings(jwt_secret="", xprv_encryption_key=""))
message = str(exc_info.value)
assert "JWT_SECRET" in message and "XPRV_ENCRYPTION_KEY" in message
def test_an_empty_admin_token_is_not_fatal():
"""require_admin already denies every request when it's unset, so the effect is a
locked admin panel rather than an open one — no reason to refuse to boot."""
validate_runtime_secrets(_settings(admin_token=""))