fix mcp and admin api
This commit is contained in:
@@ -60,6 +60,13 @@ ADMIN_USER=
|
||||
ADMIN_PASSWORD=
|
||||
SESSION_SECRET=
|
||||
|
||||
# External Admin API Key
|
||||
# Generate a secure key: openssl rand -hex 32
|
||||
# Format: sk_live_<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)
|
||||
# Set USE_JSON_DATABASE=1 to use legacy JSON files (for rollback)
|
||||
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
|
||||
RUN chmod -R 755 /opt/webchat_v2
|
||||
|
||||
# Copy MCP servers for WordPress validation
|
||||
COPY opencode/mcp-servers /opt/opencode/mcp-servers
|
||||
RUN cd /opt/opencode/mcp-servers/wp-cli-testing && npm install --production && \
|
||||
cd /opt/opencode/mcp-servers/wordpress-validator && npm install --production && \
|
||||
chmod -R 755 /opt/opencode/mcp-servers
|
||||
|
||||
# Copy validation script
|
||||
COPY scripts/validate-wordpress-plugin.sh /opt/scripts/validate-wordpress-plugin.sh
|
||||
RUN chmod +x /opt/scripts/validate-wordpress-plugin.sh
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /home/web/data \
|
||||
&& mkdir -p /var/log/shopify-ai \
|
||||
|
||||
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 { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection');
|
||||
const { initEncryption, encrypt, decrypt, isEncryptionInitialized } = require('./src/utils/encryption');
|
||||
const { createExternalAdminApiIntegration } = require('./src/external-admin-api/integration');
|
||||
|
||||
let sharp = null;
|
||||
try {
|
||||
@@ -354,6 +355,9 @@ const AFFILIATE_REF_COOKIE_TTL_SECONDS = Math.floor(AFFILIATE_REF_COOKIE_TTL_MS
|
||||
const ADMIN_USER = process.env.ADMIN_USER || process.env.ADMIN_EMAIL || '';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.ADMIN_PASS || '';
|
||||
const ADMIN_SESSION_TTL_MS = Number(process.env.ADMIN_SESSION_TTL_MS || 86_400_000); // default 24h
|
||||
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || '';
|
||||
const ADMIN_API_JWT_TTL = Number(process.env.ADMIN_API_JWT_TTL || 3600);
|
||||
const ADMIN_API_RATE_LIMIT = Number(process.env.ADMIN_API_RATE_LIMIT || 1000);
|
||||
const ADMIN_MODELS_FILE = path.join(STATE_DIR, 'admin-models.json');
|
||||
const OPENROUTER_SETTINGS_FILE = path.join(STATE_DIR, 'openrouter-settings.json');
|
||||
const MISTRAL_SETTINGS_FILE = path.join(STATE_DIR, 'mistral-settings.json');
|
||||
@@ -1692,6 +1696,9 @@ const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute window
|
||||
// Admin password hashing
|
||||
let adminPasswordHash = null;
|
||||
|
||||
// External Admin API
|
||||
let externalAdminApiRouter = null;
|
||||
|
||||
function log(message, extra) {
|
||||
const payload = extra ? `${message} ${JSON.stringify(extra)}` : message;
|
||||
console.log(`[${new Date().toISOString()}] ${payload}`);
|
||||
@@ -19866,6 +19873,13 @@ async function route(req, res) {
|
||||
|
||||
async function routeInternal(req, res, url, pathname) {
|
||||
if (req.method === 'GET' && pathname === '/api/health') return sendJson(res, 200, { ok: true });
|
||||
|
||||
// Handle External Admin API routes
|
||||
if (pathname.startsWith('/api/external/') && externalAdminApiRouter) {
|
||||
const handled = await externalAdminApiRouter.handle(req, res);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/opencode/status') return handleOpencodeStatus(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/memory/stats') return handleMemoryStats(req, res);
|
||||
if (req.method === 'POST' && pathname === '/api/memory/cleanup') return handleForceMemoryCleanup(req, res);
|
||||
@@ -20493,6 +20507,41 @@ async function bootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize External Admin API
|
||||
if (ADMIN_API_KEY) {
|
||||
try {
|
||||
const integration = createExternalAdminApiIntegration({
|
||||
userRepository: null,
|
||||
sessionRepository: null,
|
||||
auditRepository: null,
|
||||
adminModels,
|
||||
publicModels,
|
||||
affiliateAccounts,
|
||||
withdrawals: withdrawalsDb,
|
||||
featureRequests: featureRequestsDb,
|
||||
contactMessages: contactMessagesDb,
|
||||
blogs: blogsDb,
|
||||
trackingStats: trackingData,
|
||||
resourceMonitor: null,
|
||||
tokenUsage: null,
|
||||
log,
|
||||
getConfiguredModels,
|
||||
persistAdminModels,
|
||||
getPlanTokens,
|
||||
getTokenRates,
|
||||
getProviderLimits,
|
||||
getDatabase,
|
||||
databaseEnabled
|
||||
});
|
||||
externalAdminApiRouter = integration.router;
|
||||
log('External Admin API initialized', { configured: integration.isConfigured });
|
||||
} catch (error) {
|
||||
log('Failed to initialize External Admin API', { error: String(error) });
|
||||
}
|
||||
} else {
|
||||
log('External Admin API disabled (ADMIN_API_KEY not set)');
|
||||
}
|
||||
|
||||
log('Resource limits detected', {
|
||||
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
||||
memoryMb: Math.round((RESOURCE_LIMITS.memoryBytes || 0) / (1024 * 1024)),
|
||||
|
||||
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_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
- ADMIN_SESSION_TTL_MS=${ADMIN_SESSION_TTL_MS:-}
|
||||
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||
- ADMIN_API_JWT_TTL=${ADMIN_API_JWT_TTL:-3600}
|
||||
- ADMIN_API_RATE_LIMIT=${ADMIN_API_RATE_LIMIT:-1000}
|
||||
- COOKIE_SECURE=${COOKIE_SECURE:-}
|
||||
# Database configuration
|
||||
- USE_JSON_DATABASE=${USE_JSON_DATABASE:-}
|
||||
|
||||
Reference in New Issue
Block a user