# 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 │ │ └──────────┘ └────┬─────┘ │ ▼ ┌────────────────┐ │ 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 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 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 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 Response 204: (No content) ``` #### Public API v1 (External Access) **GET /api/v1/stats** ```http Request: GET /api/v1/stats Authorization: Bearer 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 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 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 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*