- 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
1095 lines
34 KiB
Markdown
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*
|