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

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 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:

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:

  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

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! 🎉