# 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 " \ -H "Content-Type: application/json" \ -d '{"name": "Test Token"}' # Usa il token ricevuto curl -H "Authorization: Bearer " \ 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! πŸŽ‰