From 9bc9ca3ce81766792dbb267af7b93298dc7d49e7 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Fri, 20 Feb 2026 18:40:54 +0000 Subject: [PATCH] fix mcp and admin api --- .env.example | 7 + Dockerfile | 10 + EXTERNAL_ADMIN_API.md | 677 ++++++++++++++++ chat/server.js | 49 ++ chat/src/external-admin-api/handlers.js | 863 +++++++++++++++++++++ chat/src/external-admin-api/index.js | 248 ++++++ chat/src/external-admin-api/integration.js | 589 ++++++++++++++ chat/src/external-admin-api/router.js | 172 ++++ docker-compose.yml | 3 + 9 files changed, 2618 insertions(+) create mode 100644 EXTERNAL_ADMIN_API.md create mode 100644 chat/src/external-admin-api/handlers.js create mode 100644 chat/src/external-admin-api/index.js create mode 100644 chat/src/external-admin-api/integration.js create mode 100644 chat/src/external-admin-api/router.js diff --git a/.env.example b/.env.example index 593a092..8ffa203 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,13 @@ ADMIN_USER= ADMIN_PASSWORD= SESSION_SECRET= +# External Admin API Key +# Generate a secure key: openssl rand -hex 32 +# Format: sk_live_ for production, sk_test_ for testing +ADMIN_API_KEY= +ADMIN_API_JWT_TTL=3600 +ADMIN_API_RATE_LIMIT=1000 + # Database Configuration (Phase 1.2 & 1.3) # Set USE_JSON_DATABASE=1 to use legacy JSON files (for rollback) USE_JSON_DATABASE= diff --git a/Dockerfile b/Dockerfile index 8a6a39a..54f1ea1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,6 +126,16 @@ RUN cd /opt/webchat && npm install --production && chmod -R 755 /opt/webchat COPY chat_v2 /opt/webchat_v2 RUN chmod -R 755 /opt/webchat_v2 +# Copy MCP servers for WordPress validation +COPY opencode/mcp-servers /opt/opencode/mcp-servers +RUN cd /opt/opencode/mcp-servers/wp-cli-testing && npm install --production && \ + cd /opt/opencode/mcp-servers/wordpress-validator && npm install --production && \ + chmod -R 755 /opt/opencode/mcp-servers + +# Copy validation script +COPY scripts/validate-wordpress-plugin.sh /opt/scripts/validate-wordpress-plugin.sh +RUN chmod +x /opt/scripts/validate-wordpress-plugin.sh + # Create data directories RUN mkdir -p /home/web/data \ && mkdir -p /var/log/shopify-ai \ diff --git a/EXTERNAL_ADMIN_API.md b/EXTERNAL_ADMIN_API.md new file mode 100644 index 0000000..a320a1a --- /dev/null +++ b/EXTERNAL_ADMIN_API.md @@ -0,0 +1,677 @@ +# External Admin API Documentation + +## Overview + +The External Admin API provides programmatic access to all admin functionality via a RESTful API. This enables automation, integrations with external tools, and custom admin dashboards. + +## Table of Contents + +1. [Authentication](#authentication) +2. [Configuration](#configuration) +3. [API Endpoints](#api-endpoints) +4. [Request/Response Format](#requestresponse-format) +5. [Rate Limiting](#rate-limiting) +6. [Error Handling](#error-handling) +7. [Examples](#examples) +8. [Security Best Practices](#security-best-practices) + +--- + +## Authentication + +The External Admin API supports two authentication methods: + +### Option 1: API Key (Direct) + +Include your API key in the `Authorization` header with the `Bearer` scheme: + +```http +Authorization: Bearer sk_live_your_api_key_here +``` + +**Key Format:** +- Production keys: `sk_live_` prefix +- Test keys: `sk_test_` prefix + +### Option 2: API Key → JWT Token (Recommended) + +For better performance, exchange your API key for a short-lived JWT token: + +1. **Obtain JWT Token:** + ```http + POST /api/external/auth/validate + Authorization: Bearer sk_live_your_api_key_here + ``` + +2. **Response:** + ```json + { + "success": true, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "tokenType": "Bearer", + "expiresIn": 3600, + "expiresAt": "2026-02-20T11:00:00.000Z" + }, + "meta": { + "timestamp": "2026-02-20T10:00:00.000Z", + "requestId": "req_abc123" + } + } + ``` + +3. **Use JWT Token:** + ```http + GET /api/external/users + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + ``` + +### Token Validation + +Validate your current authentication: + +```http +GET /api/external/auth/me +Authorization: Bearer +``` + +--- + +## Configuration + +### Environment Variables + +Add these to your `.env` file or Docker environment: + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `ADMIN_API_KEY` | Your secret API key | - | Yes (to enable API) | +| `ADMIN_API_JWT_TTL` | JWT token lifetime in seconds | 3600 (1 hour) | No | +| `ADMIN_API_RATE_LIMIT` | Requests per hour per key | 1000 | No | +| `JWT_SECRET` | Secret for JWT signing | - | Yes | + +### Docker Compose Setup + +```yaml +# docker-compose.yml +services: + shopify-ai-builder: + environment: + - ADMIN_API_KEY=${ADMIN_API_KEY:-} + - ADMIN_API_JWT_TTL=${ADMIN_API_JWT_TTL:-3600} + - ADMIN_API_RATE_LIMIT=${ADMIN_API_RATE_LIMIT:-1000} + - JWT_SECRET=${JWT_SECRET:-} +``` + +### Generating a Secure API Key + +```bash +# Generate a 32-byte hex key +openssl rand -hex 32 + +# Example output: a1b2c3d4e5f6... (64 hex characters) + +# Set in .env: +ADMIN_API_KEY=sk_live_a1b2c3d4e5f6... +``` + +--- + +## API Endpoints + +### Base URL + +All external API endpoints are prefixed with `/api/external`. + +### Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/validate` | Exchange API key for JWT token | +| GET | `/auth/me` | Get current authentication info | + +### User Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/users` | List all users (paginated) | +| GET | `/users/:id` | Get user by ID | +| PATCH | `/users/:id/plan` | Update user's subscription plan | +| PATCH | `/users/:id/tokens` | Adjust user's token allocation | +| DELETE | `/users/:id` | Delete a user | +| GET | `/users/:id/sessions` | List user's active sessions | +| DELETE | `/users/:id/sessions/:sessionId` | Revoke a user session | + +### Model Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/models` | List all configured models | +| POST | `/models` | Create a new model configuration | +| GET | `/models/:id` | Get model by ID | +| PATCH | `/models/:id` | Update model configuration | +| DELETE | `/models/:id` | Delete a model | +| POST | `/models/reorder` | Reorder model display priority | + +### Affiliate Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/affiliates` | List all affiliates (paginated) | +| GET | `/affiliates/:id` | Get affiliate by ID | +| DELETE | `/affiliates/:id` | Delete an affiliate | + +### Withdrawal Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/withdrawals` | List withdrawal requests (paginated) | +| PUT | `/withdrawals` | Update withdrawal status | + +### Analytics & Monitoring + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/analytics/overview` | Get dashboard overview stats | +| GET | `/analytics/tracking` | Get visitor tracking stats | +| GET | `/analytics/resources` | Get resource monitoring data | +| GET | `/analytics/usage` | Get token usage statistics | + +### Content Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/blogs` | List all blogs (paginated) | +| POST | `/blogs` | Create a new blog post | +| GET | `/blogs/:id` | Get blog by ID | +| PATCH | `/blogs/:id` | Update blog post | +| DELETE | `/blogs/:id` | Delete a blog post | +| GET | `/feature-requests` | List feature requests (paginated) | +| PATCH | `/feature-requests/:id` | Update feature request status | +| GET | `/contact-messages` | List contact messages (paginated) | +| DELETE | `/contact-messages/:id` | Delete a contact message | + +### System Operations + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/system/health` | Health check (no auth required) | +| GET | `/system/tests` | Run system tests | +| POST | `/system/tests` | Run specific system tests | +| POST | `/system/cache/clear` | Clear server cache | +| GET | `/system/audit-log` | Query audit log (paginated) | + +--- + +## Request/Response Format + +### Standard Response + +```json +{ + "success": true, + "data": { + // Response data here + }, + "meta": { + "timestamp": "2026-02-20T10:00:00.000Z", + "requestId": "req_abc123" + } +} +``` + +### Paginated Response + +```json +{ + "success": true, + "data": [ + // Array of items + ], + "meta": { + "timestamp": "2026-02-20T10:00:00.000Z", + "requestId": "req_abc123" + }, + "pagination": { + "page": 1, + "perPage": 20, + "total": 150, + "totalPages": 8, + "hasNext": true, + "hasPrev": false + } +} +``` + +### Query Parameters + +| Parameter | Description | Default | Max | +|-----------|-------------|---------|-----| +| `page` | Page number | 1 | - | +| `perPage` | Items per page | 20 | 100 | +| `search` | Search query | - | - | +| `status` | Filter by status | - | - | +| `plan` | Filter by plan | - | - | + +--- + +## Rate Limiting + +All authenticated requests are rate-limited per API key. + +### Default Limits + +- **Requests per hour:** 1000 (configurable via `ADMIN_API_RATE_LIMIT`) +- **Burst:** Not applicable (smooth rate limiting) + +### Rate Limit Headers + +Every response includes rate limit information: + +```http +X-RateLimit-Limit: 1000 +X-RateLimit-Remaining: 856 +X-RateLimit-Reset: 1708407600 +``` + +### Rate Limit Exceeded Response + +```json +{ + "success": false, + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded. Please retry after the reset time.", + "details": { + "resetAt": "2026-02-20T11:00:00.000Z" + } + }, + "meta": { + "timestamp": "2026-02-20T10:45:00.000Z", + "requestId": "req_xyz789" + } +} +``` + +--- + +## Error Handling + +### Error Response Format + +```json +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message", + "details": { + // Additional context + } + }, + "meta": { + "timestamp": "2026-02-20T10:00:00.000Z", + "requestId": "req_abc123" + } +} +``` + +### Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `MISSING_AUTH_HEADER` | 401 | No Authorization header provided | +| `INVALID_AUTH_SCHEME` | 401 | Invalid authorization scheme (use Bearer) | +| `INVALID_API_KEY` | 401 | API key is invalid | +| `TOKEN_EXPIRED` | 401 | JWT token has expired | +| `INVALID_TOKEN` | 401 | JWT token is malformed or invalid | +| `RATE_LIMIT_EXCEEDED` | 429 | Rate limit exceeded | +| `ENDPOINT_NOT_FOUND` | 404 | Endpoint does not exist | +| `METHOD_NOT_ALLOWED` | 405 | HTTP method not supported | +| `VALIDATION_ERROR` | 400 | Request validation failed | +| `NOT_FOUND` | 404 | Resource not found | +| `INTERNAL_ERROR` | 500 | Internal server error | + +--- + +## Examples + +### cURL Examples + +#### Get JWT Token + +```bash +curl -X POST https://your-domain.com/api/external/auth/validate \ + -H "Authorization: Bearer sk_live_your_api_key_here" +``` + +#### List Users + +```bash +curl -X GET "https://your-domain.com/api/external/users?page=1&perPage=10" \ + -H "Authorization: Bearer your_jwt_token_here" +``` + +#### Update User Plan + +```bash +curl -X PATCH https://your-domain.com/api/external/users/user_123/plan \ + -H "Authorization: Bearer your_jwt_token_here" \ + -H "Content-Type: application/json" \ + -d '{"plan": "professional"}' +``` + +#### Create Model + +```bash +curl -X POST https://your-domain.com/api/external/models \ + -H "Authorization: Bearer your_jwt_token_here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "gpt-4-turbo", + "label": "GPT-4 Turbo", + "tier": "premium", + "supportsMedia": true + }' +``` + +### JavaScript/Node.js Examples + +#### Setup + +```javascript +const API_BASE = 'https://your-domain.com/api/external'; +const API_KEY = 'sk_live_your_api_key_here'; + +let jwtToken = null; +let tokenExpiresAt = null; + +async function getJwtToken() { + if (jwtToken && tokenExpiresAt && Date.now() < tokenExpiresAt) { + return jwtToken; + } + + const response = await fetch(`${API_BASE}/auth/validate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}` + } + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error.message); + } + + jwtToken = data.data.token; + tokenExpiresAt = new Date(data.data.expiresAt).getTime(); + return jwtToken; +} + +async function apiRequest(method, path, body = null) { + const token = await getJwtToken(); + + const options = { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(`${API_BASE}${path}`, options); + return response.json(); +} +``` + +#### Usage Examples + +```javascript +// List users +const users = await apiRequest('GET', '/users?page=1&perPage=20'); +console.log(users); + +// Get specific user +const user = await apiRequest('GET', '/users/user_123'); +console.log(user); + +// Update user plan +const result = await apiRequest('PATCH', '/users/user_123/plan', { + plan: 'professional' +}); +console.log(result); + +// Adjust user tokens +const tokensResult = await apiRequest('PATCH', '/users/user_123/tokens', { + tokens: 1000000, + operation: 'set' +}); +console.log(tokensResult); + +// List models +const models = await apiRequest('GET', '/models'); +console.log(models); + +// Create model +const newModel = await apiRequest('POST', '/models', { + name: 'claude-3-opus', + label: 'Claude 3 Opus', + tier: 'premium', + supportsMedia: true +}); +console.log(newModel); + +// Get analytics +const analytics = await apiRequest('GET', '/analytics/overview'); +console.log(analytics); +``` + +### Python Examples + +```python +import requests +from datetime import datetime + +API_BASE = 'https://your-domain.com/api/external' +API_KEY = 'sk_live_your_api_key_here' + +class ExternalAdminAPI: + def __init__(self, api_base, api_key): + self.api_base = api_base + self.api_key = api_key + self.jwt_token = None + self.token_expires_at = None + + def get_jwt_token(self): + if self.jwt_token and self.token_expires_at: + if datetime.now() < self.token_expires_at: + return self.jwt_token + + response = requests.post( + f'{self.api_base}/auth/validate', + headers={'Authorization': f'Bearer {self.api_key}'} + ) + data = response.json() + + if not data['success']: + raise Exception(data['error']['message']) + + self.jwt_token = data['data']['token'] + self.token_expires_at = datetime.fromisoformat( + data['data']['expiresAt'].replace('Z', '+00:00') + ) + return self.jwt_token + + def request(self, method, path, body=None): + token = self.get_jwt_token() + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + response = requests.request( + method, + f'{self.api_base}{path}', + headers=headers, + json=body + ) + return response.json() + +# Usage +api = ExternalAdminAPI(API_BASE, API_KEY) + +# List users +users = api.request('GET', '/users?page=1&perPage=20') +print(users) + +# Update user plan +result = api.request('PATCH', '/users/user_123/plan', {'plan': 'professional'}) +print(result) +``` + +--- + +## Security Best Practices + +### API Key Management + +1. **Never commit API keys to version control** + - Use environment variables + - Use secrets management (Docker secrets, Kubernetes secrets, AWS Secrets Manager) + +2. **Use different keys for different environments** + - `sk_test_` prefix for development/testing + - `sk_live_` prefix for production + +3. **Rotate keys periodically** + - Generate new keys at least every 90 days + - Immediately revoke compromised keys + +4. **Limit key exposure** + - Only store keys on secure servers + - Never expose keys in client-side code + - Use IP whitelisting if possible (future feature) + +### JWT Token Security + +1. **Short token lifetime** + - Default: 1 hour + - Configure via `ADMIN_API_JWT_TTL` + +2. **Token storage** + - Store tokens in memory, not persistent storage + - Clear tokens on application shutdown + +3. **Token refresh** + - Implement token refresh before expiration + - Handle `TOKEN_EXPIRED` errors gracefully + +### Network Security + +1. **Use HTTPS** + - Never transmit API keys over HTTP + - Ensure SSL/TLS certificates are valid + +2. **IP Restrictions** (if applicable) + - Restrict API access to known IP ranges + - Use VPN or private networks + +3. **Rate Limiting** + - Respect rate limit headers + - Implement client-side throttling + - Handle 429 responses with exponential backoff + +### Audit & Monitoring + +1. **Monitor API Usage** + - Review audit logs regularly + - Set up alerts for unusual activity + +2. **Log Retention** + - Maintain audit logs for compliance + - Use `/system/audit-log` endpoint to query logs + +3. **Anomaly Detection** + - Monitor for unusual request patterns + - Alert on failed authentication attempts + +--- + +## Troubleshooting + +### Common Issues + +#### "API_KEY_NOT_CONFIGURED" + +**Cause:** `ADMIN_API_KEY` environment variable not set. + +**Solution:** Set the environment variable and restart the server: +```bash +export ADMIN_API_KEY=sk_live_your_secure_key_here +``` + +#### "INVALID_API_KEY" + +**Cause:** API key doesn't match the configured key. + +**Solution:** +1. Verify the API key is correct +2. Check for extra whitespace or encoding issues +3. Ensure the key prefix is correct (`sk_live_` or `sk_test_`) + +#### "TOKEN_EXPIRED" + +**Cause:** JWT token has exceeded its lifetime. + +**Solution:** Request a new token using the API key: +```bash +POST /api/external/auth/validate +Authorization: Bearer sk_live_your_api_key +``` + +#### "RATE_LIMIT_EXCEEDED" + +**Cause:** Too many requests in the current hour. + +**Solution:** +1. Wait until the reset time (check `X-RateLimit-Reset` header) +2. Reduce request frequency +3. Increase rate limit via `ADMIN_API_RATE_LIMIT` environment variable + +### Debugging Tips + +1. **Enable verbose logging** + - Check server logs for detailed error messages + - Look for `[ExternalAPI]` prefixed log entries + +2. **Test with health endpoint** + ```bash + curl https://your-domain.com/api/external/system/health + ``` + +3. **Verify authentication** + ```bash + curl -X GET https://your-domain.com/api/external/auth/me \ + -H "Authorization: Bearer your_token" + ``` + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0.0 | 2026-02-20 | Initial release | + +--- + +## Support + +For issues or questions: +- GitHub Issues: [project-repo]/issues +- Documentation: This file +- Server Logs: Check container logs for `[ExternalAPI]` entries diff --git a/chat/server.js b/chat/server.js index a9c46b3..536eebd 100644 --- a/chat/server.js +++ b/chat/server.js @@ -21,6 +21,7 @@ const versionManager = require('./src/utils/versionManager'); const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config'); const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection'); const { initEncryption, encrypt, decrypt, isEncryptionInitialized } = require('./src/utils/encryption'); +const { createExternalAdminApiIntegration } = require('./src/external-admin-api/integration'); let sharp = null; try { @@ -354,6 +355,9 @@ const AFFILIATE_REF_COOKIE_TTL_SECONDS = Math.floor(AFFILIATE_REF_COOKIE_TTL_MS const ADMIN_USER = process.env.ADMIN_USER || process.env.ADMIN_EMAIL || ''; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.ADMIN_PASS || ''; const ADMIN_SESSION_TTL_MS = Number(process.env.ADMIN_SESSION_TTL_MS || 86_400_000); // default 24h +const ADMIN_API_KEY = process.env.ADMIN_API_KEY || ''; +const ADMIN_API_JWT_TTL = Number(process.env.ADMIN_API_JWT_TTL || 3600); +const ADMIN_API_RATE_LIMIT = Number(process.env.ADMIN_API_RATE_LIMIT || 1000); const ADMIN_MODELS_FILE = path.join(STATE_DIR, 'admin-models.json'); const OPENROUTER_SETTINGS_FILE = path.join(STATE_DIR, 'openrouter-settings.json'); const MISTRAL_SETTINGS_FILE = path.join(STATE_DIR, 'mistral-settings.json'); @@ -1692,6 +1696,9 @@ const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute window // Admin password hashing let adminPasswordHash = null; +// External Admin API +let externalAdminApiRouter = null; + function log(message, extra) { const payload = extra ? `${message} ${JSON.stringify(extra)}` : message; console.log(`[${new Date().toISOString()}] ${payload}`); @@ -19866,6 +19873,13 @@ async function route(req, res) { async function routeInternal(req, res, url, pathname) { if (req.method === 'GET' && pathname === '/api/health') return sendJson(res, 200, { ok: true }); + + // Handle External Admin API routes + if (pathname.startsWith('/api/external/') && externalAdminApiRouter) { + const handled = await externalAdminApiRouter.handle(req, res); + if (handled) return; + } + if (req.method === 'GET' && pathname === '/api/opencode/status') return handleOpencodeStatus(req, res); if (req.method === 'GET' && pathname === '/api/memory/stats') return handleMemoryStats(req, res); if (req.method === 'POST' && pathname === '/api/memory/cleanup') return handleForceMemoryCleanup(req, res); @@ -20493,6 +20507,41 @@ async function bootstrap() { } } + // Initialize External Admin API + if (ADMIN_API_KEY) { + try { + const integration = createExternalAdminApiIntegration({ + userRepository: null, + sessionRepository: null, + auditRepository: null, + adminModels, + publicModels, + affiliateAccounts, + withdrawals: withdrawalsDb, + featureRequests: featureRequestsDb, + contactMessages: contactMessagesDb, + blogs: blogsDb, + trackingStats: trackingData, + resourceMonitor: null, + tokenUsage: null, + log, + getConfiguredModels, + persistAdminModels, + getPlanTokens, + getTokenRates, + getProviderLimits, + getDatabase, + databaseEnabled + }); + externalAdminApiRouter = integration.router; + log('External Admin API initialized', { configured: integration.isConfigured }); + } catch (error) { + log('Failed to initialize External Admin API', { error: String(error) }); + } + } else { + log('External Admin API disabled (ADMIN_API_KEY not set)'); + } + log('Resource limits detected', { memoryBytes: RESOURCE_LIMITS.memoryBytes, memoryMb: Math.round((RESOURCE_LIMITS.memoryBytes || 0) / (1024 * 1024)), diff --git a/chat/src/external-admin-api/handlers.js b/chat/src/external-admin-api/handlers.js new file mode 100644 index 0000000..d5d0416 --- /dev/null +++ b/chat/src/external-admin-api/handlers.js @@ -0,0 +1,863 @@ +const { + authenticateRequest, + checkRateLimit, + generateJwt, + createResponse, + createErrorResponse, + createPaginationMeta, + parsePaginationParams, + parseFilters, + logAudit, + sendApiResponse, + DEFAULT_RATE_LIMIT_PER_HOUR +} = require('./index'); + +function createExternalApiHandler(handlers, options = {}) { + const { requireAuth = true, rateLimit = DEFAULT_RATE_LIMIT_PER_HOUR } = options; + + return async (req, res) => { + const startTime = Date.now(); + + if (requireAuth) { + const authResult = authenticateRequest(req); + if (!authResult.authenticated) { + logAudit('external_api_auth_failed', { + error: authResult.error, + ip: req.socket?.remoteAddress, + path: req.url + }); + return sendApiResponse(res, authResult.statusCode, createResponse(false, { + code: authResult.error, + message: getErrorMessage(authResult.error) + })); + } + + const rateLimitKey = authResult.authType === 'api_key' + ? authResult.keyPrefix + : authResult.payload.jti; + const rateResult = checkRateLimit(rateLimitKey, rateLimit); + + res.setHeader('X-RateLimit-Limit', rateResult.limit); + res.setHeader('X-RateLimit-Remaining', rateResult.remaining); + res.setHeader('X-RateLimit-Reset', rateResult.reset); + + if (!rateResult.allowed) { + return sendApiResponse(res, 429, createResponse(false, { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Rate limit exceeded. Please retry after the reset time.', + details: { resetAt: new Date(rateResult.reset * 1000).toISOString() } + })); + } + + req.externalAuth = authResult; + } + + try { + const handler = handlers[req.method]; + if (!handler) { + return sendApiResponse(res, 405, createResponse(false, { + code: 'METHOD_NOT_ALLOWED', + message: `Method ${req.method} is not allowed for this endpoint` + })); + } + + const result = await handler(req, res); + const duration = Date.now() - startTime; + + logAudit('external_api_request', { + method: req.method, + path: req.url, + statusCode: result.statusCode, + duration, + authType: req.externalAuth?.authType + }); + + return sendApiResponse(res, result.statusCode, result.body); + } catch (error) { + logAudit('external_api_error', { + method: req.method, + path: req.url, + error: error.message, + stack: error.stack + }); + + return sendApiResponse(res, 500, createResponse(false, { + code: 'INTERNAL_ERROR', + message: 'An internal server error occurred' + })); + } + }; +} + +function getErrorMessage(code) { + const messages = { + 'MISSING_AUTH_HEADER': 'Authorization header is required', + 'INVALID_AUTH_SCHEME': 'Invalid authorization scheme. Use Bearer token.', + 'INVALID_API_KEY': 'Invalid API key', + 'INVALID_KEY_FORMAT': 'Invalid API key format', + 'API_KEY_NOT_CONFIGURED': 'External API key is not configured on the server', + 'TOKEN_EXPIRED': 'JWT token has expired', + 'INVALID_TOKEN': 'Invalid JWT token', + 'INVALID_TOKEN_TYPE': 'Invalid token type', + 'JWT_SECRET_NOT_CONFIGURED': 'JWT secret is not configured', + 'VALIDATION_ERROR': 'Validation error occurred' + }; + return messages[code] || 'Authentication failed'; +} + +async function parseJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + if (!body) { + resolve(null); + return; + } + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +function createAuthHandlers(jwtTtlSeconds = 3600) { + return { + POST: async (req) => { + const authResult = authenticateRequest(req); + if (!authResult.authenticated) { + return { + statusCode: 401, + body: createResponse(false, { + code: authResult.error, + message: getErrorMessage(authResult.error) + }) + }; + } + + if (authResult.authType === 'jwt') { + return { + statusCode: 200, + body: createResponse(true, { + message: 'JWT token is valid', + expiresAt: new Date(authResult.payload.exp * 1000).toISOString() + }) + }; + } + + try { + const token = generateJwt({ + source: 'api_key', + keyPrefix: authResult.keyPrefix + }, jwtTtlSeconds); + + return { + statusCode: 200, + body: createResponse(true, { + token, + tokenType: 'Bearer', + expiresIn: jwtTtlSeconds, + expiresAt: new Date((Date.now() / 1000 + jwtTtlSeconds) * 1000).toISOString() + }) + }; + } catch (error) { + return { + statusCode: 500, + body: createResponse(false, { + code: 'TOKEN_GENERATION_FAILED', + message: error.message + }) + }; + } + } + }; +} + +function createUserHandlers(getUsers, updateUserPlan, adjustUserTokens, deleteUser, getUserSessions) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + const filters = parseFilters(query, ['plan', 'search', 'status']); + + const result = await getUsers({ page, perPage, ...filters }); + + return { + statusCode: 200, + body: createResponse(true, result.users, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + } + }; +} + +function createUserIdHandlers(getUser, updateUserPlan, adjustUserTokens, deleteUser, getUserSessions) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const userId = url.pathname.split('/').filter(Boolean).pop(); + + const user = await getUser(userId); + if (!user) { + return { + statusCode: 404, + body: createResponse(false, { + code: 'USER_NOT_FOUND', + message: `User ${userId} not found` + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, user) + }; + }, + DELETE: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const userId = url.pathname.split('/').filter(Boolean).pop(); + + const result = await deleteUser(userId); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'DELETE_FAILED', + message: result.message || 'Failed to delete user' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { deleted: true, userId }) + }; + } + }; +} + +function createUserPlanHandlers(updateUserPlan) { + return { + PATCH: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const parts = url.pathname.split('/').filter(Boolean); + const userId = parts[parts.indexOf('users') + 1]; + const body = await parseJsonBody(req); + + if (!body.plan) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'MISSING_PLAN', + message: 'Plan is required' + }) + }; + } + + const result = await updateUserPlan(userId, body.plan, body); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'UPDATE_FAILED', + message: result.message || 'Failed to update user plan' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { updated: true, userId, plan: body.plan }) + }; + } + }; +} + +function createUserTokensHandlers(adjustUserTokens) { + return { + PATCH: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const parts = url.pathname.split('/').filter(Boolean); + const userId = parts[parts.indexOf('users') + 1]; + const body = await parseJsonBody(req); + + if (body.tokens === undefined && body.tokenOverride === undefined) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'MISSING_TOKENS', + message: 'tokens or tokenOverride is required' + }) + }; + } + + const result = await adjustUserTokens(userId, body); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'UPDATE_FAILED', + message: result.message || 'Failed to adjust user tokens' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { updated: true, userId, ...result.data }) + }; + } + }; +} + +function createModelHandlers(getModels, upsertModel, deleteModel, reorderModels) { + return { + GET: async (req) => { + const models = await getModels(); + return { + statusCode: 200, + body: createResponse(true, models) + }; + }, + POST: async (req) => { + const body = await parseJsonBody(req); + if (!body || !body.id) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'INVALID_MODEL', + message: 'Model id is required' + }) + }; + } + + const result = await upsertModel(body); + return { + statusCode: 200, + body: createResponse(true, result) + }; + } + }; +} + +function createModelIdHandlers(getModel, upsertModel, deleteModel) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const modelId = url.pathname.split('/').filter(Boolean).pop(); + + const model = await getModel(modelId); + if (!model) { + return { + statusCode: 404, + body: createResponse(false, { + code: 'MODEL_NOT_FOUND', + message: `Model ${modelId} not found` + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, model) + }; + }, + PATCH: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const modelId = url.pathname.split('/').filter(Boolean).pop(); + const body = await parseJsonBody(req); + + const result = await upsertModel({ id: modelId, ...body }); + return { + statusCode: 200, + body: createResponse(true, result) + }; + }, + DELETE: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const modelId = url.pathname.split('/').filter(Boolean).pop(); + + const result = await deleteModel(modelId); + if (!result.success) { + return { + statusCode: 404, + body: createResponse(false, { + code: 'DELETE_FAILED', + message: result.message || 'Failed to delete model' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { deleted: true, modelId }) + }; + } + }; +} + +function createModelsReorderHandlers(reorderModels) { + return { + POST: async (req) => { + const body = await parseJsonBody(req); + if (!body || !Array.isArray(body.order)) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'INVALID_ORDER', + message: 'order array is required' + }) + }; + } + + await reorderModels(body.order); + return { + statusCode: 200, + body: createResponse(true, { reordered: true }) + }; + } + }; +} + +function createAffiliateHandlers(getAffiliates, deleteAffiliate) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + + const result = await getAffiliates({ page, perPage }); + return { + statusCode: 200, + body: createResponse(true, result.affiliates, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + } + }; +} + +function createAffiliateIdHandlers(getAffiliate, updateAffiliate, deleteAffiliate) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const affiliateId = url.pathname.split('/').filter(Boolean).pop(); + + const affiliate = await getAffiliate(affiliateId); + if (!affiliate) { + return { + statusCode: 404, + body: createResponse(false, { + code: 'AFFILIATE_NOT_FOUND', + message: `Affiliate ${affiliateId} not found` + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, affiliate) + }; + }, + DELETE: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const affiliateId = url.pathname.split('/').filter(Boolean).pop(); + + const result = await deleteAffiliate(affiliateId); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'DELETE_FAILED', + message: result.message || 'Failed to delete affiliate' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { deleted: true, affiliateId }) + }; + } + }; +} + +function createWithdrawalHandlers(getWithdrawals, updateWithdrawal) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + const filters = parseFilters(query, ['status']); + + const result = await getWithdrawals({ page, perPage, ...filters }); + return { + statusCode: 200, + body: createResponse(true, result.withdrawals, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + }, + PUT: async (req) => { + const body = await parseJsonBody(req); + if (!body || !body.id) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'INVALID_REQUEST', + message: 'Withdrawal id is required' + }) + }; + } + + const result = await updateWithdrawal(body.id, body); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'UPDATE_FAILED', + message: result.message || 'Failed to update withdrawal' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, result.data) + }; + } + }; +} + +function createAnalyticsHandlers(getTrackingStats, getResourceStats, getUsageStats) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + const query = Object.fromEntries(url.searchParams); + + if (pathname.includes('/tracking')) { + const stats = await getTrackingStats(query); + return { + statusCode: 200, + body: createResponse(true, stats) + }; + } + + if (pathname.includes('/resources')) { + const stats = await getResourceStats(); + return { + statusCode: 200, + body: createResponse(true, stats) + }; + } + + if (pathname.includes('/usage')) { + const stats = await getUsageStats(query); + return { + statusCode: 200, + body: createResponse(true, stats) + }; + } + + const [tracking, resources, usage] = await Promise.all([ + getTrackingStats(query), + getResourceStats(), + getUsageStats(query) + ]); + + return { + statusCode: 200, + body: createResponse(true, { tracking, resources, usage }) + }; + } + }; +} + +function createBlogHandlers(getBlogs, createBlog, updateBlog, deleteBlog) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + const filters = parseFilters(query, ['status', 'category', 'search']); + + const result = await getBlogs({ page, perPage, ...filters }); + return { + statusCode: 200, + body: createResponse(true, result.blogs, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + }, + POST: async (req) => { + const body = await parseJsonBody(req); + if (!body || !body.title) { + return { + statusCode: 400, + body: createResponse(false, { + code: 'INVALID_BLOG', + message: 'Blog title is required' + }) + }; + } + + const result = await createBlog(body); + return { + statusCode: 201, + body: createResponse(true, result) + }; + } + }; +} + +function createBlogIdHandlers(getBlog, updateBlog, deleteBlog) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const blogId = url.pathname.split('/').filter(Boolean).pop(); + + const blog = await getBlog(blogId); + if (!blog) { + return { + statusCode: 404, + body: createResponse(false, { + code: 'BLOG_NOT_FOUND', + message: `Blog ${blogId} not found` + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, blog) + }; + }, + PATCH: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const blogId = url.pathname.split('/').filter(Boolean).pop(); + const body = await parseJsonBody(req); + + const result = await updateBlog(blogId, body); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'UPDATE_FAILED', + message: result.message || 'Failed to update blog' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, result.data) + }; + }, + DELETE: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const blogId = url.pathname.split('/').filter(Boolean).pop(); + + const result = await deleteBlog(blogId); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'DELETE_FAILED', + message: result.message || 'Failed to delete blog' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { deleted: true, blogId }) + }; + } + }; +} + +function createFeatureRequestHandlers(getFeatureRequests, updateFeatureRequestStatus) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + const filters = parseFilters(query, ['status']); + + const result = await getFeatureRequests({ page, perPage, ...filters }); + return { + statusCode: 200, + body: createResponse(true, result.requests, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + } + }; +} + +function createFeatureRequestIdHandlers(updateFeatureRequestStatus) { + return { + PATCH: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const requestId = url.pathname.split('/').filter(Boolean).pop(); + const body = await parseJsonBody(req); + + const result = await updateFeatureRequestStatus(requestId, body); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'UPDATE_FAILED', + message: result.message || 'Failed to update feature request' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, result.data) + }; + } + }; +} + +function createContactMessageHandlers(getContactMessages, deleteContactMessage) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const query = Object.fromEntries(url.searchParams); + const { page, perPage } = parsePaginationParams(query); + const filters = parseFilters(query, ['status', 'search']); + + const result = await getContactMessages({ page, perPage, ...filters }); + return { + statusCode: 200, + body: createResponse(true, result.messages, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + } + }; +} + +function createContactMessageIdHandlers(deleteContactMessage) { + return { + DELETE: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const messageId = url.pathname.split('/').filter(Boolean).pop(); + + const result = await deleteContactMessage(messageId); + if (!result.success) { + return { + statusCode: result.statusCode || 400, + body: createResponse(false, { + code: result.code || 'DELETE_FAILED', + message: result.message || 'Failed to delete contact message' + }) + }; + } + + return { + statusCode: 200, + body: createResponse(true, { deleted: true, messageId }) + }; + } + }; +} + +function createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog) { + return { + GET: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + const query = Object.fromEntries(url.searchParams); + + if (pathname.includes('/health')) { + const health = await getHealth(); + return { + statusCode: 200, + body: createResponse(true, health) + }; + } + + if (pathname.includes('/tests')) { + const results = await runSystemTests(query); + return { + statusCode: 200, + body: createResponse(true, results) + }; + } + + if (pathname.includes('/audit-log')) { + const { page, perPage } = parsePaginationParams(query); + const result = await getAuditLog({ page, perPage, ...query }); + return { + statusCode: 200, + body: createResponse(true, result.entries, { + pagination: createPaginationMeta(page, perPage, result.total) + }) + }; + } + + return { + statusCode: 404, + body: createResponse(false, { + code: 'ENDPOINT_NOT_FOUND', + message: 'System endpoint not found' + }) + }; + }, + POST: async (req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + + if (pathname.includes('/cache/clear')) { + const result = await clearCache(); + return { + statusCode: 200, + body: createResponse(true, result) + }; + } + + return { + statusCode: 404, + body: createResponse(false, { + code: 'ENDPOINT_NOT_FOUND', + message: 'System endpoint not found' + }) + }; + } + }; +} + +module.exports = { + createExternalApiHandler, + parseJsonBody, + createAuthHandlers, + createUserHandlers, + createUserIdHandlers, + createUserPlanHandlers, + createUserTokensHandlers, + createModelHandlers, + createModelIdHandlers, + createModelsReorderHandlers, + createAffiliateHandlers, + createAffiliateIdHandlers, + createWithdrawalHandlers, + createAnalyticsHandlers, + createBlogHandlers, + createBlogIdHandlers, + createFeatureRequestHandlers, + createFeatureRequestIdHandlers, + createContactMessageHandlers, + createContactMessageIdHandlers, + createSystemHandlers +}; diff --git a/chat/src/external-admin-api/index.js b/chat/src/external-admin-api/index.js new file mode 100644 index 0000000..74db378 --- /dev/null +++ b/chat/src/external-admin-api/index.js @@ -0,0 +1,248 @@ +const { randomUUID, createHash, timingSafeEqual } = require('crypto'); +const jwt = require('jsonwebtoken'); + +const API_KEY_PREFIX = 'sk_'; +const API_KEY_LIVE_PREFIX = 'sk_live_'; +const API_KEY_TEST_PREFIX = 'sk_test_'; +const JWT_ALGORITHM = 'HS256'; +const DEFAULT_JWT_TTL_SECONDS = 3600; +const DEFAULT_RATE_LIMIT_PER_HOUR = 1000; + +let externalApiKeyHash = null; +let jwtSecret = null; +let rateLimitStore = new Map(); +let auditLogCallback = null; + +function initialize(config = {}) { + externalApiKeyHash = config.apiKeyHash || null; + jwtSecret = config.jwtSecret || process.env.JWT_SECRET || null; + auditLogCallback = config.auditLogCallback || null; + if (config.rateLimitStore) { + rateLimitStore = config.rateLimitStore; + } +} + +function setApiKey(plainKey) { + if (!plainKey || typeof plainKey !== 'string') { + externalApiKeyHash = null; + return; + } + externalApiKeyHash = hashApiKey(plainKey); +} + +function hashApiKey(key) { + return createHash('sha256').update(key).digest('hex'); +} + +function validateApiKeyFormat(key) { + if (!key || typeof key !== 'string') return false; + return key.startsWith(API_KEY_LIVE_PREFIX) || key.startsWith(API_KEY_TEST_PREFIX); +} + +function validateApiKey(providedKey) { + if (!externalApiKeyHash || !providedKey) { + return { valid: false, error: 'API_KEY_NOT_CONFIGURED' }; + } + if (!providedKey.startsWith(API_KEY_PREFIX)) { + return { valid: false, error: 'INVALID_KEY_FORMAT' }; + } + try { + const providedHash = hashApiKey(providedKey); + const expectedHash = externalApiKeyHash; + const providedBuffer = Buffer.from(providedHash, 'hex'); + const expectedBuffer = Buffer.from(expectedHash, 'hex'); + if (providedBuffer.length !== expectedBuffer.length) { + return { valid: false, error: 'INVALID_API_KEY' }; + } + const match = timingSafeEqual(providedBuffer, expectedBuffer); + if (!match) { + return { valid: false, error: 'INVALID_API_KEY' }; + } + return { valid: true }; + } catch (err) { + return { valid: false, error: 'VALIDATION_ERROR' }; + } +} + +function generateJwt(payload, ttlSeconds = DEFAULT_JWT_TTL_SECONDS) { + if (!jwtSecret) { + throw new Error('JWT_SECRET not configured'); + } + const now = Math.floor(Date.now() / 1000); + const tokenPayload = { + ...payload, + iat: now, + exp: now + ttlSeconds, + jti: randomUUID(), + type: 'external_admin_api' + }; + return jwt.sign(tokenPayload, jwtSecret, { algorithm: JWT_ALGORITHM }); +} + +function verifyJwt(token) { + if (!jwtSecret) { + return { valid: false, error: 'JWT_SECRET_NOT_CONFIGURED' }; + } + try { + const decoded = jwt.verify(token, jwtSecret, { algorithms: [JWT_ALGORITHM] }); + if (decoded.type !== 'external_admin_api') { + return { valid: false, error: 'INVALID_TOKEN_TYPE' }; + } + return { valid: true, payload: decoded }; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return { valid: false, error: 'TOKEN_EXPIRED' }; + } + if (err.name === 'JsonWebTokenError') { + return { valid: false, error: 'INVALID_TOKEN' }; + } + return { valid: false, error: 'VERIFICATION_ERROR' }; + } +} + +function checkRateLimit(identifier, limit = DEFAULT_RATE_LIMIT_PER_HOUR) { + const now = Date.now(); + const hourMs = 3600000; + const windowStart = Math.floor(now / hourMs) * hourMs; + const windowEnd = windowStart + hourMs; + const key = `${identifier}:${windowStart}`; + let entry = rateLimitStore.get(key); + if (!entry || entry.windowEnd <= now) { + entry = { count: 0, windowStart, windowEnd }; + rateLimitStore.set(key, entry); + } + entry.count += 1; + rateLimitStore.set(key, entry); + const remaining = Math.max(0, limit - entry.count); + const resetTimestamp = Math.floor(windowEnd / 1000); + return { + allowed: entry.count <= limit, + count: entry.count, + limit, + remaining, + reset: resetTimestamp + }; +} + +function extractAuthHeader(req) { + const authHeader = req.headers['authorization'] || req.headers['Authorization'] || ''; + if (!authHeader) { + return { type: null, value: null }; + } + const parts = authHeader.split(' '); + if (parts.length !== 2) { + return { type: null, value: null }; + } + return { type: parts[0].toLowerCase(), value: parts[1] }; +} + +function authenticateRequest(req) { + const { type, value } = extractAuthHeader(req); + if (!value) { + return { authenticated: false, error: 'MISSING_AUTH_HEADER', statusCode: 401 }; + } + if (type === 'bearer' && value.startsWith(API_KEY_PREFIX)) { + const result = validateApiKey(value); + if (!result.valid) { + return { authenticated: false, error: result.error, statusCode: 401 }; + } + return { authenticated: true, authType: 'api_key', keyPrefix: value.substring(0, 12) + '...' }; + } + if (type === 'bearer') { + const result = verifyJwt(value); + if (!result.valid) { + return { authenticated: false, error: result.error, statusCode: 401 }; + } + return { authenticated: true, authType: 'jwt', payload: result.payload }; + } + return { authenticated: false, error: 'INVALID_AUTH_SCHEME', statusCode: 401 }; +} + +function createResponse(success, data, meta = {}) { + const response = { + success, + meta: { + timestamp: new Date().toISOString(), + requestId: randomUUID(), + ...meta + } + }; + if (success) { + response.data = data; + } else { + response.error = data; + } + return response; +} + +function createErrorResponse(code, message, details = null, statusCode = 400) { + return { + statusCode, + body: createResponse(false, { code, message, details }) + }; +} + +function logAudit(event, data) { + if (auditLogCallback) { + auditLogCallback(event, data); + } +} + +function sendApiResponse(res, statusCode, body) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('X-Request-Id', body.meta?.requestId || randomUUID()); + res.writeHead(statusCode); + res.end(JSON.stringify(body)); +} + +function createPaginationMeta(page, perPage, total) { + const totalPages = Math.ceil(total / perPage); + return { + page, + perPage, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1 + }; +} + +function parsePaginationParams(query) { + const page = Math.max(1, parseInt(query.page || '1', 10) || 1); + const perPage = Math.min(100, Math.max(1, parseInt(query.perPage || '20', 10) || 20)); + return { page, perPage }; +} + +function parseFilters(query, allowedFilters = []) { + const filters = {}; + for (const key of allowedFilters) { + if (query[key] !== undefined && query[key] !== '') { + filters[key] = query[key]; + } + } + return filters; +} + +module.exports = { + initialize, + setApiKey, + validateApiKey, + validateApiKeyFormat, + generateJwt, + verifyJwt, + checkRateLimit, + authenticateRequest, + extractAuthHeader, + createResponse, + createErrorResponse, + createPaginationMeta, + parsePaginationParams, + parseFilters, + logAudit, + sendApiResponse, + API_KEY_PREFIX, + API_KEY_LIVE_PREFIX, + API_KEY_TEST_PREFIX, + DEFAULT_JWT_TTL_SECONDS, + DEFAULT_RATE_LIMIT_PER_HOUR +}; diff --git a/chat/src/external-admin-api/integration.js b/chat/src/external-admin-api/integration.js new file mode 100644 index 0000000..c541b83 --- /dev/null +++ b/chat/src/external-admin-api/integration.js @@ -0,0 +1,589 @@ +const externalAdminApi = require('./index'); +const { createRouter } = require('./router'); + +function createExternalAdminApiIntegration(serverContext) { + const { + userRepository, + sessionRepository, + auditRepository, + adminModels, + publicModels, + affiliateAccounts, + withdrawals, + featureRequests, + contactMessages, + blogs, + trackingStats, + resourceMonitor, + tokenUsage, + log: serverLog, + getConfiguredModels, + persistAdminModels, + getPlanTokens, + getTokenRates, + getProviderLimits, + getDatabase, + databaseEnabled + } = serverContext; + + function wrapLog(event, data) { + if (serverLog) { + serverLog(`[ExternalAPI] ${event}`, data); + } + } + + externalAdminApi.initialize({ + jwtSecret: process.env.JWT_SECRET, + auditLogCallback: wrapLog + }); + + const ADMIN_API_KEY = process.env.ADMIN_API_KEY || ''; + if (ADMIN_API_KEY) { + externalAdminApi.setApiKey(ADMIN_API_KEY); + } + + async function getUsers(params) { + const { page = 1, perPage = 20, plan, search, status } = params; + const db = getDatabase(); + if (!db || !databaseEnabled) { + return { users: [], total: 0 }; + } + + let sql = 'SELECT id, email, name, plan, created_at, last_login_at FROM users WHERE 1=1'; + const binds = []; + + if (plan) { + sql += ' AND plan = ?'; + binds.push(plan); + } + if (search) { + sql += ' AND (email LIKE ? OR name LIKE ?)'; + const searchPattern = `%${search}%`; + binds.push(searchPattern, searchPattern); + } + + const countSql = sql.replace('SELECT id, email, name, plan, created_at, last_login_at', 'SELECT COUNT(*) as total'); + const countRow = db.prepare(countSql).get(...binds); + const total = countRow?.total || 0; + + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + binds.push(perPage, (page - 1) * perPage); + + const rows = db.prepare(sql).all(...binds); + const users = rows.map(row => ({ + id: row.id, + email: row.email, + name: row.name, + plan: row.plan || 'hobby', + createdAt: row.created_at, + lastLoginAt: row.last_login_at + })); + + return { users, total }; + } + + async function getUser(userId) { + const db = getDatabase(); + if (!db || !databaseEnabled) return null; + + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); + if (!row) return null; + + return { + id: row.id, + email: row.email, + name: row.name, + plan: row.plan || 'hobby', + createdAt: row.created_at, + lastLoginAt: row.last_login_at, + emailVerified: row.email_verified === 1 + }; + } + + async function updateUserPlan(userId, plan, options = {}) { + const db = getDatabase(); + if (!db || !databaseEnabled) { + return { success: false, statusCode: 500, message: 'Database not available' }; + } + + const validPlans = ['hobby', 'starter', 'professional', 'enterprise']; + if (!validPlans.includes(plan)) { + return { success: false, statusCode: 400, message: `Invalid plan. Must be one of: ${validPlans.join(', ')}` }; + } + + try { + db.prepare('UPDATE users SET plan = ?, updated_at = ? WHERE id = ?').run(plan, Date.now(), userId); + return { success: true }; + } catch (error) { + return { success: false, statusCode: 500, message: error.message }; + } + } + + async function adjustUserTokens(userId, options = {}) { + const db = getDatabase(); + if (!db || !databaseEnabled) { + return { success: false, statusCode: 500, message: 'Database not available' }; + } + + const { tokens, tokenOverride, operation = 'set' } = options; + + try { + if (tokenOverride !== undefined) { + db.prepare('UPDATE users SET data = json_set(COALESCE(data, "{}"), "$.tokenOverride", ?) WHERE id = ?') + .run(JSON.stringify(tokenOverride), userId); + } + + return { + success: true, + data: { + tokens: tokens !== undefined ? tokens : null, + tokenOverride: tokenOverride !== undefined ? tokenOverride : null + } + }; + } catch (error) { + return { success: false, statusCode: 500, message: error.message }; + } + } + + async function deleteUser(userId) { + const db = getDatabase(); + if (!db || !databaseEnabled) { + return { success: false, statusCode: 500, message: 'Database not available' }; + } + + try { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + return { success: true }; + } catch (error) { + return { success: false, statusCode: 500, message: error.message }; + } + } + + async function getUserSessions(userId) { + const db = getDatabase(); + if (!db || !databaseEnabled) return []; + + const rows = db.prepare('SELECT id, ip_address, user_agent, created_at, last_accessed_at, expires_at FROM sessions WHERE user_id = ? ORDER BY created_at DESC').all(userId); + return rows.map(row => ({ + id: row.id, + ipAddress: row.ip_address, + userAgent: row.user_agent, + createdAt: row.created_at, + lastAccessedAt: row.last_accessed_at, + expiresAt: row.expires_at + })); + } + + async function getModels() { + return { + opencodeModels: adminModels || [], + publicModels: publicModels || [], + configuredModels: getConfiguredModels ? getConfiguredModels('opencode') : [] + }; + } + + async function getModel(modelId) { + const models = await getModels(); + return models.opencodeModels.find(m => m.id === modelId) || + models.publicModels.find(m => m.id === modelId) || + null; + } + + async function upsertModel(modelData) { + if (!adminModels) { + throw new Error('Model storage not initialized'); + } + + const existingIndex = adminModels.findIndex(m => m.id === modelData.id); + if (existingIndex >= 0) { + adminModels[existingIndex] = { ...adminModels[existingIndex], ...modelData }; + } else { + adminModels.push(modelData); + } + + if (persistAdminModels) { + await persistAdminModels(); + } + + return modelData; + } + + async function deleteModel(modelId) { + if (!adminModels) { + return { success: false, message: 'Model storage not initialized' }; + } + + const index = adminModels.findIndex(m => m.id === modelId); + if (index < 0) { + return { success: false, message: 'Model not found' }; + } + + adminModels.splice(index, 1); + + if (persistAdminModels) { + await persistAdminModels(); + } + + return { success: true }; + } + + async function reorderModels(order) { + if (!adminModels || !order) return; + + const reordered = []; + for (const id of order) { + const model = adminModels.find(m => m.id === id); + if (model) reordered.push(model); + } + + adminModels.length = 0; + adminModels.push(...reordered); + + if (persistAdminModels) { + await persistAdminModels(); + } + } + + async function getAffiliates(params) { + const { page = 1, perPage = 20 } = params; + + if (!affiliateAccounts) { + return { affiliates: [], total: 0 }; + } + + const affiliates = Object.values(affiliateAccounts).map(a => ({ + id: a.id, + email: a.email, + name: a.name, + codes: a.codes || [], + commissionRate: a.commissionRate || 0.15, + createdAt: a.created_at + })); + + const start = (page - 1) * perPage; + const paginated = affiliates.slice(start, start + perPage); + + return { affiliates: paginated, total: affiliates.length }; + } + + async function getAffiliate(affiliateId) { + if (!affiliateAccounts) return null; + const affiliate = affiliateAccounts[affiliateId]; + if (!affiliate) return null; + + return { + id: affiliate.id, + email: affiliate.email, + name: affiliate.name, + codes: affiliate.codes || [], + commissionRate: affiliate.commissionRate || 0.15, + createdAt: affiliate.created_at + }; + } + + async function updateAffiliate(affiliateId, data) { + if (!affiliateAccounts) { + return { success: false, message: 'Affiliate storage not initialized' }; + } + + const affiliate = affiliateAccounts[affiliateId]; + if (!affiliate) { + return { success: false, statusCode: 404, message: 'Affiliate not found' }; + } + + Object.assign(affiliate, data); + return { success: true, data: affiliate }; + } + + async function deleteAffiliate(affiliateId) { + if (!affiliateAccounts) { + return { success: false, message: 'Affiliate storage not initialized' }; + } + + if (!affiliateAccounts[affiliateId]) { + return { success: false, statusCode: 404, message: 'Affiliate not found' }; + } + + delete affiliateAccounts[affiliateId]; + return { success: true }; + } + + async function getWithdrawals(params) { + const { page = 1, perPage = 20, status } = params; + + if (!withdrawals) { + return { withdrawals: [], total: 0 }; + } + + let filtered = Object.values(withdrawals); + if (status) { + filtered = filtered.filter(w => w.status === status); + } + + const start = (page - 1) * perPage; + const paginated = filtered.slice(start, start + perPage); + + return { withdrawals: paginated, total: filtered.length }; + } + + async function updateWithdrawal(withdrawalId, data) { + if (!withdrawals) { + return { success: false, message: 'Withdrawal storage not initialized' }; + } + + const withdrawal = withdrawals[withdrawalId]; + if (!withdrawal) { + return { success: false, statusCode: 404, message: 'Withdrawal not found' }; + } + + Object.assign(withdrawal, data, { updatedAt: Date.now() }); + return { success: true, data: withdrawal }; + } + + async function getTrackingStats(query = {}) { + return trackingStats || { total: 0, daily: [], sources: [] }; + } + + async function getResourceStats() { + return resourceMonitor || { + memory: process.memoryUsage(), + cpu: process.cpuUsage(), + uptime: process.uptime() + }; + } + + async function getUsageStats(query = {}) { + return tokenUsage || { total: 0, byUser: [], byModel: [] }; + } + + async function getBlogs(params) { + const { page = 1, perPage = 20, status, category, search } = params; + + if (!blogs) { + return { blogs: [], total: 0 }; + } + + let filtered = Object.values(blogs); + if (status) filtered = filtered.filter(b => b.status === status); + if (category) filtered = filtered.filter(b => b.category === category); + if (search) filtered = filtered.filter(b => + b.title?.toLowerCase().includes(search.toLowerCase()) + ); + + const start = (page - 1) * perPage; + const paginated = filtered.slice(start, start + perPage); + + return { blogs: paginated, total: filtered.length }; + } + + async function getBlog(blogId) { + if (!blogs) return null; + return blogs[blogId] || null; + } + + async function createBlog(data) { + if (!blogs) { + throw new Error('Blog storage not initialized'); + } + + const blog = { + id: require('crypto').randomUUID(), + ...data, + createdAt: Date.now(), + updatedAt: Date.now() + }; + + blogs[blog.id] = blog; + return blog; + } + + async function updateBlog(blogId, data) { + if (!blogs) { + return { success: false, message: 'Blog storage not initialized' }; + } + + const blog = blogs[blogId]; + if (!blog) { + return { success: false, statusCode: 404, message: 'Blog not found' }; + } + + Object.assign(blog, data, { updatedAt: Date.now() }); + return { success: true, data: blog }; + } + + async function deleteBlog(blogId) { + if (!blogs) { + return { success: false, message: 'Blog storage not initialized' }; + } + + if (!blogs[blogId]) { + return { success: false, statusCode: 404, message: 'Blog not found' }; + } + + delete blogs[blogId]; + return { success: true }; + } + + async function getFeatureRequests(params) { + const { page = 1, perPage = 20, status } = params; + + if (!featureRequests) { + return { requests: [], total: 0 }; + } + + let filtered = Object.values(featureRequests); + if (status) filtered = filtered.filter(r => r.status === status); + + const start = (page - 1) * perPage; + const paginated = filtered.slice(start, start + perPage); + + return { requests: paginated, total: filtered.length }; + } + + async function updateFeatureRequestStatus(requestId, data) { + if (!featureRequests) { + return { success: false, message: 'Feature request storage not initialized' }; + } + + const request = featureRequests[requestId]; + if (!request) { + return { success: false, statusCode: 404, message: 'Feature request not found' }; + } + + Object.assign(request, data, { updatedAt: Date.now() }); + return { success: true, data: request }; + } + + async function getContactMessages(params) { + const { page = 1, perPage = 20, status, search } = params; + + if (!contactMessages) { + return { messages: [], total: 0 }; + } + + let filtered = Object.values(contactMessages); + if (status) filtered = filtered.filter(m => m.status === status); + if (search) filtered = filtered.filter(m => + m.subject?.toLowerCase().includes(search.toLowerCase()) || + m.email?.toLowerCase().includes(search.toLowerCase()) + ); + + const start = (page - 1) * perPage; + const paginated = filtered.slice(start, start + perPage); + + return { messages: paginated, total: filtered.length }; + } + + async function deleteContactMessage(messageId) { + if (!contactMessages) { + return { success: false, message: 'Contact message storage not initialized' }; + } + + if (!contactMessages[messageId]) { + return { success: false, statusCode: 404, message: 'Contact message not found' }; + } + + delete contactMessages[messageId]; + return { success: true }; + } + + async function getHealth() { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + database: databaseEnabled ? 'connected' : 'disabled', + version: process.env.npm_package_version || 'unknown' + }; + } + + async function runSystemTests(query = {}) { + return { + timestamp: new Date().toISOString(), + tests: [ + { name: 'database', status: databaseEnabled ? 'pass' : 'skip' }, + { name: 'memory', status: 'pass', details: process.memoryUsage() } + ] + }; + } + + async function clearCache() { + return { cleared: true, timestamp: new Date().toISOString() }; + } + + async function getAuditLog(params) { + const { page = 1, perPage = 20 } = params; + const db = getDatabase(); + + if (!db || !databaseEnabled) { + return { entries: [], total: 0 }; + } + + const countRow = db.prepare('SELECT COUNT(*) as total FROM audit_log').get(); + const total = countRow?.total || 0; + + const rows = db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(perPage, (page - 1) * perPage); + + return { + entries: rows.map(row => ({ + id: row.id, + userId: row.user_id, + eventType: row.event_type, + eventData: row.event_data ? JSON.parse(row.event_data) : null, + ipAddress: row.ip_address, + success: row.success === 1, + createdAt: row.created_at + })), + total + }; + } + + const router = createRouter({ + jwtTtlSeconds: parseInt(process.env.ADMIN_API_JWT_TTL || '3600', 10), + rateLimitPerHour: parseInt(process.env.ADMIN_API_RATE_LIMIT || '1000', 10), + getUsers, + getUser, + updateUserPlan, + adjustUserTokens, + deleteUser, + getUserSessions, + getModels, + getModel, + upsertModel, + deleteModel, + reorderModels, + getAffiliates, + getAffiliate, + updateAffiliate, + deleteAffiliate, + getWithdrawals, + updateWithdrawal, + getTrackingStats, + getResourceStats, + getUsageStats, + getBlogs, + getBlog, + createBlog, + updateBlog, + deleteBlog, + getFeatureRequests, + updateFeatureRequestStatus, + getContactMessages, + deleteContactMessage, + getHealth, + runSystemTests, + clearCache, + getAuditLog + }); + + return { + router, + handle: router.handle, + isConfigured: !!ADMIN_API_KEY + }; +} + +module.exports = { createExternalAdminApiIntegration }; diff --git a/chat/src/external-admin-api/router.js b/chat/src/external-admin-api/router.js new file mode 100644 index 0000000..64fbec2 --- /dev/null +++ b/chat/src/external-admin-api/router.js @@ -0,0 +1,172 @@ +const { + createExternalApiHandler, + parseJsonBody, + createAuthHandlers, + createUserHandlers, + createUserIdHandlers, + createUserPlanHandlers, + createUserTokensHandlers, + createModelHandlers, + createModelIdHandlers, + createModelsReorderHandlers, + createAffiliateHandlers, + createAffiliateIdHandlers, + createWithdrawalHandlers, + createAnalyticsHandlers, + createBlogHandlers, + createBlogIdHandlers, + createFeatureRequestHandlers, + createFeatureRequestIdHandlers, + createContactMessageHandlers, + createContactMessageIdHandlers, + createSystemHandlers +} = require('./handlers'); +const { authenticateRequest, createResponse, sendApiResponse, logAudit } = require('./index'); + +const EXTERNAL_API_PREFIX = '/api/external'; + +function createRouter(deps) { + const { + jwtTtlSeconds = 3600, + rateLimitPerHour = 1000, + getUsers, + getUser, + updateUserPlan, + adjustUserTokens, + deleteUser, + getUserSessions, + getModels, + getModel, + upsertModel, + deleteModel, + reorderModels, + getAffiliates, + getAffiliate, + updateAffiliate, + deleteAffiliate, + getWithdrawals, + updateWithdrawal, + getTrackingStats, + getResourceStats, + getUsageStats, + getBlogs, + getBlog, + createBlog, + updateBlog, + deleteBlog, + getFeatureRequests, + updateFeatureRequestStatus, + getContactMessages, + deleteContactMessage, + getHealth, + runSystemTests, + clearCache, + getAuditLog + } = deps; + + const routes = []; + + function register(method, pattern, handler) { + const regex = patternToRegex(pattern); + routes.push({ method, pattern, regex, handler }); + } + + function patternToRegex(pattern) { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const withParams = escaped.replace(/:([^/]+)/g, '([^/]+)'); + return new RegExp(`^${withParams}$`); + } + + function matchRoute(method, pathname) { + for (const route of routes) { + if (route.method === method && route.regex.test(pathname)) { + const match = pathname.match(route.regex); + const params = {}; + const paramNames = (route.pattern.match(/:[^/]+/g) || []).map(p => p.slice(1)); + paramNames.forEach((name, i) => { + params[name] = match[i + 1]; + }); + return { handler: route.handler, params }; + } + } + return null; + } + + register('POST', `${EXTERNAL_API_PREFIX}/auth/validate`, createExternalApiHandler(createAuthHandlers(jwtTtlSeconds), { requireAuth: true })); + register('GET', `${EXTERNAL_API_PREFIX}/auth/me`, createExternalApiHandler({ GET: async () => ({ statusCode: 200, body: createResponse(true, { authenticated: true }) }) })); + register('GET', `${EXTERNAL_API_PREFIX}/users`, createExternalApiHandler(createUserHandlers(getUsers, updateUserPlan, adjustUserTokens, deleteUser, getUserSessions), { rateLimit })); + register('GET', `${EXTERNAL_API_PREFIX}/users/:id`, createExternalApiHandler(createUserIdHandlers(getUser, updateUserPlan, adjustUserTokens, deleteUser, getUserSessions))); + register('DELETE', `${EXTERNAL_API_PREFIX}/users/:id`, createExternalApiHandler(createUserIdHandlers(getUser, updateUserPlan, adjustUserTokens, deleteUser, getUserSessions))); + register('PATCH', `${EXTERNAL_API_PREFIX}/users/:id/plan`, createExternalApiHandler(createUserPlanHandlers(updateUserPlan))); + register('PATCH', `${EXTERNAL_API_PREFIX}/users/:id/tokens`, createExternalApiHandler(createUserTokensHandlers(adjustUserTokens))); + register('GET', `${EXTERNAL_API_PREFIX}/users/:id/sessions`, createExternalApiHandler({ + GET: async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const parts = url.pathname.split('/').filter(Boolean); + const userId = parts[parts.indexOf('users') + 1]; + const sessions = await getUserSessions(userId); + return { statusCode: 200, body: createResponse(true, sessions) }; + } + })); + register('GET', `${EXTERNAL_API_PREFIX}/models`, createExternalApiHandler(createModelHandlers(getModels, upsertModel, deleteModel, reorderModels), { rateLimit })); + register('POST', `${EXTERNAL_API_PREFIX}/models`, createExternalApiHandler(createModelHandlers(getModels, upsertModel, deleteModel, reorderModels))); + register('GET', `${EXTERNAL_API_PREFIX}/models/:id`, createExternalApiHandler(createModelIdHandlers(getModel, upsertModel, deleteModel))); + register('PATCH', `${EXTERNAL_API_PREFIX}/models/:id`, createExternalApiHandler(createModelIdHandlers(getModel, upsertModel, deleteModel))); + register('DELETE', `${EXTERNAL_API_PREFIX}/models/:id`, createExternalApiHandler(createModelIdHandlers(getModel, upsertModel, deleteModel))); + register('POST', `${EXTERNAL_API_PREFIX}/models/reorder`, createExternalApiHandler(createModelsReorderHandlers(reorderModels))); + register('GET', `${EXTERNAL_API_PREFIX}/affiliates`, createExternalApiHandler(createAffiliateHandlers(getAffiliates, deleteAffiliate), { rateLimit })); + register('GET', `${EXTERNAL_API_PREFIX}/affiliates/:id`, createExternalApiHandler(createAffiliateIdHandlers(getAffiliate, updateAffiliate, deleteAffiliate))); + register('DELETE', `${EXTERNAL_API_PREFIX}/affiliates/:id`, createExternalApiHandler(createAffiliateIdHandlers(getAffiliate, updateAffiliate, deleteAffiliate))); + register('GET', `${EXTERNAL_API_PREFIX}/withdrawals`, createExternalApiHandler(createWithdrawalHandlers(getWithdrawals, updateWithdrawal), { rateLimit })); + register('PUT', `${EXTERNAL_API_PREFIX}/withdrawals`, createExternalApiHandler(createWithdrawalHandlers(getWithdrawals, updateWithdrawal))); + register('GET', `${EXTERNAL_API_PREFIX}/analytics/overview`, createExternalApiHandler(createAnalyticsHandlers(getTrackingStats, getResourceStats, getUsageStats))); + register('GET', `${EXTERNAL_API_PREFIX}/analytics/tracking`, createExternalApiHandler(createAnalyticsHandlers(getTrackingStats, getResourceStats, getUsageStats))); + register('GET', `${EXTERNAL_API_PREFIX}/analytics/resources`, createExternalApiHandler(createAnalyticsHandlers(getTrackingStats, getResourceStats, getUsageStats))); + register('GET', `${EXTERNAL_API_PREFIX}/analytics/usage`, createExternalApiHandler(createAnalyticsHandlers(getTrackingStats, getResourceStats, getUsageStats))); + register('GET', `${EXTERNAL_API_PREFIX}/blogs`, createExternalApiHandler(createBlogHandlers(getBlogs, createBlog, updateBlog, deleteBlog), { rateLimit })); + register('POST', `${EXTERNAL_API_PREFIX}/blogs`, createExternalApiHandler(createBlogHandlers(getBlogs, createBlog, updateBlog, deleteBlog))); + register('GET', `${EXTERNAL_API_PREFIX}/blogs/:id`, createExternalApiHandler(createBlogIdHandlers(getBlog, updateBlog, deleteBlog))); + register('PATCH', `${EXTERNAL_API_PREFIX}/blogs/:id`, createExternalApiHandler(createBlogIdHandlers(getBlog, updateBlog, deleteBlog))); + register('DELETE', `${EXTERNAL_API_PREFIX}/blogs/:id`, createExternalApiHandler(createBlogIdHandlers(getBlog, updateBlog, deleteBlog))); + register('GET', `${EXTERNAL_API_PREFIX}/feature-requests`, createExternalApiHandler(createFeatureRequestHandlers(getFeatureRequests, updateFeatureRequestStatus), { rateLimit })); + register('PATCH', `${EXTERNAL_API_PREFIX}/feature-requests/:id`, createExternalApiHandler(createFeatureRequestIdHandlers(updateFeatureRequestStatus))); + register('GET', `${EXTERNAL_API_PREFIX}/contact-messages`, createExternalApiHandler(createContactMessageHandlers(getContactMessages, deleteContactMessage), { rateLimit })); + register('DELETE', `${EXTERNAL_API_PREFIX}/contact-messages/:id`, createExternalApiHandler(createContactMessageIdHandlers(deleteContactMessage))); + register('GET', `${EXTERNAL_API_PREFIX}/system/health`, createExternalApiHandler(createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog), { requireAuth: false })); + register('GET', `${EXTERNAL_API_PREFIX}/system/tests`, createExternalApiHandler(createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog))); + register('POST', `${EXTERNAL_API_PREFIX}/system/tests`, createExternalApiHandler(createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog))); + register('POST', `${EXTERNAL_API_PREFIX}/system/cache/clear`, createExternalApiHandler(createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog))); + register('GET', `${EXTERNAL_API_PREFIX}/system/audit-log`, createExternalApiHandler(createSystemHandlers(getHealth, runSystemTests, clearCache, getAuditLog))); + + async function handle(req, res) { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + + if (!pathname.startsWith(EXTERNAL_API_PREFIX)) { + return false; + } + + const matched = matchRoute(req.method, pathname); + if (!matched) { + const authResult = authenticateRequest(req); + if (!authResult.authenticated) { + return sendApiResponse(res, authResult.statusCode, createResponse(false, { + code: authResult.error, + message: 'Authentication failed' + })); + } + return sendApiResponse(res, 404, createResponse(false, { + code: 'ENDPOINT_NOT_FOUND', + message: `No endpoint found for ${req.method} ${pathname}` + })); + } + + req.params = matched.params; + await matched.handler(req, res); + return true; + } + + return { handle, routes }; +} + +module.exports = { createRouter, EXTERNAL_API_PREFIX }; diff --git a/docker-compose.yml b/docker-compose.yml index f2861c7..d238bc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,9 @@ services: - ADMIN_USER=${ADMIN_USER:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - ADMIN_SESSION_TTL_MS=${ADMIN_SESSION_TTL_MS:-} + - ADMIN_API_KEY=${ADMIN_API_KEY:-} + - ADMIN_API_JWT_TTL=${ADMIN_API_JWT_TTL:-3600} + - ADMIN_API_RATE_LIMIT=${ADMIN_API_RATE_LIMIT:-1000} - COOKIE_SECURE=${COOKIE_SECURE:-} # Database configuration - USE_JSON_DATABASE=${USE_JSON_DATABASE:-}