Replace flowchart/ with a traceable specification in docs/

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>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-28 12:16:01 +02:00
1 parent 526a649c8b
commit 61efbb0e37
29 files changed
+6964 -310

No files matched your search

+800
View File
@@ -0,0 +1,800 @@
# 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](registrazione-e-depositi.md) |
| Scommesse e prelievi | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) |
| Round, estrazione, pagamento | [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) |
| Ciclo di vita delle transazioni, RBF | [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) |
| Chiavi, JWT, limiti di frequenza, header di sicurezza | [sicurezza.md](sicurezza.md) |
| Eventi del registro di audit | [audit-log.md](audit-log.md) |
| Tabelle e campi | [modello-dati.md](modello-dati.md) |
| Frontend, canale SSE lato architettura, i18n | [architettura.md](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-authregister) |
| `POST` | `/auth/login` | nessuna | [API-19](#post-authlogin) |
| `GET` | `/users/me` | Bearer | [API-20](#get-usersme) |
| `POST` | `/users/me/change-password` | Bearer | [API-21](#post-usersmechange-password) |
| `GET` | `/users/me/last-round-result` | Bearer | [API-22](#get-usersmelast-round-result) |
| `POST` | `/bets` | Bearer | [API-23](#post-bets) |
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
| `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) |
| `GET` | `/admin/config` | admin | [API-30](#get-adminconfig) |
| `PUT` | `/admin/config` | admin | [API-31](#put-adminconfig) |
| `POST` | `/admin/pause` | admin | [API-32](#post-adminpause-e-post-adminresume) |
| `POST` | `/admin/resume` | admin | [API-32](#post-adminpause-e-post-adminresume) |
| `GET` | `/admin/users` | admin | [API-33](#get-adminusers) |
| `GET` | `/admin/users/{user_id}/privkey` | admin | [API-34](#get-adminusersuser_idprivkey) |
| `POST` | `/admin/users/{user_id}/reset-password` | admin | [API-35](#post-adminusersuser_idreset-password) |
| `GET` | `/admin/rounds` | admin | [API-36](#get-adminrounds) |
| `GET` | `/admin/audit-log` | admin | [API-37](#get-adminaudit-log) |
| `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` | `/health` | nessuna | [API-41](#get-health) |
| `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) |
## 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](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`) è invece intercettata da FastAPI prima del
codice applicativo: `401` con il corpo non strutturato
`{"detail": "Not authenticated"}` e header `WWW-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 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
(vedi [sicurezza.md](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 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`;
- 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.
*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`
- **[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), 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).
*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:
```json
{
"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.
*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 `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 **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),
`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 `detail` come semplice stringa inglese:
| 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 |
Il 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 `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).
- **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
[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`
#### 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](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](registrazione-e-depositi.md).
- **Errori:** `401` (vedi [API-06](#convenzioni-generali)).
- **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: `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.
- **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` ·
*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/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](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:** nessuno.
- **Risposta `201`:**
| 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"` |
- **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
[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).
*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](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`:**
| Campo | Tipo | Significato |
|---|---|---|
| `txid` | stringa | txid della transazione trasmessa |
| `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"` |
- **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`.
- **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.
*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 `/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 della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) |
| `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 (il seme dell'estrazione) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota |
| `lottery_paused` | booleano | pausa di manutenzione attiva |
| `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 |
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
[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)).
- **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](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` 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.
- **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 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`.
- **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`,
`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](#convenzioni-generali)) e rispondono `403` senza di esso. 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
[modello-dati.md](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`
#### 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 |
`paused` **non** è accettato qui (è ignorato come campo sconosciuto): 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.
*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 `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.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/users
- **[API-33]** Elenco di **tutti** gli utenti, per `id` crescente, 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`
#### 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](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.
- **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"`; `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"}`; i campi non ancora valorizzati sono
`null`.
*Codice:* `app/api/routes/admin.py:list_rounds` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/audit-log
- **[API-37]** Registro di audit, dalla voce più recente.
- **Query:** `limit` intero 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`
#### 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](transazioni-e-riconciliazione.md).
*Codice:* `app/api/routes/admin.py:list_pending_transactions` ·
*Test:* `tests/unit/test_admin.py`
#### 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](#get-adminbug-reports).
- **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`
### 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`.
*Codice:* `app/main.py:health`
#### 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` | `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](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](#contratto-degli-errori)),
`invalid_amount` (validazione lato client del modulo di prelievo) e
`guide_unavailable` (attualmente non usata da nessun file).