Files
plm-lottery/docs/api.md
T
davideandClaude Sonnet 5.5 7ba8e22bb4 Real SQLite transactions: BEGIN IMMEDIATE and synchronous=FULL (B-88, DAT-74, DAT-52, ARC-07, TX-28, BET-11, DAT-16)
pysqlite's legacy mode emitted BEGIN only at the first DML, so every read
before it ran outside any transaction and a lock wait surfaced as "database is
locked" at an arbitrary later statement; place_bet also mistook that error for
a write-snapshot conflict, which SQLite cannot produce.

- _register_sqlite_pragmas: synchronous=FULL, isolation_level=None and a
  `begin` event that emits BEGIN / BEGIN IMMEDIATE.
- begin_immediate(session): takes the write lock before the first read; called
  first thing in place_bet, request_withdrawal, the scheduler transitions,
  _trigger_payout, the confirmation poller, the reconciler, bump_fee's persist
  phase, deposit crediting / external-spend marking / reinstating, and the
  derivation-index allocation at registration.
- A lock not granted before a broadcast answers service_busy (503, all seven
  languages), nothing built or sent; after a broadcast nothing is undone, the
  rows stay `building` for the reconciler.
- Spec, storico, BUGS.md and CLAUDE.md updated; 37 new tests on a real file DB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 16:26:01 +02:00

65 KiB
Raw Blame History

API HTTP

Specifica dell'interfaccia HTTP di PLM Lottery: convenzioni comuni, contratto degli errori, ogni endpoint esposto e il catalogo completo dei codici di errore. Descrive la forma delle richieste e delle risposte; il comportamento di dominio che un endpoint innesca (come si costruisce una scommessa, come si chiude un round…) è specificato nel documento del suo argomento, a cui ogni sezione rimanda:

Argomento Documento
Registrazione, depositi, saldo mostrato registrazione-e-depositi.md
Scommesse e prelievi scommesse-e-prelievi.md
Round, estrazione, pagamento ciclo-di-vita-del-round.md
Ciclo di vita delle transazioni, RBF transazioni-e-riconciliazione.md
Chiavi, JWT, limiti di frequenza, header di sicurezza sicurezza.md
Eventi del registro di audit audit-log.md
Tabelle e campi modello-dati.md
Frontend, canale SSE lato architettura, i18n architettura.md

Le due interfacce web (/ e /admin) parlano con questa stessa API JSON: non esiste una API separata per l'amministrazione, solo un'autenticazione diversa.

Panoramica degli endpoint

Metodo Percorso Autenticazione Requisito
POST /auth/register nessuna API-18
POST /auth/login nessuna API-19
GET /users/me Bearer API-20
POST /users/me/change-password Bearer API-21
GET /users/me/last-round-result Bearer API-22
POST /bets Bearer API-23
POST /withdrawals Bearer API-24
GET /rounds/current facoltativa API-25
GET /rounds/stream nessuna API-26
GET /rounds/{round_id}/proof nessuna API-45 — da implementare (B-73)
GET /qr/{address} nessuna API-27
POST /bug-reports facoltativa API-28
GET /bug-reports/mine Bearer API-29
GET /admin/config admin API-30
PUT /admin/config admin API-31
POST /admin/pause admin API-32
POST /admin/resume admin API-32
GET /admin/users admin API-33
GET /admin/users/{user_id}/privkey admin API-34
POST /admin/users/{user_id}/reset-password admin API-35
GET /admin/rounds admin API-36
GET /admin/audit-log admin API-37
GET /admin/pending-transactions admin API-38
GET /admin/bug-reports admin API-39
POST /admin/bug-reports/{report_id}/status admin API-40
GET /admin/electrum admin API-47 — da implementare (B-86, B-92)
GET /health nessuna API-41
GET /health/ready nessuna API-46 — da implementare (B-92)
GET /, /admin, /guida, /report-bug nessuna API-42
GET qualunque altro percorso (file statici) nessuna API-43

Convenzioni generali

  • [API-01] L'API è servita dallo stesso processo e sulla stessa origine delle interfacce web: in produzione https://<SITE_ADDRESS>/… attraverso Caddy (che termina TLS e inoltra al container app sulla porta interna 8123), senza prefisso di versione (/v1…) e senza CORS — non è installato alcun middleware CORS, quindi un browser può chiamarla solo dalla stessa origine. I percorsi sono quelli della tabella sopra, alla radice. Codice: app/main.py (include_router), Caddyfile, Dockerfile

  • [API-02] Corpi di richiesta e di risposta sono JSON (application/json), tranne tre eccezioni: GET /qr/{address} risponde image/png, GET /rounds/stream risponde text/event-stream, e le pagine/i file statici rispondono con il loro tipo. I campi dei corpi sono validati da modelli Pydantic; i campi non previsti vengono ignorati in silenzio (comportamento predefinito di Pydantic), non rifiutati. Codice: app/api/routes/ (modelli *Request/*Response di ogni file)

  • [API-03] Tutti gli importi viaggiano come interi in satoshi (1 PLM = 100 000 000 sat), in campi il cui nome termina in _sats. L'API non espone mai importi in PLM né valori decimali; la conversione per la visualizzazione è compito del client. Codice: app/api/routes/ · app/static/app.js:errorParams

  • [API-04] Ogni istante nelle risposte è una stringa ISO 8601 in UTC con offset esplicito +00:00 (ad esempio 2026-09-28T10:15:00.123456+00:00). SQLite restituisce datetime privi di fuso: prima della serializzazione vengono marcati come UTC, così new Date() nel browser non li interpreta come ora locale. server_time di GET /rounds/current è generato direttamente con fuso UTC e ha lo stesso formato. Codice: app/api/timeutil.py:isoformat_utc · Test: tests/unit/test_api_timeutil.py, tests/unit/test_users.py

  • [API-05] Esistono quattro modalità di autenticazione, fissate per endpoint (colonna "Autenticazione" della panoramica):

    Modalità Come si presenta Se manca o è invalida
    Bearer (get_current_user) header Authorization: Bearer <JWT> 401
    facoltativa (get_optional_user) lo stesso header, se c'è la richiesta prosegue come anonima
    admin (require_admin) header X-Admin-Token: <ADMIN_TOKEN> 403
    nessuna — —

    Le regole di emissione e verifica del JWT (algoritmo, durata, claim tv, revoca al cambio password) sono in sicurezza.md; qui conta cosa vede il client. Codice: app/auth/dependencies.py:get_current_user, app/auth/dependencies.py:get_optional_user, app/api/routes/admin.py:require_admin

  • [API-06] Autenticazione Bearer: il token è quello restituito da /auth/register, /auth/login o /users/me/change-password. Un token illeggibile, scaduto o con firma errata, un utente non più esistente e un token superato (emesso prima dell'ultimo cambio o reset della password) rispondono tutti 401 con codice session_expired. L'assenza dell'header (o uno schema diverso da Bearer) risponde 401 con codice not_authenticated, nel formato strutturato di [API-12], con header WWW-Authenticate: Bearer. Il frontend tratta i due codici allo stesso modo: torna alla schermata di login. Codice: app/auth/dependencies.py:get_current_user · Test: tests/unit/test_users.py, tests/unit/test_bug_reports.py Stato: da implementare (B-94), per l'header assente · Oggi: l'assenza dell'header è intercettata da FastAPI prima del codice applicativo e risponde con il corpo non strutturato {"detail": "Not authenticated"}.

  • [API-07] Autenticazione facoltativa: senza header, con un header che non inizia con Bearer , con un token invalido o superato, la richiesta è trattata come anonima — mai un errore. Serve agli endpoint raggiungibili anche da non loggati che personalizzano la risposta se l'utente è noto (GET /rounds/current, POST /bug-reports). Codice: app/auth/dependencies.py:get_optional_user · Test: tests/unit/test_rounds_route.py, tests/unit/test_bug_reports.py

  • [API-08] Autenticazione admin: l'header X-Admin-Token è confrontato a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un 500) con ADMIN_TOKEN del .env. Un ADMIN_TOKEN vuoto nega tutto, anche un header vuoto. Il rifiuto è 403 con codice invalid_admin_token, nel formato strutturato di [API-12]. Non c'è identità per singolo operatore (vedi sicurezza.md). Codice: app/api/routes/admin.py:require_admin · Test: tests/unit/test_admin.py Stato: da implementare (B-94) · Oggi: 403 con il corpo non strutturato {"detail": "invalid admin token"}.

  • [API-09] Intestazioni di cache:

    • le quattro pagine (/, /admin, /guida, /report-bug) rispondono con Cache-Control: no-store;
    • il flusso SSE risponde con Cache-Control: no-store e X-Accel-Buffering: no;
    • il QR risponde con Cache-Control: private, max-age=86400, immutable;
    • ogni file servito dal mount dei file statici (/app.js, /style.css, /i18n.js, /admin.js, /admin.css, /index.html…) risponde con Cache-Control: no-cache, oltre a ETag/Last-Modified: il browser può tenerne una copia ma la rivalida a ogni uso, così dopo un aggiornamento del server non esegue un app.js vecchio contro l'API nuova;
    • le risposte JSON non hanno Cache-Control. Codice: app/main.py:index_page, app/main.py:admin_panel, app/main.py:user_guide, app/main.py:report_bug_page, app/api/routes/rounds.py:round_stream, app/api/routes/qr.py:address_qr, app/main.py (app.mount) Stato: da implementare (B-94), per i file statici · Oggi: i file serviti dal mount non hanno Cache-Control, solo ETag/Last-Modified, e il browser può riusarli senza rivalidarli.
  • [API-10] La documentazione automatica di FastAPI (/docs, /redoc, /openapi.json) è disattivata a meno che ENABLE_API_DOCS non sia impostato a vero nel .env: elencherebbe a chiunque l'intera superficie dell'API, endpoint admin compresi. Disattivata, quei percorsi rispondono 404. Codice: app/main.py (costruttore FastAPI), app/config.py:Settings · Test: tests/unit/test_docs_disabled.py

  • [API-11] Dove un controllo dipende dall'indirizzo IP del chiamante (limiti di login e registrazione, tetto per IP del flusso SSE e, da implementare con B-94, quota per IP di POST /bug-reports), l'IP è l'ultimo elemento di X-Forwarded-For (quello scritto dal proxy fidato), o in sua assenza l'indirizzo della connessione, o "unknown". Il motivo e il ruolo di Caddy sono in sicurezza.md. Codice: app/api/client_ip.py:client_ip · Test: tests/unit/test_client_ip.py

Contratto degli errori

  • [API-12] Ogni errore voluto dal codice applicativo risponde con un detail strutturato:

    {
      "detail": {
        "code": "insufficient_balance",
        "message": "insufficient balance",
        "params": {"required_sats": 1000000000}
      }
    }
    
    • code — identificatore stabile, in snake_case; è ciò che il client usa.
    • message — testo in inglese per client che non conoscono i codici (curl, test); l'API è monolingua per scelta.
    • params — i valori che il messaggio interpola (importi come *_sats, limiti, attese), sempre presente, eventualmente {}.

    Gli errori nascono come ApiError (o sue sottoclassi BetError, WithdrawalError) nei servizi, oppure direttamente con http_error nelle route. Codice: app/api/errors.py:ApiError, app/api/errors.py:http_error, app/api/errors.py:from_api_error

  • [API-13] Gli errori di POST /bets e POST /withdrawals sollevati dal servizio rispondono 400, tranne broadcast_failed, che risponde 502 (è la rete ad aver rifiutato la transazione, non il chiamante), e service_busy, che risponde 503 (il database non ha concesso il lock di scrittura entro il timeout, DAT-74; nulla è stato costruito né trasmesso). broadcast_failed si usa solo per un rifiuto esplicito del server (un errore JSON-RPC in risposta al broadcast), e significa che tutto è stato annullato. Un esito ambiguo del broadcast (timeout, connessione caduta dopo l'invio) non è un errore: la richiesta risponde 202 Accepted con lo stesso corpo del successo e status: "building", perché la transazione può essere già sulla rete e la riga resta al riconciliatore (scommesse-e-prelievi.md B-76). Un rifiuto "già noto / già nel mempool" vale come successo (201). Codice: app/api/routes/bets.py:create_bet, app/api/routes/withdrawals.py:create_withdrawal · Test: tests/unit/test_bets.py Stato: da implementare (B-76) · Oggi: qualunque eccezione del broadcast, compresi il timeout di 15 s e la caduta della connessione, annulla tutto e risponde 502 broadcast_failed.

  • [API-14] Qualunque eccezione non gestita risponde 500 con lo stesso formato strutturato, codice internal_error, messaggio "internal server error", params vuoto. Il testo e lo stack dell'eccezione finiscono solo nel log (logs/app.log), mai nella risposta. Codice: app/main.py:log_unhandled_exception · Test: tests/unit/test_rounds_route.py

  • [API-15] Un corpo, un parametro di percorso o di query che non supera la validazione risponde 422 nel formato strutturato di [API-12], con codice invalid_request, message "invalid request" e in params.errors la lista degli errori di validazione, uno per campo invalido, ciascuno {"loc", "msg", "type"} in inglese (anche il JSON malformato arriva così, con type: "json_invalid"). Il valore ricevuto (input) non è ripetuto nella risposta, così una password troppo lunga non torna indietro in chiaro. Codice: app/main.py (gestore di RequestValidationError, da scrivere), app/static/app.js:apiErrorMessage · Test: tests/unit/test_users.py, tests/unit/test_admin.py, tests/unit/test_bug_reports.py Stato: da implementare (B-94) · Oggi: 422 nel formato nativo di FastAPI: detail è una lista di oggetti {type, loc, msg, input, ctx?}, senza code; il frontend riconosce la lista e mostra il messaggio generico tradotto error.invalid_request.

  • [API-16] Nessuna risposta d'errore ha detail come semplice stringa: anche gli errori generati dagli endpoint admin, dal flusso SSE e dal framework seguono il formato strutturato di [API-12], con questi codici:

    Stato Codice atteso Dove Oggi (detail stringa)
    401 not_authenticated header Authorization assente su un endpoint Bearer ([API-06]) "Not authenticated"
    403 invalid_admin_token ogni endpoint /admin/* ([API-08]) "invalid admin token"
    404 user_not_found GET /admin/users/{id}/privkey, POST /admin/users/{id}/reset-password "user not found"
    404 bug_report_not_found POST /admin/bug-reports/{id}/status "bug report not found"
    404 not_found percorso inesistente (anche un metodo GET su un percorso che esiste solo in POST, perché lo intercetta il mount dei file statici) "Not Found"
    405 method_not_allowed metodo diverso da GET/HEAD su un percorso non definito da un router "Method Not Allowed"
    503 too_many_streams GET /rounds/stream oltre il tetto globale "too many concurrent update streams"

    I 404/405 del framework si ottengono con un gestore di StarletteHTTPException che traduce lo stato nel codice della tabella. Codice: app/api/routes/admin.py:require_admin, app/api/routes/admin.py:user_privkey, app/api/routes/admin.py:reset_user_password, app/api/routes/admin.py:update_bug_report_status, app/api/routes/rounds.py:round_stream, app/main.py · Test: tests/unit/test_admin.py, tests/unit/test_bug_reports.py Stato: da implementare (B-94) · Oggi: tutte le risposte della tabella hanno detail come stringa inglese (colonna "Oggi"), e il frontend la mostra così com'è.

  • [API-17] Il client traduce code con la chiave error.<code> di app/static/i18n.js, passando params all'interpolazione e aggiungendo per ogni parametro numerico <nome>_sats un gemello <nome>_plm già formattato in PLM; se la chiave non esiste mostra message. Ogni codice che l'API può restituire deve avere la sua chiave error.<code> in tutte e sette le lingue (en, it, es, fr, de, ru, zh). Un 401 con codice session_expired riporta inoltre il frontend alla schermata di login. Codice: app/static/app.js:apiErrorMessage, app/static/app.js:errorParams, app/static/i18n.js:TRANSLATIONS

Endpoint

Autenticazione

POST /auth/register

  • [API-18] Crea un account e restituisce subito una sessione.
    • Autenticazione: nessuna.

    • Corpo:

      Campo Tipo Vincoli
      username stringa 3–32 caratteri, solo A-Z a-z 0-9 _ . -
      password stringa 8–256 caratteri (MIN_PASSWORD_LENGTH = 8)
    • Risposta 201: {"access_token": <JWT>, "address": <indirizzo plm1…>} — l'indirizzo di deposito permanente dell'utente.

    • Errori: 422 (validazione del corpo); 429 rate_limited (quota IP esaurita, params.retry_after_seconds); 409 username_taken (nome già usato, senza distinzione tra maiuscole e minuscole, anche se l'altro account si è registrato nello stesso istante); 409 derivation_index_conflict (dopo 5 tentativi non è stato possibile assegnare un indice di derivazione libero); 503 registration_suspended (all'avvio sono stati trovati movimenti sulla catena per indici di derivazione successivi all'ultimo assegnato, cioè il database potrebbe essere un backup più vecchio della realtà; la registrazione resta sospesa finché l'operatore non riconcilia, operativita.md B-90 — da implementare).

    • Limite di frequenza: al massimo 5 account creati per IP in una finestra mobile di un'ora; la quota è verificata prima dell'hash Argon2 e consumata solo da una registrazione riuscita (un nome già preso o un errore di validazione non la consumano). Dettagli in sicurezza.md.

    • Effetti: inserisce l'utente con il nome così come digitato, il prossimo indice di derivazione e l'indirizzo derivato, e scrive user_registered con indice e indirizzo nella stessa transazione; avvia (senza attenderla, best effort) la sottoscrizione Electrum dell'indirizzo. Non notifica l'SSE. Il flusso completo è in registrazione-e-depositi.md. Codice: app/auth/routes.py:register, app/auth/routes.py:RegisterRequest · Test: tests/unit/test_auth.py, tests/unit/test_users.py Stato: da implementare (B-92, B-90) · Oggi: non scrive audit, e non esiste la sospensione registration_suspended.

POST /auth/login

  • [API-19] Apre una sessione.
    • Autenticazione: nessuna.
    • Corpo: {"username": stringa, "password": stringa}, ciascuno di al massimo 256 caratteri (oltre: 422 invalid_request, prima di qualunque hash Argon2 e senza consumare i contatori di fallimento). Lo username è confrontato senza distinzione tra maiuscole e minuscole. Stato: da implementare (B-94) · Oggi: nessun vincolo di lunghezza.
    • Risposta 200: {"access_token": <JWT>, "address": <indirizzo>}.
    • Errori: 422; 429 rate_limited (params.retry_after_seconds, arrotondato per eccesso al secondo); 401 invalid_credentials — identico per username inesistente e password sbagliata.
    • Limite di frequenza: due contatori di fallimenti con attesa esponenziale, uno per username (dal 5° fallimento) e uno per IP (dal 20°). Il blocco è verificato prima della password, quindi durante un blocco anche la password giusta riceve 429. Un login riuscito azzera solo il contatore dello username. Valori e motivazioni in sicurezza.md.
    • Effetti: nessuno oltre ai contatori in memoria; nessun audit. Codice: app/auth/routes.py:login, app/auth/rate_limit.py:AuthRateLimiters · Test: tests/unit/test_auth.py, tests/unit/test_rate_limit.py

Utente

GET /users/me

  • [API-20] Profilo e saldo dell'utente autenticato.
    • Autenticazione: Bearer.

    • Risposta 200:

      Campo Tipo Significato
      id intero identificativo dell'utente
      username stringa come digitato alla registrazione
      address stringa indirizzo permanente (deposito, vincite, resto)
      balance_sats intero saldo confermato e spendibile (User.cached_balance_sats); è quello su cui si basano scommesse e prelievi
      pending_balance_sats intero saldo comprensivo del resto ancora non confermato di scommesse/prelievi in volo — il valore da mostrare; è un totale, non una differenza
      has_pending booleano vero se esiste almeno una transazione in volo dell'utente (il frontend mostra il saldo in ambra)
      created_at stringa ISO data di registrazione

      Come si calcolano i due saldi è in registrazione-e-depositi.md.

    • Errori: 401 (vedi API-06).

    • Effetti: nessuno. Codice: app/api/routes/users.py:me, app/wallet/balance.py:compute_pending_balance · Test: tests/unit/test_users.py, tests/unit/test_balance.py

POST /users/me/change-password

  • [API-21] Cambio password self-service.
    • Autenticazione: Bearer.
    • Corpo: {"current_password": stringa, "new_password": stringa}, ciascuno di al massimo 256 caratteri.
    • Risposta 200: {"access_token": <JWT>} — un nuovo token per la sessione corrente.
    • Errori, nell'ordine in cui sono verificati: 401 di autenticazione; 422 invalid_request (un campo oltre 256 caratteri); 429 rate_limited (params.retry_after_seconds); 401 current_password_incorrect; 400 password_too_short (params.minimum = 8). La lunghezza minima è verificata nel gestore (quindi 400, non 422 come alla registrazione).
    • Limite di frequenza: un contatore di fallimenti di current_password per utente, con attesa esponenziale come quello del login; il blocco è verificato prima dell'hash Argon2. Soglie in sicurezza.md (B-94).
    • Effetti: sostituisce l'hash e incrementa User.token_version: ogni token emesso prima, compreso quello usato per questa richiesta, da quel momento riceve 401 session_expired. Scrive password_changed nella stessa transazione. Non notifica l'SSE. Codice: app/api/routes/users.py:change_password, app/auth/rate_limit.py:RateLimiter · Test: tests/unit/test_users.py Stato: da implementare (B-94) · Oggi: nessuna lunghezza massima, nessun limite di frequenza (la password attuale si può provare senza limiti) e nessuna voce di audit.

GET /users/me/last-round-result

  • [API-22] Esito dell'ultimo round chiuso a cui l'utente ha partecipato, letto dal database. È la rete di sicurezza per un dispositivo che ha perso la finestra in cui GET /rounds/current mostrava il vincitore.
    • Autenticazione: Bearer.

    • Risposta 200:

      Campo Tipo Significato
      round_id intero o null l'ultimo round in stato closed con una riga di partecipazione dell'utente; null se non ce n'è nessuno
      won booleano vero se l'utente è il vincitore di quel round
      amount_sats intero o null importo effettivamente pagato al vincitore (Round.winner_amount_sats, già al netto della commissione del pagamento), solo se won

      Senza round chiusi giocati la risposta è {"round_id": null, "won": false, "amount_sats": null}.

    • Errori: 401.

    • Effetti: nessuno. Codice: app/api/routes/users.py:last_round_result

Scommesse e prelievi

POST /bets

  • [API-23] Piazza la scommessa dell'utente sul round in corso, al costo fisso attuale. Regole, sequenza e rollback sono in scommesse-e-prelievi.md.
    • Autenticazione: Bearer.

    • Corpo: nessuno.

    • Risposta 201 (il server attivo ha accettato la transazione) o 202 (esito del broadcast ambiguo, API-13):

      Campo Tipo Significato
      round_id intero round a cui la scommessa è iscritta
      bet_txid stringa txid della transazione (trasmessa con 201, forse trasmessa con 202)
      bet_amount_sats intero importo che arriva al montepremi, cioè il costo del round meno la commissione di rete
      status stringa stato del partecipante: "broadcast" con 201, "building" con 202
    • Errori: 503 network_unavailable (nessuna connessione Electrum, verificato prima di ogni altro controllo); poi, tutti 400 salvo indicazione: no_round_open, round_closing, already_betting, round_full (params.max_participants; anche quando il round ha raggiunto max_round_pool_sats, B-95), balance_pending_confirmation (params.pending_sats; il costo sarebbe coperto dal saldo comprensivo del resto non ancora confermato, ma non da quello confermato, B-96), insufficient_balance (params.required_sats), too_many_inputs (params.max_inputs), balance_leaves_no_change (params.required_extra_sats), amount_below_network_fee, amount_below_dust_limit, 503 service_busy, 502 broadcast_failed (solo rifiuto esplicito). Il significato di ciascuno è nel catalogo.

    • Serializzazione: la richiesta tiene il blocco per-utente condiviso con POST /withdrawals per tutta la costruzione e la trasmissione.

    • Effetti: può aprire un round se nessuno è attivo e le condizioni lo permettono (lo stesso controllo dello scheduler, fatto dentro la richiesta); segna le monete spese, crea partecipante e transazione in sospeso, trasmette al server attivo e poi, best effort, a tutti i server di riserva. Audit bet_placed in caso di successo, bet_broadcast_failed se la rete rifiuta; notifica SSE in tutti e tre i casi (successo, rifiuto, esito ambiguo) e all'apertura del round. Codice: app/api/routes/bets.py:create_bet, app/bets/service.py:place_bet · Test: tests/unit/test_bets.py Stato: da implementare (B-76, B-95, B-96) · Oggi: ogni esito diverso dal successo è 502 broadcast_failed con tutto annullato; la trasmissione va solo al server attivo; un costo coperto solo da resto non confermato risponde insufficient_balance; non c'è tetto al montepremi; il costo è quello configurato al momento della scommessa, non quello del round.

POST /withdrawals

  • [API-24] Invia fondi a un indirizzo esterno. Regole e sequenza in scommesse-e-prelievi.md.
    • Autenticazione: Bearer.

    • Corpo:

      Campo Tipo Vincoli
      external_address stringa verificato nel servizio (bech32 valido con prefisso plm1)
      amount_sats intero nessun vincolo di schema; il minimo è verificato nel servizio
    • Risposta 201 (il server attivo ha accettato la transazione) o 202 (esito del broadcast ambiguo, API-13):

      Campo Tipo Significato
      txid stringa txid della transazione (trasmessa con 201, forse trasmessa con 202)
      amount_requested_sats intero importo chiesto
      amount_sent_sats intero importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto
      status stringa stato del prelievo: "broadcast" con 201, "building" con 202
    • Errori: 422; 503 network_unavailable (verificato prima della validazione dell'indirizzo, quindi un indirizzo invalido riceve 503 finché la rete non torna); poi 400: invalid_address, withdrawal_to_own_address, withdrawal_to_platform_address (destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni, B-80), amount_below_minimum (params.minimum_sats = costo attuale della scommessa; verificato anche sull'importo ridotto di un prelievo dell'intero saldo, B-96), balance_pending_confirmation (params.pending_sats), insufficient_balance (params.required_sats), too_many_inputs (params.max_inputs), amount_below_network_fee, amount_below_dust_limit; 503 service_busy; 502 broadcast_failed (solo rifiuto esplicito).

    • Serializzazione: stesso blocco per-utente di POST /bets.

    • Effetti: segna le monete spese, crea prelievo e transazione in sospeso, trasmette al server attivo e poi, best effort, a tutti i server di riserva. Audit withdrawal_sent, oppure withdrawal_broadcast_failed con il prelievo lasciato in stato failed per un rifiuto esplicito; con un esito ambiguo il prelievo resta building. Notifica SSE in tutti e tre i casi. Codice: app/api/routes/withdrawals.py:create_withdrawal, app/withdrawals/service.py:request_withdrawal · Test: tests/unit/test_withdrawals.py Stato: da implementare (B-76, B-80, B-96) · Oggi: ogni esito diverso dal successo è 502 broadcast_failed con il prelievo failed; la trasmissione va solo al server attivo; un prelievo verso l'indirizzo del pool o delle commissioni è accettato; l'importo ridotto di un prelievo dell'intero saldo non è riconfrontato con il minimo.

Round

GET /rounds/current

  • [API-25] Stato del round attivo, pensato per essere interrogato a ciclo dalle due interfacce (anche la pillola Electrum/altezza di /admin legge chain_tip_height da qui).
    • Autenticazione: facoltativa (serve solo per user_played).

    • Risposta 200 — tutti i campi sono sempre presenti:

      Campo Tipo Significato
      server_time stringa ISO ora del server, per allineare i conti alla rovescia del client
      round_id intero o null round attivo; null se non ce n'è
      status stringa o null open, closing, drawing o paying_out; un round closed non compare mai
      opened_at stringa ISO o null apertura del round
      closes_at stringa ISO o null scadenza per le scommesse: opened_at + la durata fissata all'apertura del round (non quella attuale di configurazione)
      participant_count intero partecipanti confermati, cioè quelli tra cui si estrae
      bet_amount_sats intero costo che pagherebbe una nuova scommessa: con un round attivo il costo fissato all'apertura del round (rounds.bet_amount_sats), senza round attivo quello configurato — da implementare (B-95); oggi sempre quello configurato
      jackpot_sats intero 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre
      pending_participant_count intero tutti i partecipanti, confermati e in volo (totale, non differenza)
      pending_jackpot_sats intero 70% della somma di tutte le scommesse, confermate e in volo
      has_pending_bets booleano vero se pending_participant_count > participant_count
      draw_animation_seconds intero durata minima dell'animazione dell'estrazione, dalla configurazione
      winner_user_id intero o null vincitore, valorizzato dall'estrazione in poi (paying_out)
      winner_amount_sats intero o null importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita
      draw_block_height intero o null altezza del blocco usato per l'estrazione
      draw_block_hash stringa o null hash di quel blocco; è uno degli ingressi del seme, non il seme (vedi API-45)
      draw_waiting_since stringa ISO o null inizio dell'attesa del blocco; valorizzato solo mentre status è drawing
      server_seed_hash stringa o null impegno del server sul segreto del round, pubblicato dall'apertura; null per un round aperto prima di B-73 — da implementare (B-73)
      participants_hash stringa o null impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in drawing — da implementare (B-73)
      draw_height intero o null altezza del blocco di estrazione, fissata all'ingresso in drawing — da implementare (B-73)
      draw_confirmations intero o null conferme richieste sul blocco di estrazione, fissate all'apertura del round — da implementare (B-73, B-95)
      pool_address stringa o null indirizzo del montepremi del round, verificabile su un esploratore — da implementare (B-80)
      chain_tip_height intero o null ultima altezza nota della catena; null finché non è nota
      lottery_paused booleano lotteria in pausa, per qualunque motivo
      paused_reason stringa o null operator, reorg, payout_blocked, resurrected_tx, oppure null se non in pausa (modello-dati.md, DAT-69) — da implementare (B-78, B-82, B-85)
      lottery_configured booleano falso finché manca configurazione indispensabile (oggi: fee_address), e quindi nessun round può aprirsi
      user_played booleano vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo

      Il segreto del round (server_seed) non compare mai in questa risposta: si legge solo da API-45, dopo che il vincitore è stato scritto.

      Senza round attivo sono valorizzati solo server_time, bet_amount_sats, draw_animation_seconds, chain_tip_height, lottery_paused, paused_reason e lottery_configured; gli altri valgono null, 0 o false. Il significato di dominio di ogni fase è in ciclo-di-vita-del-round.md.

    • Errori: nessuno previsto (un token invalido non è un errore, API-07).

    • Effetti: non apre round (a differenza di POST /bets); crea la riga di configurazione con i valori predefiniti se non esiste ancora. Codice: app/api/routes/rounds.py:current_round, app/rounds/service.py:get_active_round, app/rounds/service.py:round_deadline, app/rounds/service.py:winner_share, app/rounds/service.py:rounds_can_open · Test: tests/unit/test_rounds_route.py Stato: da implementare (B-73, B-78, B-80, B-82, B-85, B-95), per i campi marcati · Oggi: quei campi non esistono, bet_amount_sats è sempre il costo configurato, e draw_block_hash è il seme dell'estrazione.

GET /rounds/stream

  • [API-26] Canale Server-Sent Events: una notifica senza contenuto ("qualcosa è cambiato, rileggi") ogni volta che cambia lo stato di round, scommesse o saldi. È aggiuntivo al polling, che resta attivo; i punti del codice che notificano e il ruolo del canale sono in architettura.md.
    • Autenticazione: nessuna; la notifica non contiene dati.
    • Risposta 200, text/event-stream, con Cache-Control: no-store e X-Accel-Buffering: no. Messaggi:
      • event: update / data: {} — a ogni notifica; notifiche arrivate prima che il client abbia letto la precedente si fondono in una sola (coda di capacità 1 per client);
      • : keep-alive (commento) — dopo 20 secondi senza notifiche.
    • Chiusura: la connessione di un client scomparso senza chiusura pulita è rilevata entro 5 secondi. Oltre 5 flussi contemporanei dallo stesso IP, l'apertura di un nuovo flusso chiude il più vecchio di quell'IP.
    • Errori: 503 too_many_streams (formato strutturato, API-16) quando i flussi aperti in totale sono già 500; il client EventSource riprova da solo e intanto resta il polling.
    • Chiusura all'arresto del server: allo spegnimento ogni flusso aperto viene chiuso dal server, così l'arresto non attende i client (architettura.md B-93).
    • Effetti: nessuno.
    • Cosa non notifica: PUT /admin/config, POST /admin/pause e POST /admin/resume non pubblicano sul canale; / vede lottery_paused/paused_reason e la nuova configurazione al polling successivo di GET /rounds/current. Codice: app/api/routes/rounds.py:round_stream, app/rounds/events.py:RoundEventBroadcaster.subscribe, app/rounds/events.py:RoundEventBroadcaster.publish · Test: tests/unit/test_round_events.py Stato: da implementare (B-94, B-93) · Oggi: il 503 ha il corpo non strutturato {"detail": "too many concurrent update streams"}, e un flusso aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non termina il processo.

GET /rounds/{round_id}/proof

  • [API-45] La prova pubblica dell'estrazione di un round: tutto ciò che serve a chiunque per ricalcolare il vincitore senza fidarsi del server. L'algoritmo e la codifica esatta dei byte sono definiti in ciclo-di-vita-del-round.md (B-73); qui è specificata la forma della risposta.
    • Autenticazione: nessuna.

    • Parametro di percorso: round_id intero (non intero: 422).

    • Risposta 200, per un round in qualunque stato; i campi non ancora determinati valgono null (o lista vuota):

      Campo Tipo Valorizzato da Significato
      round_id intero sempre —
      status stringa sempre stato del round
      algorithm stringa sempre "commit-reveal-v1" per i round aperti con B-73; "block-hash-v0" per quelli precedenti, per cui la prova è incompleta (vedi sotto)
      pool_address stringa sempre indirizzo del montepremi del round
      server_seed_hash stringa apertura sha256(server_seed), in esadecimale
      server_seed stringa o null solo dopo che il vincitore è stato scritto (paying_out e closed) il segreto del round, 32 byte in esadecimale
      draw_height intero o null ingresso in drawing altezza del blocco di estrazione
      draw_confirmations intero apertura conferme richieste su quel blocco prima di leggerne l'hash
      participants lista ingresso in drawing elenco in ordine di draw_position: {"draw_position", "txid", "vout", "address", "status"}, dove txid:vout è l'output della scommessa verso pool_address, address l'indirizzo del partecipante (dove andrebbe la vincita) e status confirmed o reorged_out
      participants_hash stringa o null ingresso in drawing impronta dell'elenco participants
      draw_block_hash stringa o null estrazione hash del blocco a draw_height
      seed stringa o null estrazione il seme in decimale (rounds.seed_int)
      winner_index intero o null estrazione seed mod numero di elementi di participants
      winner_address stringa o null estrazione participants[winner_index].address
    • Come si verifica (round commit-reveal-v1):

      1. prima della chiusura, annotare server_seed_hash da GET /rounds/current o da questo endpoint;
      2. dopo la rivelazione, controllare che sha256(server_seed) sia uguale a quel valore: il segreto non è stato cambiato dopo l'apertura;
      3. ricalcolare participants_hash da participants, e controllare su un esploratore o un nodo che ogni txid:vout sia un output confermato verso pool_address;
      4. controllare su un esploratore o un nodo che il blocco a draw_height abbia hash draw_block_hash e che la catena abbia almeno draw_confirmations blocchi da lì;
      5. ricalcolare seed da server_seed, draw_block_hash e participants_hash, poi winner_index = seed mod n con n il numero di elementi di participants, e confrontare winner_address.

      Per un round block-hash-v0 la risposta ha server_seed_hash, server_seed, draw_height e participants_hash a null e participants vuota, perché quei round non hanno registrato né un impegno né l'ordine dei partecipanti: seed e draw_block_hash si possono confrontare, il vincitore no.

    • Riservatezza: server_seed non compare in nessuna risposta, di nessun endpoint (admin compresi), finché rounds.winner_user_id è NULL (modello-dati.md, DAT-64). Gli indirizzi dei partecipanti sono già pubblici sulla catena: le scommesse li collegano a pool_address.

    • Errori: 404 round_not_found; 422.

    • Effetti: nessuno. Codice: app/api/routes/rounds.py (endpoint da scrivere), app/rounds/draw.py:draw_winner · Test: — Stato: da implementare (B-73) · Oggi: l'endpoint non esiste; l'unico dato pubblico dell'estrazione è draw_block_hash in GET /rounds/current, finché il round è attivo.

QR

GET /qr/{address}

  • [API-27] Immagine PNG del QR code di un indirizzo PLM, usata dal pannello Deposito con un semplice <img>.
    • Autenticazione: nessuna (un <img> non può portare il token).
    • Parametro di percorso: address, al massimo 100 caratteri, bech32 valido con prefisso plm1 (stessa verifica dei prelievi).
    • Risposta 200: image/png, con Cache-Control: private, max-age=86400, immutable.
    • Errori: 400 invalid_address.
    • Effetti: nessuno. Il disegno avviene fuori dal ciclo di eventi ed è memorizzato per indirizzo (ultimi 512 indirizzi), così una richiesta ripetuta costa solo una lettura in memoria. Codice: app/api/routes/qr.py:address_qr, app/api/routes/qr.py:_render_png, app/wallet/address.py:is_valid_plm_address · Test: tests/unit/test_qr.py

Segnalazioni di bug

POST /bug-reports

  • [API-28] Invia una segnalazione, da loggati o no.
    • Autenticazione: facoltativa; se il chiamante è autenticato la segnalazione gli viene attribuita.

    • Corpo:

      Campo Tipo Vincoli
      description stringa 1–2000 caratteri (lunghezza misurata sul testo ricevuto); spazi iniziali e finali rimossi; non può essere vuota dopo la rimozione
      contact stringa o null facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa null
    • Risposta 201: {"id": intero}.

    • Errori: 422; 429 rate_limited (params.retry_after_seconds).

    • Limite di frequenza: una quota di segnalazioni per IP (API-11) in una finestra mobile, verificata prima dell'inserimento e consumata solo da un inserimento riuscito, anche per un chiamante autenticato; soglie in sicurezza.md (B-94).

    • Effetti: inserisce la segnalazione in stato open. Nessun audit, nessuna notifica SSE. Codice: app/api/routes/bug_reports.py:create_bug_report, app/api/routes/bug_reports.py:BugReportCreate, app/auth/rate_limit.py:RollingQuota · Test: tests/unit/test_bug_reports.py Stato: da implementare (B-94) · Oggi: nessun limite di frequenza: un anonimo può inserire segnalazioni senza limite.

GET /bug-reports/mine

  • [API-29] Le segnalazioni inviate dall'utente mentre era autenticato, per seguirne lo stato. Quelle anonime non vi compaiono mai.
    • Autenticazione: Bearer.
    • Risposta 200: lista, dalla più recente, di {"id", "description", "status", "created_at"}; status è open, read o resolved. Il campo contact non è restituito.
    • Errori: 401. Codice: app/api/routes/bug_reports.py:list_my_bug_reports · Test: tests/unit/test_bug_reports.py

Amministrazione

Tutti gli endpoint /admin/* richiedono X-Admin-Token (API-08) e rispondono 403 invalid_admin_token senza di esso. Nessuno restituisce rounds.server_seed prima che il vincitore del round sia stato scritto (API-45). Gli eventi di audit citati sono descritti in audit-log.md; il significato dei parametri operativi in ciclo-di-vita-del-round.md e modello-dati.md.

GET /admin/config

  • [API-30] Legge la configurazione operativa (la riga unica di round_config, creata con i valori predefiniti se manca).
    • Risposta 200: {"fee_address", "bet_amount_sats", "round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", "deposit_confirmations", "draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}. Codice: app/api/routes/admin.py:read_config · Test: tests/unit/test_admin.py Stato: da implementare (B-73, B-82, B-85, B-95) · Oggi: mancano deposit_confirmations, draw_confirmations, max_round_pool_sats e paused_reason.

PUT /admin/config

  • [API-31] Modifica a caldo uno o più parametri operativi.
    • Corpo: tutti i campi facoltativi; quelli assenti o null non cambiano.

      Campo Vincoli
      fee_address spazi rimossi; bech32 PLM valido (plm1…). Non può essere svuotato tramite l'API
      bet_amount_sats > 0 e ≤ 10 000 000 000 000 (100 000 PLM)
      round_duration_seconds 30 – 604 800 (7 giorni)
      round_cooldown_seconds 0 – 86 400
      fee_rate_sat_vb 1 – 10 000 (MAX_FEE_RATE_SAT_VB)
      rbf_timeout_seconds 60 – 604 800
      draw_animation_seconds 0 – 600
      deposit_confirmations 1 – 100 — da implementare (B-82)
      draw_confirmations 1 – 20 — da implementare (B-73)
      max_round_pool_sats 0 (disattivato), oppure da bet_amount_sats a 4 000 000 000 000 000 — da implementare (B-95)

      I limiti sono quelli di modello-dati.md (tabella round_config), che ne è il proprietario. paused e paused_reason non sono accettati qui (sono ignorati come campi sconosciuti): la pausa ha i suoi endpoint registrati nell'audit.

    • Risposta 200: la configurazione completa, come API-30.

    • Errori: 422 per qualunque vincolo violato (nessun campo viene applicato).

    • Effetti: scrive config_updated con i valori prima/dopo dei soli campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa tra round, costo della scommessa, fee_address e draw_confirmations si applicano dal round successivo (per costo e fee_address: da implementare, B-95; oggi subito). Nessuna notifica SSE. Codice: app/api/routes/admin.py:update_config, app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_admin.py Stato: da implementare (B-73, B-82, B-95), per i tre campi nuovi e la copia all'apertura · Oggi: sono accettati solo i primi sette campi.

POST /admin/pause e POST /admin/resume

  • [API-32] Attivano e disattivano la pausa: impedisce l'apertura del prossimo round, il round in corso prosegue fino al pagamento (salvo un pagamento trattenuto, vedi sotto). La semantica di paused_reason è in modello-dati.md (DAT-69).
    • POST /admin/pause — corpo: facoltativo, {"note": stringa} di al massimo 256 caratteri, un promemoria libero dell'operatore che finisce solo nel payload di lottery_paused. Imposta paused = true e paused_reason = 'operator', tranne quando è già attivo un motivo automatico (reorg, payout_blocked, resurrected_tx), che resta.
    • POST /admin/resume — corpo: nessuno. È l'unico modo di uscire da qualunque pausa, anche automatica: imposta paused = false, paused_reason = NULL e azzera rounds.payout_blocked_at del round attivo, se valorizzato (il pagamento viene poi riverificato, DAT-68). Il payload di lottery_resumed riporta il paused_reason che è stato tolto, così il registro dice che cosa l'operatore ha accettato di riprendere.
    • Risposta 200 (entrambi): la configurazione completa, come API-30, compreso paused_reason.
    • Errori: 422 (nota oltre 256 caratteri).
    • Effetti: scrivono lottery_paused / lottery_resumed a ogni chiamata, anche se lo stato era già quello. Nessuna notifica SSE: / vede lottery_paused al polling successivo. Codice: app/api/routes/admin.py:pause_lottery, app/api/routes/admin.py:resume_lottery · Test: tests/unit/test_admin.py Stato: da implementare (B-78, B-82, B-85) · Oggi: nessun corpo, nessun paused_reason, e la pausa è solo manuale.

GET /admin/users

  • [API-33] Elenco degli utenti, per id crescente, a pagine.
    • Query: limit intero 1–500, predefinito 100; after_id intero facoltativo: restituisce solo gli utenti con id maggiore. La pagina successiva si chiede con after_id uguale all'id dell'ultimo elemento ricevuto; una lista più corta di limit è l'ultima pagina. Fuori intervallo: 422.
    • Risposta 200: lista di {"id", "username", "address", "balance_sats", "created_at"}; balance_sats è il saldo confermato. Codice: app/api/routes/admin.py:list_users · Test: tests/unit/test_admin.py Stato: da implementare (B-94) · Oggi: restituisce tutti gli utenti in una sola risposta, senza paginazione né limite.

GET /admin/users/{user_id}/privkey

  • [API-34] Esporta la chiave privata WIF dell'indirizzo di un utente, per interventi manuali. È una scelta voluta (vedi decisioni-e-limiti.md).
    • Parametro: user_id intero.
    • Risposta 200: {"address", "wif"}.
    • Errori: 404 user_not_found (oggi non strutturato, "user not found", API-16); 422 se user_id non è un intero.
    • Effetti: scrive admin_privkey_accessed a ogni accesso. Codice: app/api/routes/admin.py:user_privkey · Test: tests/unit/test_admin.py

POST /admin/users/{user_id}/reset-password

  • [API-35] Imposta una nuova password casuale per un utente bloccato.
    • Risposta 200: {"username", "new_password"} — la password (16 caratteri URL-safe) è mostrata solo in questa risposta.
    • Errori: 404 user_not_found (oggi non strutturato, API-16); 422.
    • Effetti: sostituisce l'hash, incrementa token_version (ogni sessione aperta dell'utente riceve 401 session_expired), scrive admin_password_reset. Codice: app/api/routes/admin.py:reset_user_password · Test: tests/unit/test_admin.py

GET /admin/rounds

  • [API-36] Storico dei round, dal più recente.
    • Query: limit intero 1–500, predefinito 50 (fuori intervallo: 422).
    • Risposta 200: lista di {"id", "status", "opened_at", "closed_at", "draw_block_height", "draw_block_hash", "winner_user_id", "winner_username", "pool_amount_sats", "winner_amount_sats", "fee_amount_sats", "payout_txid", "pool_address", "server_seed_hash", "draw_height", "bump_reserve_sats", "payout_blocked_at"}; i campi non ancora valorizzati sono null. server_seed non è tra i campi: la prova completa di un round è in API-45. Codice: app/api/routes/admin.py:list_rounds · Test: tests/unit/test_admin.py Stato: da implementare (B-73, B-79, B-80, B-82), per gli ultimi cinque campi · Oggi: la lista si ferma a payout_txid.

GET /admin/audit-log

  • [API-37] Registro di audit, dalla voce più recente, filtrabile e a pagine.
    • Query, tutti facoltativi e combinati in and:

      Parametro Tipo Filtro
      limit intero 1–500, predefinito 200 numero massimo di voci
      before_id intero solo voci con id minore: la pagina successiva si chiede con l'id dell'ultima voce ricevuta; una lista più corta di limit è l'ultima pagina
      event_type stringa, ripetibile solo voci di uno dei tipi indicati, confrontati alla lettera (un tipo inesistente dà una lista vuota)
      round_id intero solo voci con quel round_id
      user_id intero solo voci con quel user_id
      from stringa ISO 8601 con offset solo voci con created_at ≥ questo istante
      to stringa ISO 8601 con offset solo voci con created_at < questo istante

      Un valore non interpretabile (un intero non valido, un istante senza offset) risponde 422 invalid_request.

    • Risposta 200: lista di {"id", "event_type", "payload" (oggetto), "user_id", "round_id", "created_at"}, per id decrescente. Codice: app/api/routes/admin.py:list_audit_log · Test: tests/unit/test_admin.py Stato: da implementare (B-92), per filtri e paginazione · Oggi: accetta solo limit, quindi oltre le ultime 500 voci il registro non è consultabile dall'API.

GET /admin/pending-transactions

  • [API-38] Transazioni tracciate (scommesse, pagamenti, prelievi), dalla più recente.
    • Query: limit intero 1–500, predefinito 50; status facoltativo, confrontato alla lettera (un valore inesistente restituisce una lista vuota, non un errore).
    • Risposta 200: lista di {"id", "kind", "status", "round_id", "withdrawal_id", "user_id", "current_txid", "fee_rate_sat_vb", "attempt_count", "broadcast_at", "replaced_by_txid"}. Non sono esposti failure_reason, last_broadcast_at né la transazione grezza. Stati e significato in transazioni-e-riconciliazione.md. Codice: app/api/routes/admin.py:list_pending_transactions · Test: tests/unit/test_admin.py

GET /admin/electrum

  • [API-47] Stato della connessione alla rete, per l'indicatore Electrum di /admin. Risponde 200 con: connected (booleano), endpoint (server attivo come host:port, null se non connesso), tip_height, last_header_age_seconds, fallback_count (server di riserva configurati), quorum_enforced (falso solo con ALLOW_NO_QUORUM=true, connessione-electrum.md ELC-19) e last_rotation (motivo e ora dell'ultimo cambio di server, ad esempio punta ferma, connessione-electrum.md ELC-21). Codice: app/api/routes/admin.py, app/electrum/listener.py:ElectrumListener.current_endpoint · Test: — Stato: da implementare (B-86, B-92) · Oggi: l'endpoint non esiste; /admin mostra solo l'altezza della catena letta da GET /rounds/current.

GET /admin/bug-reports

  • [API-39] Tutte le segnalazioni, dalla più recente.
    • Query: limit intero 1–500, predefinito 200.
    • Risposta 200: lista di {"id", "description", "contact", "user_id", "username", "status", "created_at"}; user_id e username sono null per le segnalazioni anonime. Codice: app/api/routes/admin.py:list_bug_reports · Test: tests/unit/test_bug_reports.py

POST /admin/bug-reports/{report_id}/status

  • [API-40] Cambia lo stato di una segnalazione.
    • Corpo: {"status": "open" | "read" | "resolved"}; qualunque transizione è ammessa, anche all'indietro.
    • Risposta 200: la segnalazione aggiornata, come API-39.
    • Errori: 422 (stato sconosciuto); 404 bug_report_not_found (oggi non strutturato, "bug report not found", API-16).
    • Effetti: scrive bug_report_status_changed (con stato precedente e nuovo) solo se lo stato cambia davvero. Codice: app/api/routes/admin.py:update_bug_report_status · Test: tests/unit/test_bug_reports.py

Servizio

GET /health

  • [API-41] Risponde sempre 200 {"status": "ok"} finché il processo serve richieste. Non verifica né il database né la connessione Electrum: per lo stato della rete si usa chain_tip_height di GET /rounds/current. È il controllo di vitalità (il processo risponde); quello di prontezza è [API-46]. Codice: app/main.py:health

GET /health/ready

  • [API-46] Risponde 200 {"status": "ready", "checks": {...}} solo se tutte queste condizioni sono vere, altrimenti 503 con lo stesso corpo e "status": "not_ready":

    • la connessione Electrum è attiva (listener.client non nullo);
    • l'ultimo header è stato accettato da meno di 360 secondi (connessione-electrum.md ELC-29);
    • lo scheduler dei round ha battuto da meno di 30 secondi (il battito si aggiorna a ogni giro e a ogni iterazione dell'attesa del blocco di estrazione, architettura.md ARC-61).

    checks riporta ogni condizione con il suo valore: electrum_connected (booleano), last_header_age_seconds (intero o null), scheduler_heartbeat_age_seconds (intero o null). Nessuna autenticazione, nessun dato sensibile: non espone né il server attivo né l'altezza. È il healthcheck del container. Codice: app/main.py:health · Test: — Stato: da implementare (B-92) · Oggi: l'endpoint non esiste; esiste solo GET /health ([API-41]).

Pagine e file statici

  • [API-42] GET /, /admin, /guida e /report-bug restituiscono rispettivamente index.html, admin.html, guida.html e report-bug.html di app/static/, con Cache-Control: no-store, senza autenticazione (il pannello admin chiede il token lato client). Sono esclusi dallo schema OpenAPI. Codice: app/main.py:index_page, app/main.py:admin_panel, app/main.py:user_guide, app/main.py:report_bug_page

  • [API-43] Ogni altro percorso non gestito da un router è servito dal mount dei file statici su / (app/static/, con html=True): i file esistenti rispondono 200, gli altri 404 {"detail": "Not Found"}, un metodo diverso da GET/HEAD 405. Il mount è registrato dopo tutti i router, quindi non può oscurare un endpoint dell'API. Codice: app/main.py (app.mount)

Catalogo dei codici di errore

  • [API-44] L'insieme dei codici che l'API può restituire nel formato strutturato è esattamente quello della tabella seguente; un codice nuovo va aggiunto qui, con la sua chiave error.<code> in tutte e sette le lingue di app/static/i18n.js. Codice: app/api/errors.py:ApiError, app/static/i18n.js:TRANSLATIONS
Codice Stato Sollevato da params Significato error.<code> (7 lingue)
session_expired 401 app/auth/dependencies.py:get_current_user — token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password sì
invalid_credentials 401 app/auth/routes.py:login — username inesistente o password sbagliata (risposta identica) sì
rate_limited 429 app/auth/routes.py:register, app/auth/routes.py:login; anche app/api/routes/users.py:change_password e app/api/routes/bug_reports.py da implementare (B-94) retry_after_seconds quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni sì
username_taken 409 app/auth/routes.py:register — nome già usato (senza distinzione di maiuscole) sì
derivation_index_conflict 409 app/auth/routes.py:register — nessun indice di derivazione libero dopo 5 tentativi sì
current_password_incorrect 401 app/api/routes/users.py:change_password — password attuale errata sì
password_too_short 400 app/api/routes/users.py:change_password minimum nuova password sotto MIN_PASSWORD_LENGTH sì
network_unavailable 503 app/api/routes/bets.py:create_bet, app/api/routes/withdrawals.py:create_withdrawal — nessuna connessione Electrum attiva sì
no_round_open 400 app/bets/service.py:place_bet — nessun round attivo e nessuno apribile (pausa, fee_address mancante, pausa tra round in corso) sì
round_closing 400 app/bets/service.py:place_bet — il round attivo non accetta più scommesse (scadenza passata o stato diverso da open), anche se scoperto dopo la costruzione sì
already_betting 400 app/bets/service.py:place_bet — l'utente ha già una riga di partecipazione nel round sì
round_full 400 app/bets/service.py:place_bet max_participants il round ha già 400 partecipanti (in qualunque stato) sì
insufficient_balance 400 app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/wallet/psbt_builder.py:select_utxos required_sats (assente se sollevato da select_utxos) le monete confermate e non spese non coprono l'importo sì
balance_pending_confirmation 400 app/withdrawals/service.py:request_withdrawal; anche app/bets/service.py:place_bet da implementare (B-96) pending_sats l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato sì
too_many_inputs 400 app/wallet/psbt_builder.py:select_utxos max_inputs servirebbero più di 50 monete in ingresso sì
balance_leaves_no_change 400 app/wallet/psbt_builder.py:build_signed_transaction (solo scommesse) required_extra_sats dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) sì
amount_below_network_fee 400 app/wallet/psbt_builder.py:build_signed_transaction — l'importo non copre la commissione di rete sì
amount_below_dust_limit 400 app/wallet/psbt_builder.py:build_signed_transaction — al netto della commissione l'importo sarebbe polvere sì
broadcast_failed 502 app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal — (il motivo del rifiuto è nel message) la rete ha rifiutato la trasmissione; tutto è stato annullato sì
invalid_address 400 app/withdrawals/service.py:request_withdrawal, app/api/routes/qr.py:address_qr — non è un indirizzo bech32 PLM valido sì
withdrawal_to_own_address 400 app/withdrawals/service.py:request_withdrawal — destinazione uguale all'indirizzo di deposito dell'utente sì
amount_below_minimum 400 app/withdrawals/service.py:request_withdrawal minimum_sats prelievo sotto il costo attuale della scommessa sì
internal_error 500 app/main.py:log_unhandled_exception — eccezione non gestita sì
withdrawal_to_platform_address 400 app/withdrawals/service.py:request_withdrawal — destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni — da implementare (B-80) no — da aggiungere
service_busy 503 app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/auth/routes.py:register — il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare sì
registration_unavailable 503 app/auth/routes.py:register — registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto (registrazione-e-depositi.md, B-90) — da implementare no — da aggiungere
not_authenticated 401 dipendenza Bearer di FastAPI — header Authorization assente — oggi detail stringa — da implementare (B-94) no — da aggiungere
invalid_admin_token 403 app/api/routes/admin.py:require_admin — token admin assente o errato — oggi detail stringa — da implementare (B-94) no — da aggiungere
user_not_found 404 app/api/routes/admin.py — utente inesistente — oggi detail stringa — da implementare (B-94) no — da aggiungere
bug_report_not_found 404 app/api/routes/admin.py — segnalazione inesistente — oggi detail stringa — da implementare (B-94) no — da aggiungere
round_not_found 404 app/api/routes/rounds.py (GET /rounds/{id}/proof) — round inesistente — da implementare (B-73) no — da aggiungere
not_found 404 gestore delle eccezioni HTTP in app/main.py — percorso inesistente — oggi detail stringa — da implementare (B-94) no — da aggiungere
method_not_allowed 405 gestore delle eccezioni HTTP in app/main.py — metodo non ammesso — oggi detail stringa — da implementare (B-94) no — da aggiungere
too_many_streams 503 app/api/routes/rounds.py (SSE) — tetto globale degli stream raggiunto — oggi detail stringa — da implementare (B-94) no — da aggiungere

Due codici di app/wallet/psbt_builder.py — winner_share_below_network_fee e commission_below_dust_limit — riguardano solo la costruzione del pagamento, che avviene nello scheduler e non attraverso l'API: non arrivano mai a un client (finiscono nel registro di audit, vedi ciclo-di-vita-del-round.md) e non hanno traduzione.

Chiavi error.* presenti in i18n.js che il server non produce: invalid_request (usata dal frontend per le risposte 422, API-15), invalid_amount (validazione lato client del modulo di prelievo) e guide_unavailable (attualmente non usata da nessun file).