800 lines
43 KiB
Markdown
800 lines
43 KiB
Markdown
# 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).
|