- 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
13 KiB
13 KiB
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 tokenget_current_user()- Autenticazione JWTApiTokenmodel - DatabaseApiTokenCreate,ApiTokenResponseschemas - Già creati in T35
Flusso Token API:
- Utente autenticato (JWT) richiede nuovo token
- Sistema genera token (
generate_api_token()) - Token in plaintext mostrato UNA SOLA VOLTA all'utente
- Hash SHA-256 salvato nel database
- Utente usa token per chiamare API pubblica (/api/v1/*)
- 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:
- Verifica limite token per utente
- Genera token:
generate_api_token()→ (plaintext, hash) - Salva nel DB:
ApiToken(user_id, token_hash, name) - Ritorna:
ApiTokenCreateResponsecon token PLAINTEXT (solo questa volta!)
- Errori: limite raggiunto (400), nome invalido (422)
Implementazione:
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:
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:
@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:
- RED: Scrivi test che fallisce (prima del codice!)
- GREEN: Implementa codice minimo per passare il test
- 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
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
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
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
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! 🎉