- 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
452 lines
13 KiB
Markdown
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! 🎉
|