This commit is contained in:
davide committed 2026-09-28 15:36:38 +02:00
1 parent 61efbb0e37
commit c5e7daa9c7
15 files changed
+4514 -1172

No files matched your search

+344 -106
View File
@@ -34,6 +34,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) — *da implementare (B-73)* |
| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) |
| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) |
| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) |
@@ -49,7 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-86, B-92)* |
| `GET` | `/health` | nessuna | [API-41](#get-health) |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
@@ -108,11 +111,15 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
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`) è invece intercettata da FastAPI prima del
codice applicativo: `401` con il corpo non strutturato
`{"detail": "Not authenticated"}` e header `WWW-Authenticate: Bearer`.
(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 è
@@ -125,11 +132,13 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[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 il corpo non strutturato
`{"detail": "invalid admin token"}`. Non c'è identità per singolo operatore
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](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
@@ -137,13 +146,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- 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`;
- le risposte JSON e i file statici serviti dal mount (`/app.js`,
`/style.css`, `/i18n.js`, `/index.html`…) **non** hanno `Cache-Control`:
i file statici portano solo `ETag`/`Last-Modified` per la validazione
condizionale.
- 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/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
@@ -154,7 +169,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*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**
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](sicurezza.md).
@@ -191,9 +207,20 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[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.
`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](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
@@ -204,36 +231,47 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*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 **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ì, con
`type: "json_invalid"`). Non c'è un `code`: il frontend riconosce la lista e
mostra il messaggio generico tradotto `error.invalid_request`.
*Codice:* FastAPI (nessun gestore personalizzato),
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]** Alcune risposte d'errore **non** seguono il formato strutturato
e hanno `detail` come semplice stringa inglese:
- **[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 | `detail` | Dove |
|---|---|---|
| `401` | `"Not authenticated"` | header `Authorization` assente 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-password` |
| `404` | `"bug report not found"` | `POST /admin/bug-reports/{id}/status` |
| `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) |
| `405` | `"Method Not Allowed"` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router |
| `503` | `"too many concurrent update streams"` | `GET /rounds/stream` oltre il tetto globale |
| 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"` |
Il frontend mostra la stringa così com'è.
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/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
@@ -267,27 +305,37 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
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).
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](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](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
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](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}`, senza vincoli di
lunghezza. Lo username è confrontato senza distinzione tra maiuscole e
minuscole.
- **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` —
@@ -332,20 +380,29 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[API-21]** Cambio password self-service.
- **Autenticazione:** Bearer.
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`.
- **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;
`401 current_password_incorrect`; `400 password_too_short`
(`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi
`400`, non `422` come alla registrazione) e **non** c'è una lunghezza
massima.
`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](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`. Non scrive audit e non notifica
l'SSE. Nessun limite di frequenza.
*Codice:* `app/api/routes/users.py:change_password` ·
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
@@ -376,34 +433,46 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:** nessuno.
- **Risposta `201`:**
- **Risposta `201`** (il server attivo ha accettato la transazione) o
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
| Campo | Tipo | Significato |
|---|---|---|
| `round_id` | intero | round a cui la scommessa è iscritta |
| `bet_txid` | stringa | txid della transazione trasmessa |
| `bet_amount_sats` | intero | importo arrivato al montepremi, cioè il costo **meno** la commissione di rete |
| `status` | stringa | stato del partecipante, `"broadcast"` |
| `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`), `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
`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`, `502 broadcast_failed` (solo rifiuto
esplicito). Il significato di ciascuno è nel
[catalogo](#catalogo-dei-codici-di-errore).
- **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; segna le monete spese, crea partecipante e transazione in
sospeso, trasmette. Audit `bet_placed` in caso di successo,
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in entrambi i casi
(e all'apertura del round).
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
@@ -417,31 +486,43 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `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`:**
- **Risposta `201`** (il server attivo ha accettato la transazione) o
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
| Campo | Tipo | Significato |
|---|---|---|
| `txid` | stringa | txid della transazione trasmessa |
| `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"` |
| `status` | stringa | stato del prelievo: `"broadcast"` con `201`, `"building"` con `202` |
- **Errori:** `422`; `503 network_unavailable` (verificato **prima** della
validazione dell'indirizzo); poi `400`: `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`.
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`; `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. Audit `withdrawal_sent`, oppure
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`;
notifica SSE in entrambi i casi.
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
@@ -461,7 +542,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `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 della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) |
| `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 |
@@ -470,17 +551,27 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `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 (il seme dell'estrazione) |
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
| `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 | pausa di manutenzione attiva |
| `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](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](#get-roundsround_idproof), 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` e
`lottery_configured`; gli altri valgono `null`, `0` o `false`. Il
significato di dominio di ogni fase è in
`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](ciclo-di-vita-del-round.md).
- **Errori:** nessuno previsto (un token invalido non è un errore,
[API-07](#convenzioni-generali)).
@@ -491,6 +582,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`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
@@ -509,14 +603,88 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **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` con `{"detail": "too many concurrent update streams"}`
(non strutturato) quando i flussi aperti in totale sono già **500**; il
client `EventSource` riprova da solo e intanto resta il polling.
- **Errori:** `503 too_many_streams` (formato strutturato,
[API-16](#contratto-degli-errori)) 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](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](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](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
@@ -553,12 +721,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `contact` | stringa o `null` | facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa `null` |
- **Risposta `201`:** `{"id": intero}`.
- **Errori:** `422`.
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`).
- **Limite di frequenza:** una quota di segnalazioni per IP
([API-11](#convenzioni-generali)) in una finestra mobile, verificata prima
dell'inserimento e consumata solo da un inserimento riuscito, anche per un
chiamante autenticato; soglie in [sicurezza.md](sicurezza.md) (B-94).
- **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit,
nessuna notifica SSE, nessun limite di frequenza.
nessuna notifica SSE.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/api/routes/bug_reports.py:BugReportCreate` ·
`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
@@ -575,7 +750,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
### Amministrazione
Tutti gli endpoint `/admin/*` richiedono `X-Admin-Token`
([API-08](#convenzioni-generali)) e rispondono `403` senza di esso. Gli eventi
([API-08](#convenzioni-generali)) 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](#get-roundsround_idproof)). Gli eventi
di audit citati sono descritti in [audit-log.md](audit-log.md); il significato
dei parametri operativi in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
@@ -587,9 +764,13 @@ dei parametri operativi in
`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"}`.
"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
@@ -606,41 +787,73 @@ dei parametri operativi in
| `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)* |
`paused` **non** è accettato qui (è ignorato come campo sconosciuto): la
I limiti sono quelli di [modello-dati.md](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](#get-adminconfig).
- **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 e pausa
tra round si applicano dal round successivo. Nessuna notifica SSE.
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 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 `paused` a vero/falso e scrivono `lottery_paused` /
`lottery_resumed` a **ogni** chiamata, anche se lo stato era già quello.
Nessuna notifica SSE: `/` vede `lottery_paused` al polling successivo.
- **[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](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](#get-adminconfig), 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 di **tutti** gli utenti, per `id` crescente, senza
paginazione né limite.
- **[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
@@ -649,8 +862,9 @@ dei parametri operativi in
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
- **Parametro:** `user_id` intero.
- **Risposta `200`:** `{"address", "wif"}`.
- **Errori:** `404` `"user not found"` (non strutturato); `422` se `user_id`
non è un intero.
- **Errori:** `404 user_not_found` (oggi non strutturato, `"user not
found"`, [API-16](#contratto-degli-errori)); `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`
@@ -660,7 +874,8 @@ dei parametri operativi in
- **[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`.
- **Errori:** `404 user_not_found` (oggi non strutturato,
[API-16](#contratto-degli-errori)); `422`.
- **Effetti:** sostituisce l'hash, incrementa `token_version` (ogni sessione
aperta dell'utente riceve `401 session_expired`), scrive
`admin_password_reset`.
@@ -674,19 +889,40 @@ dei parametri operativi in
- **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 sono
`null`.
"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](#get-roundsround_idproof).
*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.
- **Query:** `limit` intero 1–500, predefinito 200.
- **[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"}`.
"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
@@ -721,7 +957,9 @@ dei parametri operativi in
transizione è ammessa, anche all'indietro.
- **Risposta `200`:** la segnalazione aggiornata, come
[API-39](#get-adminbug-reports).
- **Errori:** `422` (stato sconosciuto); `404` `"bug report not found"`.
- **Errori:** `422` (stato sconosciuto); `404 bug_report_not_found` (oggi
non strutturato, `"bug report not found"`,
[API-16](#contratto-degli-errori)).
- **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` ·