Files
plm-lottery/app/main.py
T
davideandClaude Sonnet 5.5 575f1a5354 Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00

225 lines
9.7 KiB
Python

import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.logging_config import setup_logging
setup_logging()
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
from app.api.routes.admin import router as admin_router
from app.api.routes.bets import router as bets_router
from app.api.routes.bug_reports import router as bug_reports_router
from app.api.routes.qr import router as qr_router
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.api.errors import ApiError
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
from app.db.base import AsyncSessionLocal
from app.deposits.reconcile import DepositReconciler
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
from app.electrum.listener import ElectrumListener
from app.rounds.scheduler import RoundScheduler
from app.rounds.pause import pause_by_operator
from app.tx.broadcast import RbfBumper
from app.tx.confirmation import ConfirmationPoller
from app.tx.locks import UserLocks
from app.tx.reconcile import PendingTransactionReconciler
from app.wallet.verify import verify_master_key
logger = logging.getLogger(__name__)
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Refuses to serve rather than starting up half-configured (B-15).
validate_runtime_secrets()
endpoints = parse_endpoints(
settings.electrum_host,
settings.electrum_port,
settings.electrum_use_ssl,
settings.electrum_fallback_servers,
settings.electrum_cert_pin,
)
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses
if settings.start_paused: # B-90 (OPS-27): before the scheduler exists, so no round can open first
async with AsyncSessionLocal() as session:
await pause_by_operator(session, "START_PAUSED")
await session.commit()
logger.warning("START_PAUSED=true: the lottery starts paused (resume it from /admin)")
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
listener = ElectrumListener(
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
)
app.state.electrum_listener = listener
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener)
# Periodic safety net for deposit crediting/external-spend detection,
# independent of scripthash-change notifications — catches a subscription
# silently lost on an otherwise healthy connection (B-30).
deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener)
tasks = [
asyncio.create_task(listener.run()),
asyncio.create_task(scheduler.run()),
asyncio.create_task(poller.run()),
asyncio.create_task(bumper.run()),
asyncio.create_task(reconciler.run()),
asyncio.create_task(deposit_reconciler.run()),
]
try:
yield
finally:
for task in tasks:
task.cancel()
if listener.client is not None:
await listener.client.close()
# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin
# endpoints included, to anyone who requests them (B-42) — disabled unless
# ENABLE_API_DOCS is explicitly set, which should only happen in development.
app = FastAPI(
title="PLM Lottery",
lifespan=lifespan,
docs_url="/docs" if settings.enable_api_docs else None,
redoc_url="/redoc" if settings.enable_api_docs else None,
openapi_url="/openapi.json" if settings.enable_api_docs else None,
)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(bets_router)
app.include_router(withdrawals_router)
app.include_router(admin_router)
app.include_router(bug_reports_router)
app.include_router(qr_router)
app.include_router(rounds_router)
# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's
# own 401/404/405 and request-validation failures take the structured shape too.
_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"}
@app.exception_handler(StarletteHTTPException)
async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
if isinstance(exc.detail, dict) and "code" in exc.detail:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}")
detail = ApiError(code, str(exc.detail)).as_detail()
return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers)
@app.exception_handler(RequestValidationError)
async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
"""`input` is left out on purpose: an over-long password must not come back in the response."""
errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail()
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail})
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
"""Answers with the same structured `detail` shape as every deliberate failure
(app/api/errors.py) so clients never have to special-case unexpected errors.
The exception itself stays in logs/app.log only — never in the response body."""
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
READY_MAX_HEADER_AGE_SECONDS = 1800
READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
@app.get("/health/ready", include_in_schema=False)
async def health_ready() -> JSONResponse:
listener = getattr(app.state, "electrum_listener", None)
scheduler = getattr(app.state, "scheduler", None)
header_age = listener.last_header_age_seconds() if listener is not None else None
heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
checks = {
"electrum_connected": listener is not None and listener.client is not None,
"last_header_age_seconds": header_age,
"scheduler_heartbeat_age_seconds": heartbeat_age,
}
ready = (
checks["electrum_connected"]
and header_age is not None
and header_age < READY_MAX_HEADER_AGE_SECONDS
and heartbeat_age is not None
and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
)
return JSONResponse(
status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
)
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
@app.get("/", include_in_schema=False)
async def index_page() -> FileResponse:
return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS)
@app.get("/admin", include_in_schema=False)
async def admin_panel() -> FileResponse:
return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS)
@app.get("/guida", include_in_schema=False)
async def user_guide() -> FileResponse:
return FileResponse("app/static/guida.html", headers=_NO_STORE_HEADERS)
@app.get("/report-bug", include_in_schema=False)
async def report_bug_page() -> FileResponse:
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
class NoCacheStaticFiles(StaticFiles):
"""B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an
update it does not run an old app.js against the new API."""
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-cache"
return response
app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static")