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:
Luca Sacchi Ricciardi
2026-04-07 17:41:24 +02:00
parent 19a2c527a1
commit 3ae5d736ce
21 changed files with 3104 additions and 7 deletions

View File

@@ -0,0 +1,580 @@
# Prompt di Ingaggio: Background Tasks (T55-T58)
## 🎯 MISSIONE
Implementare i **Background Tasks** per sincronizzare automaticamente i dati da OpenRouter, validare API keys periodicamente e gestire la pulizia dei dati storici.
**Task da completare:** T55, T56, T57, T58
---
## 📋 CONTESTO
**AGENTE:** @tdd-developer
**Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher`
**Stato Attuale:**
- ✅ MVP Backend completato: 43/74 task (58%)
- ✅ 418+ test passanti, ~98% coverage
- ✅ Tutte le API REST implementate
- ✅ Docker support pronto
- 🎯 **Manca:** Sincronizzazione automatica dati da OpenRouter
**Perché questa fase è critica:**
Attualmente l'applicazione espone API per visualizzare statistiche, ma i dati in `UsageStats` sono vuoti (popolati solo manualmente). I background tasks sono necessari per:
1. Chiamare periodicamente le API di OpenRouter
2. Recuperare usage stats (richieste, token, costi)
3. Salvare i dati nel database
4. Mantenere le statistiche aggiornate automaticamente
**Servizi Pronti:**
- `validate_api_key()` in `services/openrouter.py` - già implementato
- `UsageStats` model - pronto
- `EncryptionService` - per decifrare API keys
- `get_db()` - per sessioni database
**Documentazione OpenRouter:**
- Endpoint usage: `GET https://openrouter.ai/api/v1/usage`
- Authentication: `Authorization: Bearer {api_key}`
- Query params: `start_date`, `end_date`
- Rate limit: 20 richieste/minuto
---
## 🔧 TASK DA IMPLEMENTARE
### T55: Setup APScheduler per Task Periodici
**File:** `src/openrouter_monitor/tasks/scheduler.py`, `src/openrouter_monitor/tasks/__init__.py`
**Requisiti:**
- Installare `APScheduler` (`pip install apscheduler`)
- Creare scheduler singleton con `AsyncIOScheduler`
- Configurare job stores (memory per MVP, opzionale Redis in futuro)
- Gestire startup/shutdown dell'applicazione FastAPI
- Supportare timezone UTC
**Implementazione:**
```python
# src/openrouter_monitor/tasks/scheduler.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
import logging
logger = logging.getLogger(__name__)
# Singleton scheduler
_scheduler: AsyncIOScheduler | None = None
def get_scheduler() -> AsyncIOScheduler:
"""Get or create scheduler singleton."""
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler(timezone='UTC')
return _scheduler
def init_scheduler():
"""Initialize and start scheduler."""
scheduler = get_scheduler()
# Add event listeners
scheduler.add_listener(
_job_error_listener,
EVENT_JOB_ERROR
)
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started")
def shutdown_scheduler():
"""Shutdown scheduler gracefully."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown()
logger.info("Scheduler shutdown")
def _job_error_listener(event):
"""Handle job execution errors."""
logger.error(f"Job {event.job_id} crashed: {event.exception}")
# Convenience decorator for tasks
def scheduled_job(trigger, **trigger_args):
"""Decorator to register scheduled jobs."""
def decorator(func):
scheduler = get_scheduler()
scheduler.add_job(
func,
trigger=trigger,
**trigger_args,
id=func.__name__,
replace_existing=True
)
return func
return decorator
```
**Integrazione con FastAPI:**
```python
# In main.py
from contextlib import asynccontextmanager
from openrouter_monitor.tasks.scheduler import init_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
init_scheduler()
yield
# Shutdown
shutdown_scheduler()
app = FastAPI(lifespan=lifespan)
```
**Test:** `tests/unit/tasks/test_scheduler.py`
- Test singleton scheduler
- Test init/shutdown
- Test job registration
- Test event listeners
---
### T56: Task Sincronizzazione OpenRouter
**File:** `src/openrouter_monitor/tasks/sync.py`
**Requisiti:**
- Task che gira ogni ora (`IntervalTrigger(hours=1)`)
- Per ogni API key attiva:
1. Decifra la key con `EncryptionService`
2. Chiama OpenRouter API `/usage`
3. Recupera dati: date, model, requests, tokens, cost
4. Salva in `UsageStats` (upsert per evitare duplicati)
- Gestire rate limiting (max 20 req/min)
- Gestire errori (API down, key invalida)
- Logging dettagliato
**Implementazione:**
```python
# src/openrouter_monitor/tasks/sync.py
import httpx
import asyncio
from datetime import date, timedelta
from sqlalchemy.orm import Session
from typing import List, Dict
import logging
from openrouter_monitor.config import get_settings
from openrouter_monitor.database import SessionLocal
from openrouter_monitor.models import ApiKey, UsageStats
from openrouter_monitor.services.encryption import EncryptionService
from openrouter_monitor.tasks.scheduler import scheduled_job, get_scheduler
logger = logging.getLogger(__name__)
settings = get_settings()
encryption_service = EncryptionService(settings.encryption_key)
async def fetch_usage_for_key(
api_key: ApiKey,
start_date: date,
end_date: date
) -> List[Dict]:
"""Fetch usage data from OpenRouter for a specific API key."""
# Decrypt API key
plaintext_key = encryption_service.decrypt(api_key.key_encrypted)
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.get(
f"{settings.openrouter_api_url}/usage",
headers={"Authorization": f"Bearer {plaintext_key}"},
params={
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
)
response.raise_for_status()
return response.json().get("data", [])
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error for key {api_key.id}: {e}")
return []
except Exception as e:
logger.error(f"Error fetching usage for key {api_key.id}: {e}")
return []
async def sync_usage_stats():
"""Sync usage stats from OpenRouter for all active API keys."""
logger.info("Starting usage stats sync")
db = SessionLocal()
try:
# Get all active API keys
api_keys = db.query(ApiKey).filter(ApiKey.is_active == True).all()
if not api_keys:
logger.info("No active API keys to sync")
return
# Date range: last 7 days (configurable)
end_date = date.today()
start_date = end_date - timedelta(days=7)
total_records = 0
for api_key in api_keys:
# Rate limiting: max 3 requests per second
await asyncio.sleep(0.35)
usage_data = await fetch_usage_for_key(api_key, start_date, end_date)
for item in usage_data:
# Upsert usage stats
existing = db.query(UsageStats).filter(
UsageStats.api_key_id == api_key.id,
UsageStats.date == item["date"],
UsageStats.model == item["model"]
).first()
if existing:
# Update existing
existing.requests_count = item["requests_count"]
existing.tokens_input = item["tokens_input"]
existing.tokens_output = item["tokens_output"]
existing.cost = item["cost"]
else:
# Create new
usage_stat = UsageStats(
api_key_id=api_key.id,
date=item["date"],
model=item["model"],
requests_count=item["requests_count"],
tokens_input=item["tokens_input"],
tokens_output=item["tokens_output"],
cost=item["cost"]
)
db.add(usage_stat)
total_records += 1
logger.info(f"Synced {len(usage_data)} records for key {api_key.id}")
db.commit()
logger.info(f"Sync completed. Total records: {total_records}")
except Exception as e:
logger.error(f"Sync failed: {e}")
db.rollback()
raise
finally:
db.close()
# Register scheduled job
def register_sync_job():
"""Register sync job with scheduler."""
scheduler = get_scheduler()
scheduler.add_job(
sync_usage_stats,
trigger=IntervalTrigger(hours=1),
id='sync_usage_stats',
replace_existing=True,
name='Sync OpenRouter Usage Stats'
)
logger.info("Registered sync_usage_stats job (every 1 hour)")
```
**Test:** `tests/unit/tasks/test_sync.py`
- Test fetch_usage_for_key success
- Test fetch_usage_for_key error handling
- Test sync_usage_stats con mock dati
- Test upsert logic
- Test rate limiting
---
### T57: Task Validazione API Keys
**File:** `src/openrouter_monitor/tasks/sync.py` (aggiungere funzione)
**Requisiti:**
- Task che gira ogni giorno (`CronTrigger(hour=2, minute=0)`)
- Per ogni API key:
1. Decifra la key
2. Chiama OpenRouter `/auth/key` per validare
3. Se invalida: set `is_active=False`
4. Logga key invalidate
- Notifica opzionale (per MVP solo logging)
**Implementazione:**
```python
async def validate_api_keys():
"""Validate all API keys and mark invalid ones."""
logger.info("Starting API keys validation")
db = SessionLocal()
try:
api_keys = db.query(ApiKey).filter(ApiKey.is_active == True).all()
invalid_count = 0
for api_key in api_keys:
await asyncio.sleep(0.35) # Rate limiting
try:
plaintext_key = encryption_service.decrypt(api_key.key_encrypted)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.openrouter_api_url}/auth/key",
headers={"Authorization": f"Bearer {plaintext_key}"}
)
if response.status_code != 200:
# Key is invalid
api_key.is_active = False
invalid_count += 1
logger.warning(f"API key {api_key.id} marked as invalid")
except Exception as e:
logger.error(f"Error validating key {api_key.id}: {e}")
db.commit()
logger.info(f"Validation completed. Invalid keys found: {invalid_count}")
finally:
db.close()
def register_validation_job():
"""Register validation job with scheduler."""
scheduler = get_scheduler()
scheduler.add_job(
validate_api_keys,
trigger=CronTrigger(hour=2, minute=0), # Every day at 2 AM
id='validate_api_keys',
replace_existing=True,
name='Validate API Keys'
)
logger.info("Registered validate_api_keys job (daily at 2 AM)")
```
**Test:**
- Test validazione key valida
- Test validazione key invalida
- Test aggiornamento flag is_active
---
### T58: Task Cleanup Dati Vecchi
**File:** `src/openrouter_monitor/tasks/cleanup.py`
**Requisiti:**
- Task che gira ogni settimana (`CronTrigger(day_of_week='sun', hour=3, minute=0)`)
- Rimuove `UsageStats` più vecchi di X giorni (configurabile, default 365)
- Mantiene dati aggregati (opzionale per MVP)
- Logga numero record eliminati
**Implementazione:**
```python
# src/openrouter_monitor/tasks/cleanup.py
from datetime import date, timedelta
from sqlalchemy import delete
import logging
from openrouter_monitor.config import get_settings
from openrouter_monitor.database import SessionLocal
from openrouter_monitor.models import UsageStats
from openrouter_monitor.tasks.scheduler import CronTrigger, get_scheduler
logger = logging.getLogger(__name__)
settings = get_settings()
async def cleanup_old_usage_stats():
"""Remove usage stats older than retention period."""
retention_days = getattr(settings, 'usage_stats_retention_days', 365)
cutoff_date = date.today() - timedelta(days=retention_days)
logger.info(f"Starting cleanup of usage stats older than {cutoff_date}")
db = SessionLocal()
try:
result = db.execute(
delete(UsageStats).where(UsageStats.date < cutoff_date)
)
deleted_count = result.rowcount
db.commit()
logger.info(f"Cleanup completed. Deleted {deleted_count} old records")
except Exception as e:
logger.error(f"Cleanup failed: {e}")
db.rollback()
raise
finally:
db.close()
def register_cleanup_job():
"""Register cleanup job with scheduler."""
scheduler = get_scheduler()
scheduler.add_job(
cleanup_old_usage_stats,
trigger=CronTrigger(day_of_week='sun', hour=3, minute=0), # Sundays at 3 AM
id='cleanup_old_usage_stats',
replace_existing=True,
name='Cleanup Old Usage Stats'
)
logger.info("Registered cleanup_old_usage_stats job (weekly on Sunday)")
```
**Test:** `tests/unit/tasks/test_cleanup.py`
- Test eliminazione dati vecchi
- Test conservazione dati recenti
- Test configurazione retention_days
---
## 🔄 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
```
src/openrouter_monitor/
├── tasks/
│ ├── __init__.py # Esporta scheduler, jobs
│ ├── scheduler.py # T55 - APScheduler setup
│ ├── sync.py # T56, T57 - Sync e validation
│ └── cleanup.py # T58 - Cleanup
├── main.py # Aggiungi lifespan per scheduler
└── config.py # Aggiungi usage_stats_retention_days
tests/unit/tasks/
├── __init__.py
├── test_scheduler.py # T55 + T58
├── test_sync.py # T56 + T57
└── test_cleanup.py # T58
```
---
## 📦 AGGIORNAMENTO REQUIREMENTS
Aggiungere a `requirements.txt`:
```
apscheduler==3.10.4
```
---
## ✅ CRITERI DI ACCETTAZIONE
- [ ] T55: APScheduler configurato e funzionante
- [ ] T56: Task sincronizzazione ogni ora
- Recupera dati da OpenRouter
- Salva in UsageStats (upsert)
- Gestisce rate limiting
- Logging dettagliato
- [ ] T57: Task validazione ogni giorno
- Marca key invalide
- Logging
- [ ] T58: Task cleanup settimanale
- Rimuove dati vecchi (>365 giorni)
- Configurabile
- [ ] Tutti i task registrati all'avvio dell'app
- [ ] Test completi coverage >= 90%
- [ ] 4 commit atomici con conventional commits
- [ ] progress.md aggiornato
---
## 📝 COMMIT MESSAGES
```
feat(tasks): T55 setup APScheduler for background tasks
feat(tasks): T56 implement OpenRouter usage sync job
feat(tasks): T57 implement API key validation job
feat(tasks): T58 implement old data cleanup job
```
---
## 🚀 VERIFICA FINALE
```bash
cd /home/google/Sources/LucaSacchiNet/openrouter-watcher
# Aggiorna dipendenze
pip install apscheduler
# Test scheduler
pytest tests/unit/tasks/test_scheduler.py -v
# Test sync
pytest tests/unit/tasks/test_sync.py -v
# Test cleanup
pytest tests/unit/tasks/test_cleanup.py -v
# Test completo
pytest tests/unit/ -v --cov=src/openrouter_monitor
# Avvia app e verifica log
uvicorn src.openrouter_monitor.main:app --reload
# Dovresti vedere: "Scheduler started", "Registered sync_usage_stats job"
```
---
## 📊 SCHEDULE RIASSUNTIVO
| Task | Frequenza | Orario | Descrizione |
|------|-----------|--------|-------------|
| sync_usage_stats | Ogni ora | - | Recupera dati da OpenRouter |
| validate_api_keys | Giornaliera | 02:00 | Verifica validità API keys |
| cleanup_old_usage_stats | Settimanale | Dom 03:00 | Pulizia dati vecchi |
---
## ⚠️ NOTE IMPORTANTI
- **Rate Limiting**: OpenRouter ha limiti. Usa `asyncio.sleep()` tra richieste
- **Error Handling**: Task non devono crashare l'applicazione
- **Logging**: Tutte le operazioni devono essere loggate
- **Database**: Ogni task crea la propria sessione (non condividere tra thread)
- **Timezone**: Usa sempre UTC
- **Idempotenza**: Il task sync deve gestire upsert (non creare duplicati)
---
## 🔍 TESTING MANUALE
Dopo l'implementazione:
1. **Aggiungi una API key** via POST /api/keys
2. **Verifica nel log** che il task sync parta (o attendi 1 ora)
3. **Forza esecuzione** per test:
```python
from openrouter_monitor.tasks.sync import sync_usage_stats
import asyncio
asyncio.run(sync_usage_stats())
```
4. **Verifica dati** in GET /api/usage (dovrebbero esserci dati)
---
**AGENTE:** @tdd-developer
**INIZIA CON:** T55 - Setup APScheduler
**QUANDO FINITO:** I dati si sincronizzeranno automaticamente da OpenRouter! 🚀

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

View File

@@ -0,0 +1,675 @@
# Prompt di Ingaggio: API Pubblica (T35-T40)
## 🎯 MISSIONE
Implementare la fase **API Pubblica** del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD.
**Task da completare:** T35, T36, T37, T38, T39, T40
---
## 📋 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
- 🎯 **Totale: 324+ test, ~98% coverage su moduli implementati**
**Servizi Pronti:**
- `EncryptionService` - Cifratura/decifratura
- `get_current_user()` - Autenticazione JWT
- `generate_api_token()`, `verify_api_token()` - Token API pubblica
- `get_dashboard_data()`, `get_usage_stats()` - Aggregazione dati
- `ApiKey`, `UsageStats`, `ApiToken` models
**Documentazione:**
- PRD: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md` (sezione 2.4)
- Architecture: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md` (sezione 5.2.3)
---
## 🔧 TASK DA IMPLEMENTARE
### T35: Creare Pydantic Schemas per API Pubblica
**File:** `src/openrouter_monitor/schemas/public_api.py`
**Requisiti:**
- `PublicStatsResponse`: summary (requests, cost, tokens), period (start_date, end_date)
- `PublicUsageResponse`: items (list), pagination (page, limit, total, pages)
- `PublicKeyInfo`: id, name, is_active, stats (total_requests, total_cost)
- `PublicKeyListResponse`: items (list[PublicKeyInfo]), total
- `ApiTokenCreate`: name (str, 1-100 chars)
- `ApiTokenResponse`: id, name, created_at, last_used_at, is_active (NO token!)
- `ApiTokenCreateResponse`: id, name, token (plaintext, solo al momento creazione), created_at
**Implementazione:**
```python
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import List, Optional
from decimal import Decimal
class PeriodInfo(BaseModel):
start_date: date
end_date: date
days: int
class PublicStatsSummary(BaseModel):
total_requests: int
total_cost: Decimal
total_tokens_input: int
total_tokens_output: int
class PublicStatsResponse(BaseModel):
summary: PublicStatsSummary
period: PeriodInfo
class PublicUsageItem(BaseModel):
date: date
model: str
requests_count: int
tokens_input: int
tokens_output: int
cost: Decimal
class PaginationInfo(BaseModel):
page: int
limit: int
total: int
pages: int
class PublicUsageResponse(BaseModel):
items: List[PublicUsageItem]
pagination: PaginationInfo
class PublicKeyStats(BaseModel):
total_requests: int
total_cost: Decimal
class PublicKeyInfo(BaseModel):
id: int
name: str
is_active: bool
stats: PublicKeyStats
class PublicKeyListResponse(BaseModel):
items: List[PublicKeyInfo]
total: int
class ApiTokenCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class ApiTokenResponse(BaseModel):
id: int
name: str
created_at: datetime
last_used_at: Optional[datetime]
is_active: bool
class ApiTokenCreateResponse(BaseModel):
id: int
name: str
token: str # PLAINTEXT - shown only once!
created_at: datetime
```
**Test:** `tests/unit/schemas/test_public_api_schemas.py` (10+ test)
---
### T36: Implementare Endpoint GET /api/v1/stats (API Pubblica)
**File:** `src/openrouter_monitor/routers/public_api.py`
**Requisiti:**
- Endpoint: `GET /api/v1/stats`
- Auth: API Token (non JWT!) - `get_current_user_from_api_token()`
- Query params:
- start_date (optional, default 30 giorni fa)
- end_date (optional, default oggi)
- Verifica token valido e attivo
- Aggiorna `last_used_at` del token
- Ritorna: `PublicStatsResponse`
- Solo lettura, nessuna modifica
**Implementazione:**
```python
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from datetime import date, timedelta
from openrouter_monitor.database import get_db
from openrouter_monitor.dependencies import get_current_user_from_api_token
from openrouter_monitor.models import User
from openrouter_monitor.schemas import PublicStatsResponse
from openrouter_monitor.services.stats import get_public_stats
router = APIRouter(prefix="/api/v1", tags=["public-api"])
@router.get("/stats", response_model=PublicStatsResponse)
async def get_public_stats_endpoint(
start_date: Optional[date] = Query(default=None),
end_date: Optional[date] = Query(default=None),
current_user: User = Depends(get_current_user_from_api_token),
db: Session = Depends(get_db)
):
"""Get usage statistics via API token authentication.
Authentication: Bearer <api_token>
Returns aggregated statistics for the authenticated user's API keys.
"""
# Default to last 30 days if dates not provided
if not end_date:
end_date = date.today()
if not start_date:
start_date = end_date - timedelta(days=29)
# Get stats using existing service
stats = await get_public_stats(db, current_user.id, start_date, end_date)
return PublicStatsResponse(
summary=stats,
period=PeriodInfo(
start_date=start_date,
end_date=end_date,
days=(end_date - start_date).days + 1
)
)
```
**Test:**
- Test con token valido (200)
- Test con token invalido (401)
- Test con token scaduto/revocado (401)
- Test date default (30 giorni)
- Test date custom
- Test aggiornamento last_used_at
---
### T37: Implementare Endpoint GET /api/v1/usage (API Pubblica)
**File:** `src/openrouter_monitor/routers/public_api.py`
**Requisiti:**
- Endpoint: `GET /api/v1/usage`
- Auth: API Token
- Query params:
- start_date (required)
- end_date (required)
- page (default 1)
- limit (default 100, max 1000)
- Paginazione con offset/limit
- Ritorna: `PublicUsageResponse`
**Implementazione:**
```python
@router.get("/usage", response_model=PublicUsageResponse)
async def get_public_usage_endpoint(
start_date: date,
end_date: date,
page: int = Query(default=1, ge=1),
limit: int = Query(default=100, ge=1, le=1000),
current_user: User = Depends(get_current_user_from_api_token),
db: Session = Depends(get_db)
):
"""Get detailed usage data via API token authentication.
Returns paginated usage records aggregated by date and model.
"""
skip = (page - 1) * limit
# Get usage data
items, total = await get_public_usage(
db, current_user.id, start_date, end_date, skip, limit
)
pages = (total + limit - 1) // limit
return PublicUsageResponse(
items=items,
pagination=PaginationInfo(
page=page,
limit=limit,
total=total,
pages=pages
)
)
```
**Test:**
- Test con filtri date (200)
- Test paginazione
- Test limit max 1000
- Test senza token (401)
- Test token scaduto (401)
---
### T38: Implementare Endpoint GET /api/v1/keys (API Pubblica)
**File:** `src/openrouter_monitor/routers/public_api.py`
**Requisiti:**
- Endpoint: `GET /api/v1/keys`
- Auth: API Token
- Ritorna: lista API keys con statistiche aggregate
- NO key values (cifrate comunque)
- Solo: id, name, is_active, stats (totali)
**Implementazione:**
```python
@router.get("/keys", response_model=PublicKeyListResponse)
async def get_public_keys_endpoint(
current_user: User = Depends(get_current_user_from_api_token),
db: Session = Depends(get_db)
):
"""Get API keys list with aggregated statistics.
Returns non-sensitive key information with usage stats.
Key values are never exposed.
"""
from sqlalchemy import func
# Query API keys with aggregated stats
results = db.query(
ApiKey.id,
ApiKey.name,
ApiKey.is_active,
func.coalesce(func.sum(UsageStats.requests_count), 0).label('total_requests'),
func.coalesce(func.sum(UsageStats.cost), 0).label('total_cost')
).outerjoin(UsageStats).filter(
ApiKey.user_id == current_user.id
).group_by(ApiKey.id).all()
items = [
PublicKeyInfo(
id=r.id,
name=r.name,
is_active=r.is_active,
stats=PublicKeyStats(
total_requests=r.total_requests,
total_cost=Decimal(str(r.total_cost))
)
)
for r in results
]
return PublicKeyListResponse(items=items, total=len(items))
```
**Test:**
- Test lista keys con stats (200)
- Test NO key values in risposta
- Test senza token (401)
---
### T39: Implementare Rate Limiting su API Pubblica
**File:** `src/openrouter_monitor/middleware/rate_limit.py` o `src/openrouter_monitor/dependencies/rate_limit.py`
**Requisiti:**
- Rate limit per API token: 100 richieste/ora (default)
- Rate limit per IP: 30 richieste/minuto (fallback)
- Memorizzare contatori in memory (per MVP, Redis in futuro)
- Header nelle risposte: X-RateLimit-Limit, X-RateLimit-Remaining
- Ritorna 429 Too Many Requests quando limite raggiunto
**Implementazione:**
```python
from fastapi import HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from datetime import datetime, timedelta
from typing import Dict, Tuple
import time
# Simple in-memory rate limiting (use Redis in production)
class RateLimiter:
def __init__(self):
self._storage: Dict[str, Tuple[int, float]] = {} # key: (count, reset_time)
def is_allowed(self, key: str, limit: int, window_seconds: int) -> Tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, limit)."""
now = time.time()
reset_time = now + window_seconds
if key not in self._storage:
self._storage[key] = (1, reset_time)
return True, limit - 1, limit
count, current_reset = self._storage[key]
# Reset window if expired
if now > current_reset:
self._storage[key] = (1, reset_time)
return True, limit - 1, limit
# Check limit
if count >= limit:
return False, 0, limit
self._storage[key] = (count + 1, current_reset)
return True, limit - count - 1, limit
rate_limiter = RateLimiter()
async def rate_limit_by_token(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
request: Request = None
) -> None:
"""Rate limiting dependency for API endpoints."""
from openrouter_monitor.config import get_settings
settings = get_settings()
# Use token as key if available, otherwise IP
if credentials:
key = f"token:{credentials.credentials}"
limit = settings.rate_limit_requests # 100/hour
window = settings.rate_limit_window # 3600 seconds
else:
key = f"ip:{request.client.host}"
limit = 30 # 30/minute for IP
window = 60
allowed, remaining, limit_total = rate_limiter.is_allowed(key, limit, window)
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Try again later.",
headers={"Retry-After": str(window)}
)
# Add rate limit headers to response (will be added by middleware)
request.state.rate_limit_remaining = remaining
request.state.rate_limit_limit = limit_total
class RateLimitHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
request = Request(scope, receive)
async def send_with_headers(message):
if message["type"] == "http.response.start":
headers = message.get("headers", [])
# Add rate limit headers if available
if hasattr(request.state, 'rate_limit_remaining'):
headers.append(
(b"x-ratelimit-remaining",
str(request.state.rate_limit_remaining).encode())
)
headers.append(
(b"x-ratelimit-limit",
str(request.state.rate_limit_limit).encode())
)
message["headers"] = headers
await send(message)
await self.app(scope, receive, send_with_headers)
else:
await self.app(scope, receive, send)
```
**Aggiungere ai router:**
```python
from openrouter_monitor.dependencies.rate_limit import rate_limit_by_token
@router.get("/stats", response_model=PublicStatsResponse, dependencies=[Depends(rate_limit_by_token)])
async def get_public_stats_endpoint(...):
...
```
**Test:**
- Test rate limit token (100/ora)
- Test rate limit IP (30/minuto)
- Test 429 quando limite raggiunto
- Test headers X-RateLimit-* presenti
- Test reset dopo window
---
### T40: Scrivere Test per API Pubblica
**File:** `tests/unit/routers/test_public_api.py`
**Requisiti:**
- Test integrazione per tutti gli endpoint API pubblica
- Mock/generare API token validi per test
- Test rate limiting
- Test sicurezza (token invalido, scaduto)
- Coverage >= 90%
**Test da implementare:**
- **Stats Tests:**
- GET /api/v1/stats con token valido (200)
- GET /api/v1/stats date default (30 giorni)
- GET /api/v1/stats date custom
- GET /api/v1/stats token invalido (401)
- GET /api/v1/stats token scaduto (401)
- GET /api/v1/stats aggiorna last_used_at
- **Usage Tests:**
- GET /api/v1/usage con filtri (200)
- GET /api/v1/usage paginazione
- GET /api/v1/usage senza token (401)
- **Keys Tests:**
- GET /api/v1/keys lista (200)
- GET /api/v1/keys NO key values in risposta
- **Rate Limit Tests:**
- Test 100 richieste/ora
- Test 429 dopo limite
- Test headers rate limit
- **Security Tests:**
- User A non vede dati di user B con token di A
- Token JWT non funziona su API pubblica (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
```
src/openrouter_monitor/
├── schemas/
│ ├── __init__.py # Aggiungi export public_api
│ └── public_api.py # T35
├── routers/
│ ├── __init__.py # Aggiungi export public_api
│ └── public_api.py # T36, T37, T38
├── dependencies/
│ ├── __init__.py # Aggiungi export
│ ├── auth.py # Aggiungi get_current_user_from_api_token
│ └── rate_limit.py # T39
├── middleware/
│ └── rate_limit.py # T39 (opzionale)
└── main.py # Registra public_api router + middleware
tests/unit/
├── schemas/
│ └── test_public_api_schemas.py # T35 + T40
├── dependencies/
│ └── test_rate_limit.py # T39 + T40
└── routers/
└── test_public_api.py # T36-T38 + T40
```
---
## 🧪 ESEMPI TEST
### Test Dependency API Token
```python
@pytest.mark.asyncio
async def test_get_current_user_from_api_token_valid_returns_user(db_session, test_user):
# Arrange
token, token_hash = generate_api_token()
api_token = ApiToken(user_id=test_user.id, token_hash=token_hash, name="Test")
db_session.add(api_token)
db_session.commit()
# Act
user = await get_current_user_from_api_token(token, db_session)
# Assert
assert user.id == test_user.id
```
### Test Endpoint Stats
```python
def test_public_stats_with_valid_token_returns_200(client, api_token):
response = client.get(
"/api/v1/stats",
headers={"Authorization": f"Bearer {api_token}"}
)
assert response.status_code == 200
assert "summary" in response.json()
```
### Test Rate Limiting
```python
def test_rate_limit_429_after_100_requests(client, api_token):
# Make 100 requests
for _ in range(100):
response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"})
assert response.status_code == 200
# 101st request should fail
response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"})
assert response.status_code == 429
```
---
## ✅ CRITERI DI ACCETTAZIONE
- [ ] T35: Schemas API pubblica con validazione
- [ ] T36: Endpoint /api/v1/stats con auth API token
- [ ] T37: Endpoint /api/v1/usage con paginazione
- [ ] T38: Endpoint /api/v1/keys con stats aggregate
- [ ] T39: Rate limiting implementato (100/ora, 429)
- [ ] T40: Test completi coverage >= 90%
- [ ] `get_current_user_from_api_token()` dependency funzionante
- [ ] Headers X-RateLimit-* presenti nelle risposte
- [ ] Token JWT non funziona su API pubblica
- [ ] 6 commit atomici con conventional commits
- [ ] progress.md aggiornato
---
## 📝 COMMIT MESSAGES
```
feat(schemas): T35 add Pydantic public API schemas
feat(auth): add get_current_user_from_api_token dependency
feat(public-api): T36 implement GET /api/v1/stats endpoint
feat(public-api): T37 implement GET /api/v1/usage endpoint with pagination
feat(public-api): T38 implement GET /api/v1/keys endpoint
feat(rate-limit): T39 implement rate limiting for public API
test(public-api): T40 add comprehensive public API endpoint tests
```
---
## 🚀 VERIFICA FINALE
```bash
cd /home/google/Sources/LucaSacchiNet/openrouter-watcher
# Test schemas
pytest tests/unit/schemas/test_public_api_schemas.py -v
# Test dependencies
pytest tests/unit/dependencies/test_rate_limit.py -v
# Test routers
pytest tests/unit/routers/test_public_api.py -v --cov=src/openrouter_monitor/routers
# Test completo
pytest tests/unit/ -v --cov=src/openrouter_monitor
# Verifica endpoint manualmente
curl -H "Authorization: Bearer or_api_xxxxx" http://localhost:8000/api/v1/stats
```
---
## 📋 DIFFERENZE CHIAVE: API Pubblica vs Web API
| Feature | Web API (/api/auth, /api/keys) | API Pubblica (/api/v1/*) |
|---------|--------------------------------|--------------------------|
| **Auth** | JWT Bearer | API Token Bearer |
| **Scopo** | Gestione (CRUD) | Lettura dati |
| **Rate Limit** | No (o diverso) | Sì (100/ora) |
| **Audience** | Frontend web | Integrazioni esterne |
| **Token TTL** | 24 ore | Illimitato (fino a revoca) |
---
## 🔒 CONSIDERAZIONI SICUREZZA
### Do's ✅
- Verificare sempre API token con hash in database
- Aggiornare `last_used_at` ad ogni richiesta
- Rate limiting per prevenire abusi
- Non esporre mai API key values (cifrate)
- Validare date (max range 365 giorni)
### Don'ts ❌
- MAI accettare JWT su API pubblica
- MAI loggare API token in plaintext
- MAI ritornare dati di altri utenti
- MAI bypassare rate limiting
- MAI permettere range date > 365 giorni
---
## 📝 NOTE IMPORTANTI
- **Path assoluti**: Usa sempre `/home/google/Sources/LucaSacchiNet/openrouter-watcher/`
- **Dependency**: Crea `get_current_user_from_api_token()` separata da `get_current_user()`
- **Rate limiting**: In-memory per MVP, Redis per produzione
- **Token format**: API token inizia con `or_api_`, JWT no
- **last_used_at**: Aggiornare ad ogni chiamata API pubblica
---
**AGENTE:** @tdd-developer
**INIZIA CON:** T35 - Pydantic public API schemas
**QUANDO FINITO:** Conferma completamento, coverage >= 90%, aggiorna progress.md