The two Mermaid diagrams under flowchart/ described the intended flow but had drifted from the code and carried no link back to it. docs/ now holds the specification instead: 15 Italian documents (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history) with 472 numbered requirements, each pointing at the function that implements it and the test that covers it. docs/processo-di-modifica.md defines the ID convention and the spec -> code -> test change workflow, and CLAUDE.md now names docs/ as authoritative. Writing the spec from the code also corrected stale claims in CLAUDE.md (B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control scope, the error contract's bare-string exceptions) and in the operator guides (payouts do retry, only `drawing` fails to resume, rounds do not open without fee_address). The B-70 doc test now pins the prose in docs/scommesse-e-prelievi.md instead of the deleted diagram. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43 KiB
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 |
/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 |
/health |
nessuna | API-41 |
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 containerappsulla 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}rispondeimage/png,GET /rounds/streamrispondetext/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/*Responsedi 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 esempio2026-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_timediGET /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>401facoltativa ( get_optional_user)lo stesso header, se c'è la richiesta prosegue come anonima admin ( require_admin)header X-Admin-Token: <ADMIN_TOKEN>403nessuna — — 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/logino/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 tutti401con codicesession_expired. L'assenza dell'header (o uno schema diverso daBearer) è invece intercettata da FastAPI prima del codice applicativo:401con il corpo non strutturato{"detail": "Not authenticated"}e headerWWW-Authenticate: Bearer. Codice:app/auth/dependencies.py:get_current_user· Test:tests/unit/test_users.py,tests/unit/test_bug_reports.py -
[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 un500) conADMIN_TOKENdel.env. UnADMIN_TOKENvuoto nega tutto, anche un header vuoto. Il rifiuto è403con il corpo non strutturato{"detail": "invalid admin token"}. Non c'è identità per singolo operatore (vedi sicurezza.md). Codice:app/api/routes/admin.py:require_admin· Test:tests/unit/test_admin.py -
[API-09] Intestazioni di cache:
- le quattro pagine (
/,/admin,/guida,/report-bug) rispondono conCache-Control: no-store; - il flusso SSE risponde con
Cache-Control: no-storeeX-Accel-Buffering: no; - il QR risponde con
Cache-Control: private, max-age=86400, immutable; - le risposte JSON e i file statici serviti dal mount (
/app.js,/style.css,/i18n.js,/index.html…) non hannoCache-Control: i file statici portano soloETag/Last-Modifiedper la validazione condizionale. 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
- le quattro pagine (
-
[API-10] La documentazione automatica di FastAPI (
/docs,/redoc,/openapi.json) è disattivata a meno cheENABLE_API_DOCSnon sia impostato a vero nel.env: elencherebbe a chiunque l'intera superficie dell'API, endpoint admin compresi. Disattivata, quei percorsi rispondono404. Codice:app/main.py(costruttoreFastAPI),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), 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
detailstrutturato:{ "detail": { "code": "insufficient_balance", "message": "insufficient balance", "params": {"required_sats": 1000000000} } }code— identificatore stabile, insnake_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 sottoclassiBetError,WithdrawalError) nei servizi, oppure direttamente conhttp_errornelle 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 /betsePOST /withdrawalssollevati dal servizio rispondono400, trannebroadcast_failed, che risponde502: è la rete ad aver rifiutato la transazione, non il chiamante. Codice:app/api/routes/bets.py:create_bet,app/api/routes/withdrawals.py:create_withdrawal· Test:tests/unit/test_bets.py -
[API-14] Qualunque eccezione non gestita risponde
500con lo stesso formato strutturato, codiceinternal_error, messaggio"internal server error",paramsvuoto. 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
422nel formato nativo di FastAPI:detailè una lista di oggetti{type, loc, msg, input, ctx?}, in inglese, uno per campo invalido (anche il JSON malformato arriva così, contype: "json_invalid"). Non c'è uncode: il frontend riconosce la lista e mostra il messaggio generico tradottoerror.invalid_request. Codice: FastAPI (nessun gestore personalizzato),app/static/app.js:apiErrorMessage· Test:tests/unit/test_users.py,tests/unit/test_admin.py,tests/unit/test_bug_reports.py -
[API-16] Alcune risposte d'errore non seguono il formato strutturato e hanno
detailcome semplice stringa inglese:Stato detailDove 401"Not authenticated"header Authorizationassente su un endpoint Bearer (FastAPI)403"invalid admin token"ogni endpoint /admin/*404"user not found"GET /admin/users/{id}/privkey,POST /admin/users/{id}/reset-password404"bug report not found"POST /admin/bug-reports/{id}/status404"Not Found"percorso inesistente (anche un metodo GETsu un percorso che esiste solo inPOST, perché lo intercetta il mount dei file statici)405"Method Not Allowed"metodo diverso da GET/HEADsu un percorso non definito da un router503"too many concurrent update streams"GET /rounds/streamoltre il tetto globaleIl frontend mostra la stringa così com'è. 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· Test:tests/unit/test_admin.py,tests/unit/test_bug_reports.py -
[API-17] Il client traduce
codecon la chiaveerror.<code>diapp/static/i18n.js, passandoparamsall'interpolazione e aggiungendo per ogni parametro numerico<nome>_satsun gemello<nome>_plmgià formattato in PLM; se la chiave non esiste mostramessage. Ogni codice che l'API può restituire deve avere la sua chiaveerror.<code>in tutte e sette le lingue (en, it, es, fr, de, ru, zh). Un401con codicesession_expiredriporta 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 usernamestringa 3–32 caratteri, solo A-Z a-z 0-9 _ . -passwordstringa 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). -
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; avvia (senza attenderla, best effort) la sottoscrizione Electrum dell'indirizzo. Non scrive audit e 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
-
POST /auth/login
- [API-19] Apre una sessione.
- Autenticazione: nessuna.
- Corpo:
{"username": stringa, "password": stringa}, senza vincoli di lunghezza. Lo username è confrontato senza distinzione tra maiuscole e minuscole. - 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 idintero identificativo dell'utente usernamestringa come digitato alla registrazione addressstringa indirizzo permanente (deposito, vincite, resto) balance_satsintero saldo confermato e spendibile ( User.cached_balance_sats); è quello su cui si basano scommesse e prelievipending_balance_satsintero saldo comprensivo del resto ancora non confermato di scommesse/prelievi in volo — il valore da mostrare; è un totale, non una differenza has_pendingbooleano vero se esiste almeno una transazione in volo dell'utente (il frontend mostra il saldo in ambra) created_atstringa 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}. - Risposta
200:{"access_token": <JWT>}— un nuovo token per la sessione corrente. - Errori, nell'ordine in cui sono verificati:
401di autenticazione;401 current_password_incorrect;400 password_too_short(params.minimum= 8). La lunghezza minima è verificata nel gestore (quindi400, non422come alla registrazione) e non c'è una lunghezza massima. - Effetti: sostituisce l'hash e incrementa
User.token_version: ogni token emesso prima, compreso quello usato per questa richiesta, da quel momento riceve401 session_expired. Non scrive audit e non notifica l'SSE. Nessun limite di frequenza. Codice:app/api/routes/users.py:change_password· Test:tests/unit/test_users.py
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/currentmostrava il vincitore.-
Autenticazione: Bearer.
-
Risposta
200:Campo Tipo Significato round_idintero o nulll'ultimo round in stato closedcon una riga di partecipazione dell'utente;nullse non ce n'è nessunowonbooleano vero se l'utente è il vincitore di quel round amount_satsintero o nullimporto effettivamente pagato al vincitore ( Round.winner_amount_sats, già al netto della commissione del pagamento), solo sewonSenza 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:Campo Tipo Significato round_idintero round a cui la scommessa è iscritta bet_txidstringa txid della transazione trasmessa bet_amount_satsintero importo arrivato al montepremi, cioè il costo meno la commissione di rete statusstringa stato del partecipante, "broadcast" -
Errori:
503 network_unavailable(nessuna connessione Electrum, verificato prima di ogni altro controllo); poi, tutti400salvo indicazione:no_round_open,round_closing,already_betting,round_full(params.max_participants),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,502 broadcast_failed. Il significato di ciascuno è nel catalogo. -
Serializzazione: la richiesta tiene il blocco per-utente condiviso con
POST /withdrawalsper tutta la costruzione e la trasmissione. -
Effetti: può aprire un round se nessuno è attivo e le condizioni lo permettono; segna le monete spese, crea partecipante e transazione in sospeso, trasmette. Audit
bet_placedin caso di successo,bet_broadcast_failedse la rete rifiuta; notifica SSE in entrambi i casi (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
-
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_addressstringa verificato nel servizio (bech32 valido con prefisso plm1)amount_satsintero nessun vincolo di schema; il minimo è verificato nel servizio -
Risposta
201:Campo Tipo Significato txidstringa txid della transazione trasmessa amount_requested_satsintero importo chiesto amount_sent_satsintero importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto statusstringa stato del prelievo, "broadcast" -
Errori:
422;503 network_unavailable(verificato prima della validazione dell'indirizzo); poi400:invalid_address,withdrawal_to_own_address,amount_below_minimum(params.minimum_sats= costo attuale della scommessa),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;502 broadcast_failed. -
Serializzazione: stesso blocco per-utente di
POST /bets. -
Effetti: segna le monete spese, crea prelievo e transazione in sospeso, trasmette. Audit
withdrawal_sent, oppurewithdrawal_broadcast_failedcon il prelievo lasciato in statofailed; notifica SSE in entrambi i casi. Codice:app/api/routes/withdrawals.py:create_withdrawal,app/withdrawals/service.py:request_withdrawal· Test:tests/unit/test_withdrawals.py
-
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
/adminleggechain_tip_heightda qui).-
Autenticazione: facoltativa (serve solo per
user_played). -
Risposta
200— tutti i campi sono sempre presenti:Campo Tipo Significato server_timestringa ISO ora del server, per allineare i conti alla rovescia del client round_idintero o nullround attivo; nullse non ce n'èstatusstringa o nullopen,closing,drawingopaying_out; un roundclosednon compare maiopened_atstringa ISO o nullapertura del round closes_atstringa ISO o nullscadenza per le scommesse: opened_at+ la durata fissata all'apertura del round (non quella attuale di configurazione)participant_countintero partecipanti confermati, cioè quelli tra cui si estrae bet_amount_satsintero costo della scommessa attualmente configurato (quello che pagherebbe una nuova scommessa) jackpot_satsintero 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre pending_participant_countintero tutti i partecipanti, confermati e in volo (totale, non differenza) pending_jackpot_satsintero 70% della somma di tutte le scommesse, confermate e in volo has_pending_betsbooleano vero se pending_participant_count>participant_countdraw_animation_secondsintero durata minima dell'animazione dell'estrazione, dalla configurazione winner_user_idintero o nullvincitore, valorizzato dall'estrazione in poi ( paying_out)winner_amount_satsintero o nullimporto al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita draw_block_heightintero o nullaltezza del blocco usato per l'estrazione draw_block_hashstringa o nullhash di quel blocco (il seme dell'estrazione) draw_waiting_sincestringa ISO o nullinizio dell'attesa del blocco; valorizzato solo mentre statusèdrawingchain_tip_heightintero o nullultima altezza nota della catena; nullfinché non è notalottery_pausedbooleano pausa di manutenzione attiva lottery_configuredbooleano falso finché manca configurazione indispensabile (oggi: fee_address), e quindi nessun round può aprirsiuser_playedbooleano vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo Senza round attivo sono valorizzati solo
server_time,bet_amount_sats,draw_animation_seconds,chain_tip_height,lottery_pausedelottery_configured; gli altri valgononull,0ofalse. 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
-
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, conCache-Control: no-storeeX-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:
503con{"detail": "too many concurrent update streams"}(non strutturato) quando i flussi aperti in totale sono già 500; il clientEventSourceriprova da solo e intanto resta il polling. - Effetti: nessuno.
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
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 prefissoplm1(stessa verifica dei prelievi). - Risposta
200:image/png, conCache-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
- Autenticazione: nessuna (un
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 descriptionstringa 1–2000 caratteri (lunghezza misurata sul testo ricevuto); spazi iniziali e finali rimossi; non può essere vuota dopo la rimozione contactstringa o nullfacoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa null -
Risposta
201:{"id": intero}. -
Errori:
422. -
Effetti: inserisce la segnalazione in stato
open. Nessun audit, nessuna notifica SSE, nessun limite di frequenza. Codice:app/api/routes/bug_reports.py:create_bug_report,app/api/routes/bug_reports.py:BugReportCreate· Test:tests/unit/test_bug_reports.py
-
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,readoresolved. Il campocontactnon è 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 senza di esso. 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", "paused"}. Codice:app/api/routes/admin.py:read_config· Test:tests/unit/test_admin.py
- Risposta
PUT /admin/config
- [API-31] Modifica a caldo uno o più parametri operativi.
-
Corpo: tutti i campi facoltativi; quelli assenti o
nullnon cambiano.Campo Vincoli fee_addressspazi rimossi; bech32 PLM valido ( plm1…). Non può essere svuotato tramite l'APIbet_amount_sats> 0 e ≤ 10 000 000 000 000 (100 000 PLM) round_duration_seconds30 – 604 800 (7 giorni) round_cooldown_seconds0 – 86 400 fee_rate_sat_vb1 – 10 000 ( MAX_FEE_RATE_SAT_VB)rbf_timeout_seconds60 – 604 800 draw_animation_seconds0 – 600 pausednon è accettato qui (è ignorato come campo sconosciuto): la pausa ha i suoi endpoint registrati nell'audit. -
Risposta
200: la configurazione completa, come API-30. -
Errori:
422per qualunque vincolo violato (nessun campo viene applicato). -
Effetti: scrive
config_updatedcon i valori prima/dopo dei soli campi davvero cambiati; nessuna scrittura se nulla cambia. Durata e pausa tra round si applicano dal round successivo. Nessuna notifica SSE. Codice:app/api/routes/admin.py:update_config,app/api/routes/admin.py:RoundConfigUpdate· Test:tests/unit/test_admin.py
-
POST /admin/pause e POST /admin/resume
- [API-32] Attivano e disattivano la pausa di manutenzione: impedisce
l'apertura del prossimo round, il round in corso prosegue fino al
pagamento.
- Corpo: nessuno. Risposta
200: la configurazione completa. - Effetti: impostano
pauseda vero/falso e scrivonolottery_paused/lottery_resumeda ogni chiamata, anche se lo stato era già quello. Nessuna notifica SSE:/vedelottery_pausedal polling successivo. Codice:app/api/routes/admin.py:pause_lottery,app/api/routes/admin.py:resume_lottery· Test:tests/unit/test_admin.py
- Corpo: nessuno. Risposta
GET /admin/users
- [API-33] Elenco di tutti gli utenti, per
idcrescente, senza paginazione né limite.- 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
- Risposta
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_idintero. - Risposta
200:{"address", "wif"}. - Errori:
404"user not found"(non strutturato);422seuser_idnon è un intero. - Effetti: scrive
admin_privkey_accesseda ogni accesso. Codice:app/api/routes/admin.py:user_privkey· Test:tests/unit/test_admin.py
- Parametro:
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";422. - Effetti: sostituisce l'hash, incrementa
token_version(ogni sessione aperta dell'utente riceve401 session_expired), scriveadmin_password_reset. Codice:app/api/routes/admin.py:reset_user_password· Test:tests/unit/test_admin.py
- Risposta
GET /admin/rounds
- [API-36] Storico dei round, dal più recente.
- Query:
limitintero 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"}; i campi non ancora valorizzati sononull. Codice:app/api/routes/admin.py:list_rounds· Test:tests/unit/test_admin.py
- Query:
GET /admin/audit-log
- [API-37] Registro di audit, dalla voce più recente.
- Query:
limitintero 1–500, predefinito 200. - Risposta
200: lista di{"id", "event_type", "payload" (oggetto), "user_id", "round_id", "created_at"}. Codice:app/api/routes/admin.py:list_audit_log· Test:tests/unit/test_admin.py
- Query:
GET /admin/pending-transactions
- [API-38] Transazioni tracciate (scommesse, pagamenti, prelievi), dalla
più recente.
- Query:
limitintero 1–500, predefinito 50;statusfacoltativo, 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 espostifailure_reason,last_broadcast_atné 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
- Query:
GET /admin/bug-reports
- [API-39] Tutte le segnalazioni, dalla più recente.
- Query:
limitintero 1–500, predefinito 200. - Risposta
200: lista di{"id", "description", "contact", "user_id", "username", "status", "created_at"};user_ideusernamesononullper le segnalazioni anonime. Codice:app/api/routes/admin.py:list_bug_reports· Test:tests/unit/test_bug_reports.py
- Query:
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". - 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
- Corpo:
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 usachain_tip_heightdiGET /rounds/current. Codice:app/main.py:health
Pagine e file statici
-
[API-42]
GET /,/admin,/guidae/report-bugrestituiscono rispettivamenteindex.html,admin.html,guida.htmlereport-bug.htmldiapp/static/, conCache-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/, conhtml=True): i file esistenti rispondono200, gli altri404 {"detail": "Not Found"}, un metodo diverso daGET/HEAD405. 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 diapp/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 |
retry_after_seconds |
quota di registrazione o blocco per tentativi di login falliti | 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 |
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ì |
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).