fix mcp and admin api
This commit is contained in:
@@ -60,6 +60,13 @@ ADMIN_USER=
|
|||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# External Admin API Key
|
||||||
|
# Generate a secure key: openssl rand -hex 32
|
||||||
|
# Format: sk_live_<your-secure-key> for production, sk_test_<your-secure-key> for testing
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
ADMIN_API_JWT_TTL=3600
|
||||||
|
ADMIN_API_RATE_LIMIT=1000
|
||||||
|
|
||||||
# Database Configuration (Phase 1.2 & 1.3)
|
# Database Configuration (Phase 1.2 & 1.3)
|
||||||
# Set USE_JSON_DATABASE=1 to use legacy JSON files (for rollback)
|
# Set USE_JSON_DATABASE=1 to use legacy JSON files (for rollback)
|
||||||
USE_JSON_DATABASE=
|
USE_JSON_DATABASE=
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -126,6 +126,16 @@ RUN cd /opt/webchat && npm install --production && chmod -R 755 /opt/webchat
|
|||||||
COPY chat_v2 /opt/webchat_v2
|
COPY chat_v2 /opt/webchat_v2
|
||||||
RUN chmod -R 755 /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
|
# Create data directories
|
||||||
RUN mkdir -p /home/web/data \
|
RUN mkdir -p /home/web/data \
|
||||||
&& mkdir -p /var/log/shopify-ai \
|
&& mkdir -p /var/log/shopify-ai \
|
||||||
|
|||||||
677
EXTERNAL_ADMIN_API.md
Normal file
677
EXTERNAL_ADMIN_API.md
Normal file
@@ -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 <your_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -21,6 +21,7 @@ const versionManager = require('./src/utils/versionManager');
|
|||||||
const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config');
|
const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config');
|
||||||
const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection');
|
const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection');
|
||||||
const { initEncryption, encrypt, decrypt, isEncryptionInitialized } = require('./src/utils/encryption');
|
const { initEncryption, encrypt, decrypt, isEncryptionInitialized } = require('./src/utils/encryption');
|
||||||
|
const { createExternalAdminApiIntegration } = require('./src/external-admin-api/integration');
|
||||||
|
|
||||||
let sharp = null;
|
let sharp = null;
|
||||||
try {
|
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_USER = process.env.ADMIN_USER || process.env.ADMIN_EMAIL || '';
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.ADMIN_PASS || '';
|
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_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 ADMIN_MODELS_FILE = path.join(STATE_DIR, 'admin-models.json');
|
||||||
const OPENROUTER_SETTINGS_FILE = path.join(STATE_DIR, 'openrouter-settings.json');
|
const OPENROUTER_SETTINGS_FILE = path.join(STATE_DIR, 'openrouter-settings.json');
|
||||||
const MISTRAL_SETTINGS_FILE = path.join(STATE_DIR, 'mistral-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
|
// Admin password hashing
|
||||||
let adminPasswordHash = null;
|
let adminPasswordHash = null;
|
||||||
|
|
||||||
|
// External Admin API
|
||||||
|
let externalAdminApiRouter = null;
|
||||||
|
|
||||||
function log(message, extra) {
|
function log(message, extra) {
|
||||||
const payload = extra ? `${message} ${JSON.stringify(extra)}` : message;
|
const payload = extra ? `${message} ${JSON.stringify(extra)}` : message;
|
||||||
console.log(`[${new Date().toISOString()}] ${payload}`);
|
console.log(`[${new Date().toISOString()}] ${payload}`);
|
||||||
@@ -19866,6 +19873,13 @@ async function route(req, res) {
|
|||||||
|
|
||||||
async function routeInternal(req, res, url, pathname) {
|
async function routeInternal(req, res, url, pathname) {
|
||||||
if (req.method === 'GET' && pathname === '/api/health') return sendJson(res, 200, { ok: true });
|
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/opencode/status') return handleOpencodeStatus(req, res);
|
||||||
if (req.method === 'GET' && pathname === '/api/memory/stats') return handleMemoryStats(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);
|
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', {
|
log('Resource limits detected', {
|
||||||
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
||||||
memoryMb: Math.round((RESOURCE_LIMITS.memoryBytes || 0) / (1024 * 1024)),
|
memoryMb: Math.round((RESOURCE_LIMITS.memoryBytes || 0) / (1024 * 1024)),
|
||||||
|
|||||||
863
chat/src/external-admin-api/handlers.js
Normal file
863
chat/src/external-admin-api/handlers.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
248
chat/src/external-admin-api/index.js
Normal file
248
chat/src/external-admin-api/index.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
589
chat/src/external-admin-api/integration.js
Normal file
589
chat/src/external-admin-api/integration.js
Normal file
@@ -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 };
|
||||||
172
chat/src/external-admin-api/router.js
Normal file
172
chat/src/external-admin-api/router.js
Normal file
@@ -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 };
|
||||||
@@ -90,6 +90,9 @@ services:
|
|||||||
- ADMIN_USER=${ADMIN_USER:-}
|
- ADMIN_USER=${ADMIN_USER:-}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||||
- ADMIN_SESSION_TTL_MS=${ADMIN_SESSION_TTL_MS:-}
|
- 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:-}
|
- COOKIE_SECURE=${COOKIE_SECURE:-}
|
||||||
# Database configuration
|
# Database configuration
|
||||||
- USE_JSON_DATABASE=${USE_JSON_DATABASE:-}
|
- USE_JSON_DATABASE=${USE_JSON_DATABASE:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user