Files
openrouter-watcher/export/architecture.md
Luca Sacchi Ricciardi 75f40acb17 feat(setup): T01 create project directory structure
- Create src/openrouter_monitor/ package structure
- Create models/, routers/, services/, utils/ subpackages
- Create tests/unit/ and tests/integration/ structure
- Create alembic/, docs/, scripts/ directories
- Add test_project_structure.py with 13 unit tests
- All tests passing (13/13)

Refs: T01
2026-04-07 09:44:41 +02:00

1095 lines
34 KiB
Markdown

# Architecture Document
## OpenRouter API Key Monitor - Fase 1 (MVP)
---
## 1. Stack Tecnologico
| Componente | Tecnologia | Versione | Note |
|------------|------------|----------|------|
| **Runtime** | Python | 3.11+ | Type hints, async/await |
| **Web Framework** | FastAPI | 0.104+ | Async, automatic OpenAPI docs |
| **Database** | SQLite | 3.39+ | Embedded, zero-config |
| **ORM** | SQLAlchemy | 2.0+ | Modern declarative syntax |
| **Migrations** | Alembic | 1.12+ | Database versioning |
| **Auth** | python-jose | 3.3+ | JWT tokens |
| **Password Hash** | bcrypt | 4.1+ | Industry standard |
| **Encryption** | cryptography | 41.0+ | AES-256-GCM |
| **Frontend** | HTMX | 1.9+ | Server-side rendering |
| **CSS** | Tailwind CSS | 3.4+ | Utility-first |
| **Background Tasks** | APScheduler | 3.10+ | Cron jobs |
| **Testing** | pytest | 7.4+ | Async support |
| **HTTP Client** | httpx | 0.25+ | Async requests |
| **Validation** | Pydantic | 2.5+ | Data validation |
---
## 2. Struttura Cartelle Progetto
```
/home/google/Sources/LucaSacchiNet/openrouter-watcher/
├── app/
│ ├── __init__.py
│ ├── main.py # Entry point FastAPI
│ ├── config.py # Configuration management
│ ├── database.py # DB connection & session
│ ├── dependencies.py # FastAPI dependencies
│ ├── models/ # SQLAlchemy models
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── api_key.py
│ │ ├── usage_stats.py
│ │ └── api_token.py
│ ├── schemas/ # Pydantic schemas
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── user.py
│ │ ├── api_key.py
│ │ └── stats.py
│ ├── routers/ # API endpoints
│ │ ├── __init__.py
│ │ ├── auth.py # Login/register/logout
│ │ ├── web.py # HTML pages (HTMX)
│ │ ├── api_keys.py # CRUD API keys
│ │ └── public_api.py # Public API v1
│ ├── services/ # Business logic
│ │ ├── __init__.py
│ │ ├── auth_service.py
│ │ ├── encryption.py
│ │ ├── openrouter.py
│ │ └── stats_service.py
│ ├── templates/ # Jinja2 templates
│ │ ├── base.html
│ │ ├── login.html
│ │ ├── register.html
│ │ ├── dashboard.html
│ │ ├── keys.html
│ │ └── partials/ # HTMX fragments
│ ├── static/ # CSS, JS, images
│ │ ├── css/
│ │ └── js/
│ └── tasks/ # Background jobs
│ ├── __init__.py
│ └── sync.py
├── alembic/ # Database migrations
│ ├── versions/
│ └── env.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_auth.py
│ ├── test_api_keys.py
│ └── test_public_api.py
├── docs/
├── export/ # Project specifications
│ ├── prd.md
│ ├── architecture.md
│ ├── kanban.md
│ └── progress.md
├── .env.example # Environment template
├── requirements.txt
├── requirements-dev.txt
├── alembic.ini
├── pytest.ini
└── README.md
```
---
## 3. Schema Database
### 3.1 Diagramma ER (ASCII)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ users │ │ api_keys │ │ usage_stats │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ PK id │◄──────┤ FK user_id │◄──────┤ FK key_id │
│ email │ 1:N │ name │ 1:N │ date │
│ password │ │ key_enc │ │ model │
│ created │ │ is_active│ │ requests │
│ updated │ │ created │ │ tokens_in│
│ active │ │ last_used│ │ tokens_out│
└─────────────┘ └─────────────┘ │ cost │
▲ └─────────────┘
│ ▲
│ │
│ ┌──────┴──────┐
│ │ api_tokens │
│ ├─────────────┤
└────────────────────────────────────┤ FK user_id │
1:N │ token_hash
│ name │
│ created │
│ last_used│
│ active │
└─────────────┘
```
### 3.2 DDL SQL (SQLite)
```sql
-- =============================================
-- Table: users
-- =============================================
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
CONSTRAINT chk_email_format CHECK (email LIKE '%_@__%.__%')
);
CREATE INDEX idx_users_email ON users(email);
-- =============================================
-- Table: api_keys
-- =============================================
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL,
key_encrypted TEXT NOT NULL, -- AES-256-GCM encrypted
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_api_keys_user ON api_keys(user_id);
CREATE INDEX idx_api_keys_active ON api_keys(is_active);
-- =============================================
-- Table: usage_stats
-- =============================================
CREATE TABLE usage_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id INTEGER NOT NULL,
date DATE NOT NULL,
model VARCHAR(100) NOT NULL,
requests_count INTEGER DEFAULT 0,
tokens_input INTEGER DEFAULT 0,
tokens_output INTEGER DEFAULT 0,
cost DECIMAL(10, 6) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE,
CONSTRAINT uniq_key_date_model UNIQUE (api_key_id, date, model)
);
CREATE INDEX idx_usage_key ON usage_stats(api_key_id);
CREATE INDEX idx_usage_date ON usage_stats(date);
CREATE INDEX idx_usage_model ON usage_stats(model);
-- =============================================
-- Table: api_tokens
-- =============================================
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash VARCHAR(255) NOT NULL, -- SHA-256 hash of token
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX idx_api_tokens_active ON api_tokens(is_active);
-- =============================================
-- Triggers per updated_at
-- =============================================
CREATE TRIGGER update_users_timestamp
AFTER UPDATE ON users
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
```
### 3.3 SQLAlchemy Models
```python
# app/models/user.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True)
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
api_tokens = relationship("ApiToken", back_populates="user", cascade="all, delete-orphan")
# app/models/api_key.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
key_encrypted = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="api_keys")
usage_stats = relationship("UsageStats", back_populates="api_key", cascade="all, delete-orphan")
# app/models/usage_stats.py
from datetime import date, datetime
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class UsageStats(Base):
__tablename__ = "usage_stats"
id = Column(Integer, primary_key=True, index=True)
api_key_id = Column(Integer, ForeignKey("api_keys.id"), nullable=False)
date = Column(Date, nullable=False)
model = Column(String(100), nullable=False)
requests_count = Column(Integer, default=0)
tokens_input = Column(Integer, default=0)
tokens_output = Column(Integer, default=0)
cost = Column(Numeric(10, 6), default=0.0)
created_at = Column(DateTime, default=datetime.utcnow)
api_key = relationship("ApiKey", back_populates="usage_stats")
# app/models/api_token.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class ApiToken(Base):
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token_hash = Column(String(255), nullable=False, index=True)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
user = relationship("User", back_populates="api_tokens")
```
---
## 4. Diagramma Flusso Autenticazione
### 4.1 Web Authentication Flow (Session/JWT)
```
┌──────────┐ POST /auth/login ┌──────────┐
│ Client │ ─────────────────────────► │ Server │
│ (Web) │ {email, password} │ │
└──────────┘ └────┬─────┘
┌────────────────┐
│ Validate │
│ credentials │
│ (bcrypt) │
└────┬───────────┘
┌────────────────┐
│ Generate JWT │
│ (HS256) │
└────┬───────────┘
┌──────────┐ 200 + JWT Cookie ┌──────────┐
│ Client │ ◄──────────────────────── │ Server │
│ (Web) │ {access_token} │ │
└────┬─────┘ └──────────┘
│ Include JWT in Cookie/Header
┌──────────┐ GET /dashboard ┌──────────┐
│ Client │ ──────────────────────► │ Server │
│ (Web) │ Cookie: jwt=xxx │ │
└──────────┘ └────┬─────┘
┌────────────────┐
│ Verify JWT │
│ Signature │
└────┬───────────┘
Valid? ────┼────┐
│ │ No
▼ ▼
┌────────┐ ┌──────────┐
│ Return │ │ 401 │
│ Data │ │ Unauthorized
└────┬───┘ └────┬─────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Client │ │ Client │
│ (Web) │ │ (Web) │
└──────────┘ └──────────┘
```
### 4.2 API Token Authentication Flow (Public API)
```
┌──────────┐ POST /api/v1/tokens ┌──────────┐
│ Client │ ───────────────────────► │ Server │
│ (Web) │ {name: "My Token"} │ │
│ (Auth) │ Bearer <JWT> │ │
└──────────┘ └────┬─────┘
┌────────────────┐
│ Generate │
│ random token │
│ (64 chars) │
└────┬───────────┘
┌────────────────┐
│ Hash token │
│ (SHA-256) │
│ Store in DB │
└────┬───────────┘
┌──────────┐ 201 + Plain Token ┌──────────┐
│ Client │ ◄──────────────────────── │ Server │
│ (Web) │ {token: "abc123..."} │ │
│ (Save) │ ⚠️ Only shown once │ │
└──────────┘ └──────────┘
────────────────────────────────────────────────────────────
┌──────────┐ GET /api/v1/stats ┌──────────┐
│ Client │ ──────────────────────► │ Server │
│ (Ext) │ Authorization: │ │
│ │ Bearer abc123... │ │
└──────────┘ └────┬─────┘
┌────────────────┐
│ Extract token │
│ from header │
└────┬───────────┘
┌────────────────┐
│ Hash token │
│ (SHA-256) │
└────┬───────────┘
┌────────────────┐
│ Lookup in DB │
│ (api_tokens) │
└────┬───────────┘
Found? ────┼────┐
│ │ No
▼ ▼
┌────────┐ ┌──────────┐
│ Update │ │ 401 │
│last_used│ │ Invalid │
└───┬────┘ │ Token │
│ └────┬─────┘
▼ │
┌──────────┐ │
│ Return │ │
│ Stats │ │
└────┬─────┘ │
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Client │ │ Client │
│ (Ext) │ │ (Ext) │
└──────────┘ └──────────┘
```
---
## 5. Interfacce API
### 5.1 Web Routes (HTML + HTMX)
| Method | Path | Auth | Description | Response |
|--------|------|------|-------------|----------|
| `GET` | `/` | No | Redirect to dashboard or login | 302 Redirect |
| `GET` | `/login` | No | Login form page | HTML form |
| `POST` | `/login` | No | Submit login credentials | Redirect or error |
| `GET` | `/register` | No | Registration form page | HTML form |
| `POST` | `/register` | No | Submit registration | Redirect or error |
| `GET` | `/logout` | Yes | Logout user | Redirect to login |
| `GET` | `/dashboard` | Yes | Main dashboard view | HTML dashboard |
| `GET` | `/keys` | Yes | API keys list page | HTML table |
| `POST` | `/keys` | Yes | Create new API key | HTMX fragment |
| `PUT` | `/keys/{id}` | Yes | Update API key | HTMX fragment |
| `DELETE` | `/keys/{id}` | Yes | Delete API key | HTMX fragment (empty) |
| `GET` | `/profile` | Yes | User profile page | HTML form |
| `PUT` | `/profile` | Yes | Update profile/password | Redirect or error |
### 5.2 REST API (JSON)
#### Auth Endpoints
**POST /api/auth/login**
```http
Request:
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword"
}
Response 200:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600
}
Response 401:
{
"detail": "Invalid credentials"
}
```
**POST /api/auth/register**
```http
Request:
POST /api/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword",
"password_confirm": "securepassword"
}
Response 201:
{
"id": 1,
"email": "user@example.com",
"created_at": "2024-01-01T00:00:00Z"
}
Response 400:
{
"detail": "Email already registered"
}
```
#### API Keys Management
**GET /api/keys**
```http
Request:
GET /api/keys
Authorization: Bearer <jwt_token>
Response 200:
{
"items": [
{
"id": 1,
"name": "Production Key",
"is_active": true,
"created_at": "2024-01-01T00:00:00Z",
"last_used_at": "2024-01-15T10:30:00Z"
}
],
"total": 1
}
```
**POST /api/keys**
```http
Request:
POST /api/keys
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"name": "My New Key",
"key": "sk-or-v1-abc123..." // OpenRouter API key
}
Response 201:
{
"id": 2,
"name": "My New Key",
"is_active": true,
"created_at": "2024-01-15T12:00:00Z"
}
Response 400:
{
"detail": "Invalid API key format"
}
```
**PUT /api/keys/{id}**
```http
Request:
PUT /api/keys/1
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"name": "Updated Name",
"is_active": false
}
Response 200:
{
"id": 1,
"name": "Updated Name",
"is_active": false,
"created_at": "2024-01-01T00:00:00Z"
}
```
**DELETE /api/keys/{id}**
```http
Request:
DELETE /api/keys/1
Authorization: Bearer <jwt_token>
Response 204: (No content)
```
#### Public API v1 (External Access)
**GET /api/v1/stats**
```http
Request:
GET /api/v1/stats
Authorization: Bearer <api_token>
Response 200:
{
"summary": {
"total_requests": 15234,
"total_cost": 125.50,
"total_tokens_input": 450000,
"total_tokens_output": 180000
},
"by_model": [
{
"model": "anthropic/claude-3-opus",
"requests": 5234,
"cost": 89.30
},
{
"model": "openai/gpt-4",
"requests": 10000,
"cost": 36.20
}
]
}
Response 401:
{
"detail": "Invalid or expired token"
}
```
**GET /api/v1/usage**
```http
Request:
GET /api/v1/usage?start_date=2024-01-01&end_date=2024-01-31&page=1&limit=100
Authorization: Bearer <api_token>
Response 200:
{
"items": [
{
"date": "2024-01-15",
"model": "anthropic/claude-3-opus",
"requests": 234,
"tokens_input": 45000,
"tokens_output": 12000,
"cost": 8.92
}
],
"pagination": {
"page": 1,
"limit": 100,
"total": 45,
"pages": 1
}
}
```
**GET /api/v1/keys**
```http
Request:
GET /api/v1/keys
Authorization: Bearer <api_token>
Response 200:
{
"items": [
{
"id": 1,
"name": "Production Key",
"is_active": true,
"stats": {
"total_requests": 15234,
"total_cost": 125.50
}
}
]
}
```
#### API Token Management
**POST /api/tokens**
```http
Request:
POST /api/tokens
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"name": "Integration Token"
}
Response 201:
{
"id": 1,
"name": "Integration Token",
"token": "or_api_abc123xyz789...", // Only shown once!
"created_at": "2024-01-15T12:00:00Z"
}
```
**GET /api/tokens**
```http
Response 200:
{
"items": [
{
"id": 1,
"name": "Integration Token",
"created_at": "2024-01-15T12:00:00Z",
"last_used_at": "2024-01-15T14:30:00Z",
"is_active": true
}
]
}
```
**DELETE /api/tokens/{id}**
```http
Response 204: (No content)
```
---
## 6. Piano Sicurezza
### 6.1 Cifratura
| Dato | Algoritmo | Implementazione |
|------|-----------|-----------------|
| **API Keys** | AES-256-GCM | `cryptography.fernet` with custom key |
| **Passwords** | bcrypt | `passlib.hash.bcrypt` (12 rounds) |
| **API Tokens** | SHA-256 | Only hash stored, never plaintext |
| **JWT** | HS256 | `python-jose` with 256-bit secret |
### 6.2 Implementation Details
```python
# Encryption Service (app/services/encryption.py)
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
import os
class EncryptionService:
"""
AES-256-GCM encryption for sensitive data.
"""
def __init__(self, master_key: str):
self._fernet = self._derive_key(master_key)
def _derive_key(self, master_key: str) -> Fernet:
"""Derive Fernet key from master key using PBKDF2."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=os.urandom(16),
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(master_key.encode()))
return Fernet(key)
def encrypt(self, plaintext: str) -> str:
"""Encrypt plaintext and return base64-encoded ciphertext."""
return self._fernet.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
"""Decrypt ciphertext and return plaintext."""
return self._fernet.decrypt(ciphertext.encode()).decode()
# Password Hashing
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
# API Token Generation
import secrets
import hashlib
def generate_api_token() -> tuple[str, str]:
"""
Generate a new API token.
Returns: (plaintext_token, token_hash)
"""
token = "or_api_" + secrets.token_urlsafe(48) # 64 chars total
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
```
### 6.3 Rate Limiting
```python
# Rate limiting configuration (app/config.py)
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
# Limits
AUTH_RATE_LIMIT = "5/minute" # Login/register attempts
API_RATE_LIMIT = "100/hour" # Public API calls per token
WEB_RATE_LIMIT = "30/minute" # Web endpoint calls per IP
```
### 6.4 Security Headers
```python
# FastAPI middleware (app/main.py)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["localhost", "*.example.com"]
)
@app.middleware("http")
async def security_headers(request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
```
### 6.5 Input Validation
```python
# Pydantic schemas with validation (app/schemas/auth.py)
from pydantic import BaseModel, EmailStr, Field, validator
import re
class UserRegister(BaseModel):
email: EmailStr
password: str = Field(..., min_length=12, max_length=128)
password_confirm: str
@validator('password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain digit')
if not re.search(r'[!@#$%^&*]', v):
raise ValueError('Password must contain special character')
return v
@validator('password_confirm')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
```
---
## 7. Configurazione Ambiente
### 7.1 Variabili d'Ambiente Richieste
| Variable | Type | Default | Required | Description |
|----------|------|---------|----------|-------------|
| `DATABASE_URL` | str | `sqlite:///./app.db` | No | SQLite database path |
| `SECRET_KEY` | str | - | **Yes** | JWT signing key (min 32 chars) |
| `ENCRYPTION_KEY` | str | - | **Yes** | AES-256 master key (32 bytes) |
| `OPENROUTER_API_URL` | str | `https://openrouter.ai/api/v1` | No | OpenRouter base URL |
| `SYNC_INTERVAL_MINUTES` | int | 60 | No | Stats sync interval |
| `MAX_API_KEYS_PER_USER` | int | 10 | No | API keys limit per user |
| `RATE_LIMIT_REQUESTS` | int | 100 | No | API requests per window |
| `RATE_LIMIT_WINDOW` | int | 3600 | No | Rate limit window (seconds) |
| `JWT_EXPIRATION_HOURS` | int | 24 | No | JWT token lifetime |
| `DEBUG` | bool | false | No | Enable debug mode |
| `LOG_LEVEL` | str | INFO | No | Logging level |
### 7.2 File .env.example
```bash
# ===========================================
# OpenRouter API Key Monitor - Configuration
# ===========================================
# Database
DATABASE_URL=sqlite:///./data/app.db
# Security - REQUIRED
# Generate with: openssl rand -hex 32
SECRET_KEY=your-super-secret-jwt-key-min-32-chars
ENCRYPTION_KEY=your-32-byte-encryption-key-here
# OpenRouter Integration
OPENROUTER_API_URL=https://openrouter.ai/api/v1
# Background Tasks
SYNC_INTERVAL_MINUTES=60
# Limits
MAX_API_KEYS_PER_USER=10
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=3600
# JWT
JWT_EXPIRATION_HOURS=24
# Development
DEBUG=false
LOG_LEVEL=INFO
```
### 7.3 Pydantic Settings (app/config.py)
```python
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "sqlite:///./app.db"
# Security
secret_key: str
encryption_key: str
jwt_expiration_hours: int = 24
# OpenRouter
openrouter_api_url: str = "https://openrouter.ai/api/v1"
# Task scheduling
sync_interval_minutes: int = 60
# Limits
max_api_keys_per_user: int = 10
rate_limit_requests: int = 100
rate_limit_window: int = 3600
# App settings
debug: bool = False
log_level: str = "INFO"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
```
---
## 8. Task Scheduler (APScheduler)
### 8.1 Background Jobs Configuration
```python
# app/tasks/sync.py
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.services.openrouter import OpenRouterService
from app.database import SessionLocal
scheduler = BackgroundScheduler()
def sync_usage_stats():
"""Sync usage statistics from OpenRouter for all active keys."""
db = SessionLocal()
try:
service = OpenRouterService(db)
service.sync_all_keys()
finally:
db.close()
def validate_api_keys():
"""Validate all API keys and update status."""
db = SessionLocal()
try:
service = OpenRouterService(db)
service.validate_all_keys()
finally:
db.close()
# Schedule jobs
scheduler.add_job(
sync_usage_stats,
trigger=IntervalTrigger(minutes=60),
id="sync_usage",
replace_existing=True
)
scheduler.add_job(
validate_api_keys,
trigger=IntervalTrigger(hours=24),
id="validate_keys",
replace_existing=True
)
def start_scheduler():
scheduler.start()
```
---
## 9. Testing Strategy
### 9.1 Test Structure
```
tests/
├── conftest.py # Shared fixtures
├── test_auth.py # Authentication tests
├── test_api_keys.py # API key CRUD tests
├── test_public_api.py # Public API tests
├── test_encryption.py # Encryption service tests
└── test_integration.py # Integration tests
```
### 9.2 Key Test Cases
| Component | Test Case | Expected |
|-----------|-----------|----------|
| Auth | Register with valid data | 201, user created |
| Auth | Register with duplicate email | 400 error |
| Auth | Login with valid credentials | 200, JWT returned |
| Auth | Login with wrong password | 401 error |
| API Keys | Create key with valid data | 201, key encrypted |
| API Keys | Create key without auth | 401 error |
| API Keys | Delete own key | 204 success |
| API Keys | Delete other user's key | 403 forbidden |
| Public API | Access with valid token | 200 + data |
| Public API | Access with invalid token | 401 error |
| Encryption | Encrypt and decrypt | Original value |
| Encryption | Wrong key decryption | Exception raised |
---
## 10. Deployment
### 10.1 Docker Configuration
```dockerfile
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .
# Create data directory
RUN mkdir -p /app/data
# Environment
ENV PYTHONPATH=/app
ENV DATABASE_URL=sqlite:///./data/app.db
# Run migrations and start
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
```
```yaml
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- SECRET_KEY=${SECRET_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- DEBUG=false
volumes:
- ./data:/app/data
restart: unless-stopped
```
---
*Document Version: 1.0*
*Last Updated: 2024-01-15*
*Status: MVP Specification*