Files
plm-lottery/app/config.py
T
davideandClaude Opus 5 25f4a1c6b6 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>
2026-07-27 00:34:33 +02:00

70 lines
2.9 KiB
Python

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")
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net"
electrum_port: int = 50002
electrum_use_ssl: bool = True
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
jwt_secret: str = ""
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
admin_token: str = ""
# Every business/round parameter (bet amount, round duration/cooldown,
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
# editable live via the admin panel/API, no env var, no restart. Only true
# infra/secrets belong in this Settings class.
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))