Files
openrouter-watcher/prompt/prompt-ingaggio-gestione-tokens.md
Luca Sacchi Ricciardi 3ae5d736ce feat(tasks): T55-T58 implement background tasks for OpenRouter sync
- T55: Setup APScheduler with AsyncIOScheduler and @scheduled_job decorator
- T56: Implement hourly usage stats sync from OpenRouter API
- T57: Implement daily API key validation job
- T58: Implement weekly cleanup job for old usage stats
- Add usage_stats_retention_days config option
- Integrate scheduler with FastAPI lifespan events
- Add 26 unit tests for scheduler, sync, and cleanup tasks
- Add apscheduler to requirements.txt

The background tasks now automatically:
- Sync usage stats every hour from OpenRouter
- Validate API keys daily at 2 AM UTC
- Clean up old data weekly on Sunday at 3 AM UTC
2026-04-07 17:41:24 +02:00

452 lines
13 KiB
Markdown

# Prompt di Ingaggio: Gestione Token API (T41-T43)
## 🎯 MISSIONE
Implementare la fase **Gestione Token API** per permettere agli utenti di generare, visualizzare e revocare i loro token API pubblici.
**Task da completare:** T41, T42, T43
---
## 📋 CONTESTO
**AGENTE:** @tdd-developer
**Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher`
**Stato Attuale:**
- ✅ Setup (T01-T05): 59 test
- ✅ Database & Models (T06-T11): 73 test
- ✅ Security Services (T12-T16): 70 test
- ✅ User Authentication (T17-T22): 34 test
- ✅ Gestione API Keys (T23-T29): 61 test
- ✅ Dashboard & Statistiche (T30-T34): 27 test
- ✅ API Pubblica (T35-T40): 70 test
- 🎯 **Totale: 394+ test, ~98% coverage sui moduli implementati**
**Servizi Pronti:**
- `generate_api_token()`, `verify_api_token()` - Generazione e verifica token
- `get_current_user()` - Autenticazione JWT
- `ApiToken` model - Database
- `ApiTokenCreate`, `ApiTokenResponse` schemas - Già creati in T35
**Flusso Token API:**
1. Utente autenticato (JWT) richiede nuovo token
2. Sistema genera token (`generate_api_token()`)
3. Token in plaintext mostrato UNA SOLA VOLTA all'utente
4. Hash SHA-256 salvato nel database
5. Utente usa token per chiamare API pubblica (/api/v1/*)
6. Utente può revocare token in qualsiasi momento
**Documentazione:**
- PRD: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md` (sezione 2.4.1)
- Architecture: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md`
---
## 🔧 TASK DA IMPLEMENTARE
### T41: Implementare POST /api/tokens (Generazione Token)
**File:** `src/openrouter_monitor/routers/tokens.py`
**Requisiti:**
- Endpoint: `POST /api/tokens`
- Auth: JWT richiesto (`get_current_user`)
- Body: `ApiTokenCreate` (name: str, 1-100 chars)
- Limite: MAX_API_TOKENS_PER_USER (default 5, configurabile)
- Logica:
1. Verifica limite token per utente
2. Genera token: `generate_api_token()` → (plaintext, hash)
3. Salva nel DB: `ApiToken(user_id, token_hash, name)`
4. Ritorna: `ApiTokenCreateResponse` con token PLAINTEXT (solo questa volta!)
- Errori: limite raggiunto (400), nome invalido (422)
**Implementazione:**
```python
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from openrouter_monitor.config import get_settings
from openrouter_monitor.database import get_db
from openrouter_monitor.dependencies import get_current_user
from openrouter_monitor.models import ApiToken, User
from openrouter_monitor.schemas import ApiTokenCreate, ApiTokenCreateResponse
from openrouter_monitor.services.token import generate_api_token
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
settings = get_settings()
@router.post(
"",
response_model=ApiTokenCreateResponse,
status_code=status.HTTP_201_CREATED
)
async def create_api_token(
token_data: ApiTokenCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new API token for programmatic access.
The token is shown ONLY ONCE in the response. Store it securely!
Max 5 tokens per user (configurable).
"""
# Check token limit
current_count = db.query(func.count(ApiToken.id)).filter(
ApiToken.user_id == current_user.id,
ApiToken.is_active == True
).scalar()
if current_count >= settings.max_api_tokens_per_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum {settings.max_api_tokens_per_user} API tokens allowed"
)
# Generate token
plaintext_token, token_hash = generate_api_token()
# Save to database (only hash!)
api_token = ApiToken(
user_id=current_user.id,
token_hash=token_hash,
name=token_data.name,
is_active=True
)
db.add(api_token)
db.commit()
db.refresh(api_token)
# Return with plaintext token (only shown once!)
return ApiTokenCreateResponse(
id=api_token.id,
name=api_token.name,
token=plaintext_token, # ⚠️ ONLY SHOWN ONCE!
created_at=api_token.created_at
)
```
**Test:** `tests/unit/routers/test_tokens.py`
- Test creazione successo (201) con token in risposta
- Test limite massimo raggiunto (400)
- Test nome troppo lungo (422)
- Test senza autenticazione (401)
- Test token salvato come hash nel DB (non plaintext)
---
### T42: Implementare GET /api/tokens (Lista Token)
**File:** `src/openrouter_monitor/routers/tokens.py`
**Requisiti:**
- Endpoint: `GET /api/tokens`
- Auth: JWT richiesto
- Ritorna: lista di `ApiTokenResponse` (senza token plaintext!)
- Include: id, name, created_at, last_used_at, is_active
- Ordinamento: created_at DESC (più recenti prima)
- NO token values nelle risposte (mai!)
**Implementazione:**
```python
from typing import List
@router.get("", response_model=List[ApiTokenResponse])
async def list_api_tokens(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all API tokens for the current user.
Token values are NEVER exposed. Only metadata is shown.
"""
tokens = db.query(ApiToken).filter(
ApiToken.user_id == current_user.id
).order_by(ApiToken.created_at.desc()).all()
return [
ApiTokenResponse(
id=t.id,
name=t.name,
created_at=t.created_at,
last_used_at=t.last_used_at,
is_active=t.is_active
)
for t in tokens
]
```
**Test:**
- Test lista vuota (utente senza token)
- Test lista con token multipli
- Test ordinamento (più recenti prima)
- Test NO token values in risposta
- Test senza autenticazione (401)
---
### T43: Implementare DELETE /api/tokens/{id} (Revoca Token)
**File:** `src/openrouter_monitor/routers/tokens.py`
**Requisiti:**
- Endpoint: `DELETE /api/tokens/{token_id}`
- Auth: JWT richiesto
- Verifica: token esiste e appartiene all'utente corrente
- Soft delete: set `is_active = False` (non eliminare dal DB)
- Ritorna: 204 No Content
- Token revocato non può più essere usato per API pubblica
- Errori: token non trovato (404), non autorizzato (403)
**Implementazione:**
```python
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_api_token(
token_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Revoke an API token.
The token is soft-deleted (is_active=False) and cannot be used anymore.
This action cannot be undone.
"""
api_token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
if not api_token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API token not found"
)
if api_token.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to revoke this token"
)
# Soft delete: mark as inactive
api_token.is_active = False
db.commit()
return None
```
**Test:**
- Test revoca successo (204)
- Test token non trovato (404)
- Test token di altro utente (403)
- Test token già revocato (idempotent)
- Test token revocato non funziona più su API pubblica
- Test senza autenticazione (401)
---
## 🔄 WORKFLOW TDD
Per **OGNI** task:
1. **RED**: Scrivi test che fallisce (prima del codice!)
2. **GREEN**: Implementa codice minimo per passare il test
3. **REFACTOR**: Migliora codice, test rimangono verdi
---
## 📁 STRUTTURA FILE DA CREARE/MODIFICARE
```
src/openrouter_monitor/
├── routers/
│ ├── __init__.py # Aggiungi export tokens router
│ └── tokens.py # T41, T42, T43
└── main.py # Registra tokens router
tests/unit/
└── routers/
└── test_tokens.py # T41-T43 tests
```
---
## 🧪 ESEMPI TEST
### Test Creazione Token
```python
def test_create_api_token_success_returns_201_and_token(client, auth_token):
response = client.post(
"/api/tokens",
json={"name": "My Integration Token"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 201
data = response.json()
assert "token" in data # Plaintext shown only here!
assert data["name"] == "My Integration Token"
assert data["token"].startswith("or_api_")
```
### Test Lista Token
```python
def test_list_api_tokens_returns_no_token_values(client, auth_token, test_api_token):
response = client.get(
"/api/tokens",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert "token" not in data[0] # Never exposed!
assert "name" in data[0]
```
### Test Revoca Token
```python
def test_revoke_api_token_makes_it_invalid_for_public_api(
client, auth_token, test_api_token
):
# Revoke token
response = client.delete(
f"/api/tokens/{test_api_token.id}",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 204
# Try to use revoked token on public API
response = client.get(
"/api/v1/stats",
headers={"Authorization": f"Bearer {test_api_token.plaintext}"}
)
assert response.status_code == 401 # Unauthorized
```
---
## ✅ CRITERI DI ACCETTAZIONE
- [ ] T41: POST /api/tokens con generazione e limite
- [ ] T42: GET /api/tokens lista senza esporre token
- [ ] T43: DELETE /api/tokens/{id} revoca (soft delete)
- [ ] Token mostrato in plaintext SOLO alla creazione
- [ ] Hash SHA-256 salvato nel database
- [ ] Token revocato (is_active=False) non funziona su API pubblica
- [ ] Limite MAX_API_TOKENS_PER_USER configurabile
- [ ] Test completi coverage >= 90%
- [ ] 3 commit atomici con conventional commits
- [ ] progress.md aggiornato
---
## 📝 COMMIT MESSAGES
```
feat(tokens): T41 implement POST /api/tokens endpoint
feat(tokens): T42 implement GET /api/tokens endpoint
feat(tokens): T43 implement DELETE /api/tokens/{id} endpoint
```
---
## 🚀 VERIFICA FINALE
```bash
cd /home/google/Sources/LucaSacchiNet/openrouter-watcher
# Test tokens
pytest tests/unit/routers/test_tokens.py -v --cov=src/openrouter_monitor/routers
# Test integrazione: token creato funziona su API pubblica
pytest tests/unit/routers/test_public_api.py::test_public_api_with_valid_token -v
# Test completo
pytest tests/unit/ -v --cov=src/openrouter_monitor
# Verifica manuale
curl -X POST http://localhost:8000/api/tokens \
-H "Authorization: Bearer <jwt_token>" \
-H "Content-Type: application/json" \
-d '{"name": "Test Token"}'
# Usa il token ricevuto
curl -H "Authorization: Bearer <api_token>" \
http://localhost:8000/api/v1/stats
```
---
## 📊 FLUSSO COMPLETO TOKEN API
```
1. Utente autenticato (JWT)
2. POST /api/tokens {"name": "My Token"}
3. Server genera: (or_api_abc123..., hash_abc123...)
4. Salva hash nel DB
5. Ritorna: {"id": 1, "name": "My Token", "token": "or_api_abc123..."}
⚠️ Token mostrato SOLO questa volta!
6. Utente salva token in modo sicuro
7. Usa token per chiamare API pubblica:
GET /api/v1/stats
Authorization: Bearer or_api_abc123...
8. Server verifica hash, aggiorna last_used_at
9. Utente può revocare token:
DELETE /api/tokens/1
10. Token revocato non funziona più
```
---
## 🔒 SICUREZZA CRITICA
### ⚠️ IMPORTANTE: Token in Plaintext
**DO:**
- ✅ Mostrare token in plaintext SOLO nella risposta POST /api/tokens
- ✅ Salvare SOLO hash SHA-256 nel database
- ✅ Documentare chiaramente che il token viene mostrato una sola volta
- ✅ Consigliare all'utente di salvarlo immediatamente
**DON'T:**
- ❌ MAI ritornare token plaintext in GET /api/tokens
- ❌ MAI loggare token in plaintext
- ❌ MAI salvare token plaintext nel database
- ❌ MAI permettere di recuperare token dopo la creazione
### Soft Delete vs Hard Delete
**Soft delete** (is_active=False) è preferito:
- Mantiene storico utilizzo
- Preverte errori utente (recupero impossibile con hard delete)
- Permette audit trail
- Il token non può più essere usato, ma rimane nel DB
---
## 📝 NOTE IMPORTANTI
- **Path assoluti**: Usa sempre `/home/google/Sources/LucaSacchiNet/openrouter-watcher/`
- **MAX_API_TOKENS_PER_USER**: Aggiungi a config.py (default 5)
- **Autenticazione**: Usa JWT (get_current_user), non API token
- **Verifica ownership**: Ogni operazione deve verificare user_id
- **Soft delete**: DELETE setta is_active=False, non rimuove dal DB
- **Rate limiting**: Non applicare a /api/tokens (gestito da JWT)
---
**AGENTE:** @tdd-developer
**INIZIA CON:** T41 - POST /api/tokens endpoint
**QUANDO FINITO:** MVP Fase 1 completato! 🎉