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 # Additional servers to fall back to, comma-separated `host:port[:notls]`. # The listener rotates over primary + these (app/electrum/listener.py), so one # unreachable server costs a single reconnect attempt instead of an outage: # every deposit credit, broadcast and confirmation goes through this one # connection, which makes a single hardcoded server the platform's biggest # single point of failure. Parsed by electrum.client.parse_endpoints. electrum_fallback_servers: str = "" 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))