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
This commit is contained in:
451
prompt/prompt-ingaggio-gestione-tokens.md
Normal file
451
prompt/prompt-ingaggio-gestione-tokens.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# 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! 🎉
|
||||
Reference in New Issue
Block a user