Merge remote-tracking branch 'gitea/main'
This commit is contained in:
14
.env.example
14
.env.example
@@ -41,6 +41,9 @@ KILO_API_KEY=
|
||||
# Bytez
|
||||
BYTEZ_API_KEY=
|
||||
|
||||
# DeepInfra
|
||||
DEEPINFRA_API_KEY=
|
||||
|
||||
# GitHub
|
||||
GITHUB_PAT=
|
||||
GITHUB_USERNAME=
|
||||
@@ -60,13 +63,24 @@ 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=
|
||||
DATABASE_PATH=./.data/shopify_ai.db
|
||||
DATABASE_ENCRYPTION_KEY=
|
||||
DATABASE_KEY_FILE=
|
||||
DATABASE_BACKUP_ENABLED=1
|
||||
DATABASE_WAL_MODE=1
|
||||
DATABASE_USE_SQLCIPHER=1
|
||||
DATABASE_CIPHER_COMPAT=4
|
||||
DATABASE_KDF_ITER=64000
|
||||
|
||||
# JWT Token Configuration
|
||||
JWT_SECRET=
|
||||
|
||||
@@ -35,6 +35,8 @@ Use this checklist when deploying the secure database implementation to producti
|
||||
```bash
|
||||
# 1. Set environment variables
|
||||
export DATABASE_ENCRYPTION_KEY=<your-64-char-hex-key>
|
||||
export DATABASE_USE_SQLCIPHER=1
|
||||
export DATABASE_KEY_FILE=/home/web/data/.data/.encryption_key
|
||||
export JWT_SECRET=<your-64-char-hex-key>
|
||||
|
||||
# 2. Deploy container
|
||||
@@ -71,9 +73,9 @@ docker logs shopify-ai-builder | grep "JSON"
|
||||
# 4. Run migration inside container
|
||||
docker exec shopify-ai-builder node /opt/webchat/scripts/migrate-to-database.js
|
||||
|
||||
# 5. Verify migration results
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "SELECT COUNT(*) FROM users;"
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "SELECT COUNT(*) FROM sessions;"
|
||||
# 5. Verify migration results (SQLCipher)
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT COUNT(*) FROM users;"
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT COUNT(*) FROM sessions;"
|
||||
|
||||
# 6. Switch to database mode
|
||||
export USE_JSON_DATABASE=0
|
||||
@@ -103,13 +105,13 @@ docker logs shopify-ai-builder | grep -v "JSON"
|
||||
|
||||
### 2. Smoke Tests
|
||||
```bash
|
||||
# Test database access
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db ".tables"
|
||||
# Test database access (SQLCipher)
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
|
||||
|
||||
# Count records
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "SELECT COUNT(*) FROM users;"
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "SELECT COUNT(*) FROM sessions;"
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "SELECT COUNT(*) FROM audit_log;"
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT COUNT(*) FROM users;"
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT COUNT(*) FROM sessions;"
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT COUNT(*) FROM audit_log;"
|
||||
|
||||
# Check encryption keys
|
||||
docker exec shopify-ai-builder test -f /home/web/data/.data/.encryption_key && echo "Encryption key exists"
|
||||
@@ -138,9 +140,9 @@ docker exec shopify-ai-builder ls -l /home/web/data/.data/
|
||||
# Verify database is encrypted (should see binary data)
|
||||
docker exec shopify-ai-builder head -c 100 /home/web/data/.data/shopify_ai.db | od -c
|
||||
|
||||
# Check audit log
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db \
|
||||
"SELECT event_type, COUNT(*) FROM audit_log GROUP BY event_type;"
|
||||
# Check audit log (SQLCipher)
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db \
|
||||
"PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; SELECT event_type, COUNT(*) FROM audit_log GROUP BY event_type;"
|
||||
```
|
||||
|
||||
**Checklist:**
|
||||
@@ -263,15 +265,15 @@ docker exec shopify-ai-builder cp -r /home/web/data/.data/migration_backup_*/* /
|
||||
|
||||
### Performance Issues
|
||||
```bash
|
||||
# Check WAL mode
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "PRAGMA journal_mode;"
|
||||
# Check WAL mode (SQLCipher)
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; PRAGMA journal_mode;"
|
||||
|
||||
# Check database size
|
||||
docker exec shopify-ai-builder du -h /home/web/data/.data/shopify_ai.db
|
||||
|
||||
# Vacuum database if needed (offline)
|
||||
docker stop shopify-ai-builder
|
||||
docker exec shopify-ai-builder sqlite3 /home/web/data/.data/shopify_ai.db "VACUUM;"
|
||||
docker exec shopify-ai-builder sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; VACUUM;"
|
||||
docker start shopify-ai-builder
|
||||
```
|
||||
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -73,6 +73,11 @@ RUN apt-get update \
|
||||
tini \
|
||||
libicu-dev \
|
||||
libssl-dev \
|
||||
build-essential \
|
||||
python3 \
|
||||
pkg-config \
|
||||
sqlcipher \
|
||||
libsqlcipher-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js
|
||||
@@ -114,11 +119,23 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh /usr/loc
|
||||
|
||||
# Copy webchat
|
||||
COPY chat /opt/webchat
|
||||
ENV npm_config_sqlite=sqlcipher \
|
||||
npm_config_build_from_source=true
|
||||
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 WordPress validator MCP server only (wp-cli-testing disabled for now)
|
||||
RUN mkdir -p /opt/opencode/mcp-servers
|
||||
COPY opencode/mcp-servers/wordpress-validator /opt/opencode/mcp-servers/wordpress-validator
|
||||
RUN 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
|
||||
@@ -5,7 +5,7 @@ This implementation adds database encryption at rest and secure session manageme
|
||||
## Features
|
||||
|
||||
### Phase 1.2: Database with Encryption at Rest
|
||||
- ✅ SQLite database with better-sqlite3
|
||||
- ✅ SQLCipher-encrypted SQLite database with better-sqlite3
|
||||
- ✅ Field-level AES-256-GCM encryption for sensitive data
|
||||
- ✅ PBKDF2 key derivation (100,000 iterations)
|
||||
- ✅ WAL mode for better concurrency
|
||||
@@ -24,16 +24,17 @@ This implementation adds database encryption at rest and secure session manageme
|
||||
## Architecture
|
||||
|
||||
### Database Schema
|
||||
- **users**: User accounts with encrypted email, name, 2FA secrets
|
||||
- **users**: User accounts with encrypted email, name, 2FA secrets + JSON data column
|
||||
- **sessions**: Active sessions for revocation
|
||||
- **refresh_tokens**: Refresh tokens with device fingerprinting
|
||||
- **token_blacklist**: Immediate token revocation
|
||||
- **affiliates**, **withdrawals**, **feature_requests**, **contact_messages**
|
||||
- **affiliate_accounts** (current app), **affiliates** (legacy), **withdrawals**, **feature_requests**, **contact_messages**
|
||||
- **audit_log**: Comprehensive security event logging
|
||||
- **payment_sessions**: DoDo payment tracking
|
||||
|
||||
### Encryption
|
||||
- **Algorithm**: AES-256-GCM with authenticated encryption
|
||||
- **Database**: SQLCipher encryption at rest
|
||||
- **Algorithm**: AES-256-GCM with authenticated encryption (field-level)
|
||||
- **Key Derivation**: PBKDF2 with 100,000 iterations
|
||||
- **Per-field**: Sensitive fields encrypted individually
|
||||
- **Token Storage**: PBKDF2 hashed (not encrypted) for secure comparison
|
||||
@@ -65,8 +66,12 @@ Optional:
|
||||
```bash
|
||||
USE_JSON_DATABASE=1 # Use JSON files instead of database (for rollback)
|
||||
DATABASE_PATH=./.data/shopify_ai.db
|
||||
DATABASE_KEY_FILE=./.data/.encryption_key
|
||||
DATABASE_BACKUP_ENABLED=1
|
||||
DATABASE_WAL_MODE=1
|
||||
DATABASE_USE_SQLCIPHER=1
|
||||
DATABASE_CIPHER_COMPAT=4
|
||||
DATABASE_KDF_ITER=64000
|
||||
JWT_ACCESS_TOKEN_TTL=900 # 15 minutes in seconds
|
||||
JWT_REFRESH_TOKEN_TTL=604800 # 7 days in seconds
|
||||
```
|
||||
@@ -135,14 +140,14 @@ export USE_JSON_DATABASE=1
|
||||
|
||||
### Verify Database Setup
|
||||
```bash
|
||||
# Check database exists and tables are created
|
||||
sqlite3 ./.data/shopify_ai.db ".tables"
|
||||
# Check database exists and tables are created (SQLCipher)
|
||||
sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
|
||||
|
||||
# Should output:
|
||||
# affiliates payment_sessions token_blacklist
|
||||
# audit_log refresh_tokens users
|
||||
# contact_messages sessions withdrawals
|
||||
# feature_requests
|
||||
# affiliate_accounts payment_sessions token_blacklist
|
||||
# affiliates refresh_tokens users
|
||||
# audit_log sessions withdrawals
|
||||
# contact_messages feature_requests
|
||||
```
|
||||
|
||||
### Test Encryption
|
||||
@@ -161,8 +166,8 @@ node scripts/migrate-to-database.js
|
||||
|
||||
### Database Health
|
||||
- Check file size: `ls -lh ./.data/shopify_ai.db`
|
||||
- Check WAL mode: `sqlite3 ./.data/shopify_ai.db "PRAGMA journal_mode;"`
|
||||
- Check tables: `sqlite3 ./.data/shopify_ai.db ".tables"`
|
||||
- Check WAL mode: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; PRAGMA journal_mode;"`
|
||||
- Check tables: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"`
|
||||
|
||||
### Audit Logs
|
||||
Audit logs are stored in the `audit_log` table and include:
|
||||
|
||||
1275
chat/OPENCODE_SERVER_INTEGRATION.md
Normal file
1275
chat/OPENCODE_SERVER_INTEGRATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -235,15 +235,15 @@ docker exec shopify-ai-test node /opt/webchat/scripts/migrate-to-database.js
|
||||
### 7. Verify Tables
|
||||
|
||||
```bash
|
||||
docker exec shopify-ai-test sqlite3 /home/web/data/.data/shopify_ai.db ".tables"
|
||||
docker exec shopify-ai-test sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
affiliates payment_sessions token_blacklist
|
||||
audit_log refresh_tokens users
|
||||
contact_messages sessions withdrawals
|
||||
feature_requests
|
||||
affiliate_accounts payment_sessions token_blacklist
|
||||
affiliates refresh_tokens users
|
||||
audit_log sessions withdrawals
|
||||
contact_messages feature_requests
|
||||
```
|
||||
|
||||
### 8. Check Encryption Keys Persisted
|
||||
|
||||
@@ -9,7 +9,18 @@ const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Blog configuration
|
||||
const BLOGS_REPO_DIR = path.join(process.cwd(), 'blogs');
|
||||
// Support Docker/Portainer deployment where:
|
||||
// - BLOGS_DIR can be explicitly set
|
||||
// - CHAT_APP_DIR points to /opt/webchat in Docker
|
||||
// - CHAT_REPO_ROOT points to workspace directory
|
||||
// - Fall back to cwd/blogs for local development
|
||||
const BLOGS_REPO_DIR = process.env.BLOGS_DIR
|
||||
? process.env.BLOGS_DIR
|
||||
: process.env.CHAT_APP_DIR
|
||||
? path.join(process.env.CHAT_APP_DIR, 'blogs')
|
||||
: process.env.CHAT_REPO_ROOT
|
||||
? path.join(process.env.CHAT_REPO_ROOT, 'blogs')
|
||||
: path.join(process.cwd(), 'blogs');
|
||||
const BLOGS_DB_FILE = path.join(process.cwd(), '.data', '.opencode-chat', 'blogs.db.json');
|
||||
const BLOGS_UPLOAD_DIR = path.join(process.cwd(), 'public', 'blogs', 'images');
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"slug": "choosing-wordpress-plugins-expert-guide",
|
||||
"type": "seo",
|
||||
"title": "Choosing the Right WordPress Plugins: An Expert Guide to Building Your Perfect Stack",
|
||||
"excerpt": "Master the art of selecting WordPress plugins. Learn evaluation criteria, avoid common pitfalls, and discover how Plugin Compass AI can build custom plugins tailored to your exact needs.",
|
||||
"excerpt": "Master the art of selecting WordPress plugins. Learn evaluation criteria, avoid common pitfalls, and discover how to build the perfect plugin stack for your website.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
@@ -202,10 +202,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Wordfence: Comprehensive with firewall and scanning",
|
||||
"Sucuri: Great for sites needing WAF protection",
|
||||
"iThemes Security: Feature-rich with strong login protection",
|
||||
"Solid Security: Formerly iThemes, enterprise-focused"
|
||||
"Firewall protection to block malicious traffic",
|
||||
"Real-time malware scanning",
|
||||
"Login security with two-factor authentication",
|
||||
"File integrity monitoring",
|
||||
"Security activity logging"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -227,10 +228,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WP Rocket: Premium, easiest to configure, best results",
|
||||
"WP Super Cache: Free, simple, maintained by Automattic",
|
||||
"W3 Total Cache: Advanced users, many configuration options",
|
||||
"LiteSpeed Cache: Best if your host uses LiteSpeed"
|
||||
"Page caching for static content delivery",
|
||||
"Browser caching to reduce repeat visits",
|
||||
"Database caching for faster queries",
|
||||
"GZIP compression to reduce file sizes",
|
||||
"CDN integration for global delivery"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -246,10 +248,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Yoast SEO: Most popular, comprehensive features",
|
||||
"Rank Math: Growing rapidly, more features in free version",
|
||||
"SEOPress: Lightweight alternative with strong features",
|
||||
"The SEO Framework: Minimalist, performance-focused"
|
||||
"XML sitemaps for search engine discovery",
|
||||
"Meta tag management for title and descriptions",
|
||||
"Schema markup for rich search results",
|
||||
"Canonical URL handling to prevent duplicate content",
|
||||
"Open Graph and Twitter Card support"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -265,10 +268,12 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Gravity Forms: Premium, most powerful, developer-friendly",
|
||||
"WPForms: User-friendly, great for beginners",
|
||||
"Fluent Forms: Excellent free version, fast performance",
|
||||
"Formidable Forms: Advanced calculations and data views"
|
||||
"Drag-and-drop form builder",
|
||||
"Conditional logic for dynamic forms",
|
||||
"File upload capability",
|
||||
"Email notifications and auto-responders",
|
||||
"Database storage and entry management",
|
||||
"Spam protection integration"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -284,10 +289,12 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WooCommerce: The standard, most extensions available",
|
||||
"Easy Digital Downloads: Perfect for digital products",
|
||||
"WP EasyCart: Lightweight alternative for small stores",
|
||||
"BigCommerce for WordPress: Headless commerce solution"
|
||||
"Multiple payment gateway options",
|
||||
"Inventory management system",
|
||||
"Shipping label integration",
|
||||
"Tax calculation capability",
|
||||
"Customer account management",
|
||||
"Order tracking and notifications"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -303,10 +310,12 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Elementor: Most popular, extensive template library",
|
||||
"Beaver Builder: Developer-friendly, clean code output",
|
||||
"Divi: Theme and builder combination, visual editing",
|
||||
"Bricks: Rising star, excellent performance"
|
||||
"Visual drag-and-drop interface",
|
||||
"Pre-built template library",
|
||||
"Responsive design controls",
|
||||
"Theme builder capabilities",
|
||||
"Custom CSS support",
|
||||
"Performance-optimized code output"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -529,7 +538,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Plugin Compass represents this future—AI-generated custom plugins that perfectly match modern WordPress architecture while maintaining the platform's famous flexibility."
|
||||
"text": "This evolution opens doors for businesses with unique requirements to explore custom solutions."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -548,7 +557,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "When existing plugins don't meet your exact needs, remember that custom solutions are more accessible than ever. Plugin Compass empowers you to build tailored plugins that perfectly fit your business requirements, without the cost and complexity of traditional development. Ready to build your perfect WordPress solution? Start with Plugin Compass today."
|
||||
"text": "When existing plugins don't meet your exact needs, custom plugin development offers a powerful alternative. Tools like Plugin Compass can help you build tailored plugins that perfectly fit your specific requirements."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -87,11 +87,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"GeneratePress: Under 10KB page size, modular approach to features",
|
||||
"Astra: Optimized for speed with extensive customization options",
|
||||
"Kadence: Lightweight with advanced header/footer builder",
|
||||
"Neve: Mobile-first design with AMP compatibility",
|
||||
"Blocksy: Built for Gutenberg with minimal bloat"
|
||||
"Minimal page size (under 50KB is ideal)",
|
||||
"Modular feature approach - only load what you need",
|
||||
"Regular updates and active development",
|
||||
"Responsive design built-in",
|
||||
"Clean code with no unnecessary bloat"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -126,10 +126,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WP Rocket (Premium): Best-in-class with easy configuration",
|
||||
"WP Super Cache (Free): Simple and effective, maintained by Automattic",
|
||||
"W3 Total Cache (Free): Feature-rich with advanced options",
|
||||
"LiteSpeed Cache (Free): Excellent if your host uses LiteSpeed servers"
|
||||
"Page caching to serve static versions of your pages",
|
||||
"Browser caching headers for returning visitors",
|
||||
"Object caching for database queries",
|
||||
"GZIP compression to reduce file sizes",
|
||||
"Minification to reduce CSS and JavaScript file sizes"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -166,7 +167,7 @@
|
||||
"items": [
|
||||
"Use the right format: JPEG for photographs, PNG for graphics with transparency, WebP when possible",
|
||||
"Resize before uploading: Don't upload 4000px images for 800px display areas",
|
||||
"Compress images: Use tools like TinyPNG, ShortPixel, or Imagify",
|
||||
"Compress images: Use tools like TinyPNG, ShortPixel, or Imagify before uploading, or use plugins that handle this automatically",
|
||||
"Enable lazy loading: Images load only when users scroll to them",
|
||||
"Use responsive images: Serve different sizes for different devices"
|
||||
]
|
||||
@@ -175,7 +176,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Recommended image optimization plugins:"
|
||||
"text": "Look for image optimization plugins that offer:"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -183,10 +184,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"ShortPixel: Excellent compression with WebP conversion",
|
||||
"Imagify: Made by WP Rocket team, great integration",
|
||||
"Smush: Popular free option with lazy loading",
|
||||
"Optimole: Cloud-based optimization with CDN delivery"
|
||||
"Automatic compression on upload",
|
||||
"WebP conversion for modern browsers",
|
||||
"Lazy loading integration",
|
||||
"Bulk optimization for existing images",
|
||||
"CDN integration for faster delivery"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -225,7 +227,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "This is where Plugin Compass offers a unique advantage. Instead of installing multiple plugins to achieve your goals, you can use our AI to build a single, optimized custom plugin that does exactly what you need—nothing more, nothing less."
|
||||
"text": "Sometimes, combining multiple plugins can cause conflicts or performance issues. For unique requirements, custom plugin development can provide a more efficient solution."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -252,10 +254,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Cloudflare (Free tier available): Includes security features and optimization",
|
||||
"Bunny CDN: Affordable with excellent performance",
|
||||
"KeyCDN: Pay-as-you-go pricing",
|
||||
"StackPath: Formerly MaxCDN, reliable enterprise option"
|
||||
"Global server network for faster delivery worldwide",
|
||||
"Static file caching (images, CSS, JavaScript)",
|
||||
"DDoS protection and security features",
|
||||
"Easy WordPress integration",
|
||||
"Free tier availability for testing"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -283,9 +286,11 @@
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WP-Optimize: Cleans database, compresses images, and caches",
|
||||
"Advanced Database Cleaner: Detailed control over what to clean",
|
||||
"WP Sweep: Simple, safe database cleaning"
|
||||
"Post revision cleanup",
|
||||
"Spam comment removal",
|
||||
"Transient option cleanup",
|
||||
"Database table optimization",
|
||||
"Scheduled automatic cleaning"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -330,20 +335,20 @@
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Building Custom Performance Solutions with Plugin Compass",
|
||||
"text": "Custom Solutions for Unique Performance Needs",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "While the techniques above will significantly improve your site's performance, sometimes you need custom solutions for specific performance challenges. Plugin Compass empowers you to build tailored performance plugins without writing code."
|
||||
"text": "While the techniques above will significantly improve your site's performance, sometimes you need custom solutions for specific performance challenges. Custom plugin development can address unique requirements that generic plugins cannot."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Here are custom performance solutions you can build with Plugin Compass:"
|
||||
"text": "Here are custom performance solutions that can help:"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -363,7 +368,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "For example, if you run a WooCommerce store with thousands of products, you might need custom database indexing and query optimization that generic plugins can't provide. With Plugin Compass, simply describe your needs in plain English, and our AI will generate an optimized plugin specifically for your store's requirements."
|
||||
"text": "For example, if you run a WooCommerce store with thousands of products, you might need custom database indexing and query optimization that generic plugins can't provide. Custom plugin development can address these specific needs."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -435,7 +440,7 @@
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "For unique performance challenges or specific business requirements, Plugin Compass offers the perfect solution. Build custom performance plugins tailored exactly to your needs, saving money on premium plugins while achieving better results. Ready to take your WordPress performance to the next level? Start building with Plugin Compass today."
|
||||
"text": "For unique performance challenges or specific business requirements, custom plugin development offers a powerful alternative to generic solutions."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
476
chat/blogs/seo/wordpress-backup-strategies-guide.json
Normal file
476
chat/blogs/seo/wordpress-backup-strategies-guide.json
Normal file
@@ -0,0 +1,476 @@
|
||||
{
|
||||
"id": "wordpress-backup-strategies-guide",
|
||||
"slug": "wordpress-backup-strategies-guide",
|
||||
"type": "seo",
|
||||
"title": "WordPress Backup Strategies: A Practical Guide to Protecting Your Website Data",
|
||||
"excerpt": "Learn essential backup strategies for WordPress. Understand what to backup, how often, storage options, and testing procedures to ensure you can recover from any disaster.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Imagine waking up one morning to find your website hacked, corrupted by a failed update, or accidentally deleted by a team member. Without a proper backup strategy, years of content, customer data, and business operations could vanish instantly. This guide covers essential backup strategies—from understanding what to back up to implementing a reliable recovery plan."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Why Backups Are Non-Negotiable",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Website disasters happen more often than you might think. Consider these statistics:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"60% of companies that lose their data shut down within 6 months",
|
||||
"29% of data loss is caused by human error",
|
||||
"30,000 websites are hacked daily",
|
||||
"94% of companies with a disaster recovery plan survive major data loss"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A robust backup strategy is your insurance policy against these threats. It's not a question of if you'll need a backup—it's when."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Understanding What Needs to Be Backed Up",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A complete WordPress backup consists of two components that must both be preserved:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Files",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your WordPress files include:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WordPress core files",
|
||||
"Theme files (including customizations)",
|
||||
"Plugin files",
|
||||
"Uploaded media (images, videos, documents)",
|
||||
"Configuration files (wp-config.php)",
|
||||
".htaccess file and other server configs"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Database",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your MySQL database contains all your dynamic content:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"All posts, pages, and custom post types",
|
||||
"User information and roles",
|
||||
"Comments and discussions",
|
||||
"Plugin and theme settings",
|
||||
"WooCommerce orders and products",
|
||||
"Custom field data",
|
||||
"SEO metadata",
|
||||
"Widget and menu configurations"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "The 3-2-1 Backup Rule",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "The gold standard for data protection follows the 3-2-1 rule:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"3 copies of your data (1 primary, 2 backups)",
|
||||
"2 different storage media or services",
|
||||
"1 offsite backup (physically separate location)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "quote",
|
||||
"data": {
|
||||
"text": "Following the 3-2-1 rule ensures that even if your primary server fails and your local backup is corrupted, you still have a third copy safely stored elsewhere.",
|
||||
"caption": "Data Protection Best Practices"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Backup Frequency: How Often Should You Backup?",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your backup frequency depends on how often your site changes and how much data you can afford to lose:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"data": {
|
||||
"content": [
|
||||
["Site Type", "Recommended Frequency", "Retention"],
|
||||
["Static brochure site", "Weekly", "4 weeks"],
|
||||
["Active blog (1-2 posts/week)", "Daily", "30 days"],
|
||||
["E-commerce store", "Real-time or hourly", "90 days"],
|
||||
["Membership site", "Daily minimum", "30 days"],
|
||||
["News site with frequent updates", "Multiple times daily", "14 days"],
|
||||
["Multi-site network", "Daily with real-time for active subsites", "30 days"]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "The key question is: How much data can you afford to lose? If you publish 10 articles per day, daily backups mean potentially losing a full day's work. Hourly backups limit that loss to at most one hour."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Types of Backups",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Full Backups",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A complete backup includes all files and the database. This is the most comprehensive but takes more storage space and time to create and restore."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Incremental Backups",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "After an initial full backup, incremental backups only save changes since the last backup. This saves storage space and time but requires all incremental backups plus the original full backup to restore."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Differential Backups",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Differential backups save changes since the last full backup. They're a middle ground between full and incremental—faster than full but require less reconstruction than incremental."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Database-Only vs File-Only Backups",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Some situations call for partial backups:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Database-only: Quick backups for content-heavy sites",
|
||||
"File-only: When files haven't changed but database has",
|
||||
"Selective: Specific tables or directories when needed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Backup Storage Options",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Never store backups only on your web server. If the server fails, you lose both your site and backups. Consider these options:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Cloud Storage Services",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "General-purpose cloud storage offers flexibility and often includes free tiers:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Google Drive: 15GB free, integrates with many backup plugins",
|
||||
"Dropbox: 2GB free, excellent sync client",
|
||||
"Amazon S3: Pay-as-you-go, highly reliable, industry standard",
|
||||
"Microsoft OneDrive: 5GB free, good Office integration",
|
||||
"Wasabi: Affordable S3-compatible storage"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Specialized Backup Storage",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Some services are designed specifically for backups:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Backblaze B2: Very affordable cloud storage",
|
||||
"pCloud: European-based with client-side encryption",
|
||||
"iDrive: Multiple device backup plus WordPress support"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "What to Look for in Backup Solutions",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "When evaluating backup tools, prioritize these features:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Automated scheduling (set it and forget it)",
|
||||
"Multiple storage destination options",
|
||||
"Easy one-click restoration",
|
||||
"Incremental backup support",
|
||||
"Compression and encryption options",
|
||||
"Email notifications for backup status",
|
||||
"Staging site creation for safe testing"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Testing Your Backups",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A backup you can't restore is worthless. Regular testing is crucial:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Quarterly restoration tests: Restore a backup to a staging site",
|
||||
"Verify file integrity: Check that all files are present",
|
||||
"Test database restoration: Ensure data is complete",
|
||||
"Document the process: Keep step-by-step restoration instructions",
|
||||
"Test different backup ages: Verify older backups still work"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Creating a Disaster Recovery Plan",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Beyond having backups, you need a plan for when disaster strikes:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Identify who is responsible for recovery",
|
||||
"Document the restoration process step by step",
|
||||
"Determine acceptable downtime for your business",
|
||||
"Establish communication procedures",
|
||||
"Know your hosting provider's capabilities and limits",
|
||||
"Keep emergency contacts handy"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "What to Do When Disaster Strikes",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "When problems occur, follow this process:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Stay calm: Panic leads to mistakes",
|
||||
"Assess the damage: Determine what happened and when",
|
||||
"Choose the right backup: Select a clean backup from before the incident",
|
||||
"Restore to staging first: Test the restoration before affecting your live site",
|
||||
"Verify functionality: Check that everything works properly",
|
||||
"Document everything: Record what happened and how you fixed it",
|
||||
"Implement preventive measures: Add monitoring or security to prevent recurrence"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Best Practices Summary",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Automate everything: Manual backups are forgotten backups",
|
||||
"Use multiple storage locations: Never rely on a single backup destination",
|
||||
"Encrypt sensitive backups: Protect customer data with encryption",
|
||||
"Monitor backup success: Set up alerts for failed backups",
|
||||
"Keep backup history: Maintain 30-90 days of backups",
|
||||
"Test regularly: A backup you can't restore is useless",
|
||||
"Document everything: Keep restoration procedures updated",
|
||||
"Secure your backups: Use strong passwords and two-factor authentication",
|
||||
"Consider compliance: Ensure backups meet GDPR, HIPAA, or other requirements",
|
||||
"Have a disaster recovery plan: Know exactly what to do when disaster strikes"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Conclusion",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A comprehensive backup strategy is essential for any WordPress site. By following the 3-2-1 rule, choosing the right backup frequency, using reliable storage locations, and regularly testing your backups, you can ensure your website is protected against any disaster."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Remember: The best backup strategy is one you'll actually use. Set up automation, test regularly, and have a clear recovery plan. Your future self will thank you."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"author": "Plugin Compass Team",
|
||||
"status": "published",
|
||||
"featured_image": "",
|
||||
"meta_title": "WordPress Backup Strategies: Practical Guide | Plugin Compass",
|
||||
"meta_description": "Learn essential WordPress backup strategies. Understand what to backup, storage options, testing procedures, and disaster recovery planning.",
|
||||
"category": "wordpress",
|
||||
"tags": ["wordpress", "backup", "security", "disaster recovery", "data protection"],
|
||||
"published_at": "2026-01-10T10:00:00Z",
|
||||
"updated_at": "2026-02-20T10:00:00Z"
|
||||
}
|
||||
429
chat/blogs/seo/wordpress-maintenance-checklist-2026.json
Normal file
429
chat/blogs/seo/wordpress-maintenance-checklist-2026.json
Normal file
@@ -0,0 +1,429 @@
|
||||
{
|
||||
"id": "wordpress-maintenance-checklist-2026",
|
||||
"slug": "wordpress-maintenance-checklist-2026",
|
||||
"type": "seo",
|
||||
"title": "WordPress Maintenance Checklist for 2026: Keep Your Site Running Smoothly",
|
||||
"excerpt": "A comprehensive maintenance checklist to keep your WordPress site secure, fast, and reliable. Learn the essential tasks for daily, weekly, and monthly maintenance routines.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Running a WordPress site requires ongoing maintenance to ensure it remains secure, fast, and reliable. Without regular care, sites can become vulnerable to security threats, suffer performance degradation, and eventually break. This comprehensive checklist outlines the essential maintenance tasks you should perform to keep your WordPress site in top condition throughout 2026."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Why Maintenance Matters",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Regular maintenance isn't just about fixing problems—it's about preventing them. A well-maintained site offers:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Better security against threats and vulnerabilities",
|
||||
"Faster loading times and improved user experience",
|
||||
"Fewer technical issues and downtime",
|
||||
"Better search engine rankings",
|
||||
"Lower long-term costs by catching issues early"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Daily Maintenance Tasks",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "These quick checks take just a few minutes each day:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Check if your site is accessible (use a monitoring service)",
|
||||
"Review any automated error notifications",
|
||||
"Check for pending comment moderation (if comments are enabled)",
|
||||
"Review form submissions if you collect leads or orders"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Weekly Maintenance Tasks",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Set aside 30-60 minutes each week for these tasks:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Updates and Backups",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Verify backups completed successfully",
|
||||
"Check for WordPress core updates",
|
||||
"Check for theme and plugin updates",
|
||||
"Review any security alerts from your hosting provider"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Performance Check",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Test page load times on key pages",
|
||||
"Check server resource usage (CPU, memory)",
|
||||
"Review error logs for recurring issues",
|
||||
"Clear any unnecessary cache if needed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Content Review",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Review and approve pending comments",
|
||||
"Check for broken links on important pages",
|
||||
"Review user registrations if applicable",
|
||||
"Check product inventory if running an e-commerce site"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Monthly Maintenance Tasks",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Deeper maintenance tasks that require more time:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Security Audit",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Review user accounts and remove inactive ones",
|
||||
"Check for unused themes and plugins (delete them)",
|
||||
"Verify file permissions are correct",
|
||||
"Review security plugin logs",
|
||||
"Check SSL certificate expiration",
|
||||
"Update strong passwords for admin accounts"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Database Maintenance",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Optimize database tables",
|
||||
"Clean up post revisions (configure limits)",
|
||||
"Remove spam comments",
|
||||
"Clean expired transients",
|
||||
"Remove unused media files"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Performance Optimization",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Analyze performance with Google PageSpeed Insights",
|
||||
"Optimize images that have been added",
|
||||
"Review and clean up CSS and JavaScript",
|
||||
"Check database query performance",
|
||||
"Review caching configuration"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Content Audit",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Identify underperforming content",
|
||||
"Update outdated information",
|
||||
"Refresh internal links",
|
||||
"Review and update meta descriptions",
|
||||
"Check for broken external links"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Quarterly Maintenance Tasks",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Every 3 months, perform these deeper tasks:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Testing and Verification",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Test complete backup restoration on a staging site",
|
||||
"Verify all forms are working correctly",
|
||||
"Test checkout process if e-commerce",
|
||||
"Verify email notifications are working",
|
||||
"Test on different browsers and devices"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Review and Planning",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Review hosting needs and consider upgrades",
|
||||
"Evaluate if current plugins are still needed",
|
||||
"Review SEO performance and adjust strategy",
|
||||
"Check domain registration and SSL renewal dates",
|
||||
"Review analytics for trends and insights"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Annual Maintenance Tasks",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Once a year, tackle these important items:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Renew domain registrations",
|
||||
"Renew SSL certificates",
|
||||
"Review and update privacy policy",
|
||||
"ReviewTerms of Service if applicable",
|
||||
"Check GDPR and compliance requirements",
|
||||
"Archive or update old content",
|
||||
"Review and update disaster recovery plan",
|
||||
"Consider a site redesign or major update"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Automating Maintenance",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Many maintenance tasks can be automated to save time:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Set up automated backups (daily minimum)",
|
||||
"Configure automatic updates for minor releases",
|
||||
"Use uptime monitoring services",
|
||||
"Set up automated security scanning",
|
||||
"Configure error monitoring and alerts",
|
||||
"Use image optimization plugins"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Creating a Maintenance Schedule",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Consistency is key. Here's how to build a sustainable routine:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Schedule maintenance tasks on your calendar",
|
||||
"Set up reminders for recurring tasks",
|
||||
"Document your procedures for consistency",
|
||||
"Track issues to identify patterns",
|
||||
"Adjust frequency based on site complexity"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "quote",
|
||||
"data": {
|
||||
"text": "Spend one hour per week on maintenance to avoid spending days fixing problems later.",
|
||||
"caption": "WordPress Maintenance Best Practices"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "When to Seek Professional Help",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Some tasks require expert assistance:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Major WordPress upgrades",
|
||||
"Server configuration changes",
|
||||
"Security incident response",
|
||||
"Performance optimization for complex sites",
|
||||
"Custom development needs",
|
||||
"Migration to new hosting"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Conclusion",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Regular maintenance is essential for keeping your WordPress site secure, fast, and reliable. By following this checklist and establishing a consistent routine, you can prevent most problems before they occur and ensure your site continues to serve your visitors effectively."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Start with the daily and weekly tasks, then gradually build up to the monthly and quarterly routines. Your site—and your visitors—will thank you."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"author": "Plugin Compass Team",
|
||||
"status": "published",
|
||||
"featured_image": "",
|
||||
"meta_title": "WordPress Maintenance Checklist 2026 | Plugin Compass",
|
||||
"meta_description": "Essential WordPress maintenance checklist for 2026. Daily, weekly, monthly tasks to keep your site secure, fast, and reliable.",
|
||||
"category": "wordpress",
|
||||
"tags": ["wordpress", "maintenance", "security", "performance", "checklist"],
|
||||
"published_at": "2026-02-20T10:00:00Z",
|
||||
"updated_at": "2026-02-20T10:00:00Z"
|
||||
}
|
||||
393
chat/blogs/seo/wordpress-security-essential-guide.json
Normal file
393
chat/blogs/seo/wordpress-security-essential-guide.json
Normal file
@@ -0,0 +1,393 @@
|
||||
{
|
||||
"id": "wordpress-security-essential-guide",
|
||||
"slug": "wordpress-security-essential-guide",
|
||||
"type": "seo",
|
||||
"title": "WordPress Security Essentials: A Practical Guide to Protecting Your Website",
|
||||
"excerpt": "Learn essential WordPress security practices to protect your website from threats. Understand firewalls, malware prevention, login security, and how to build a defense-in-depth strategy.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "WordPress powers over 43% of all websites on the internet, making it a prime target for hackers and malicious attacks. In 2026, website security has never been more critical, with cyber threats becoming increasingly sophisticated. While WordPress core is secure, the vast ecosystem of themes and plugins can introduce vulnerabilities. This guide covers essential security concepts and practical steps every WordPress site owner should understand."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Why WordPress Security Matters",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Understanding why security matters helps prioritize protection efforts. A compromised website can lead to:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Data breaches exposing customer information",
|
||||
"Blacklisting by search engines (damaging your SEO)",
|
||||
"Malware distribution to your visitors",
|
||||
"Loss of revenue and customer trust",
|
||||
"Legal liabilities and compliance issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Investing in robust security measures is not just about protection—it's about maintaining your business reputation and ensuring uninterrupted service to your customers."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Understanding the Threat Landscape",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Before implementing security measures, it's important to understand the types of threats your site faces:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Brute Force Attacks: Automated attempts to guess passwords",
|
||||
"SQL Injection: Malicious database queries",
|
||||
"Cross-Site Scripting (XSS): Injecting malicious scripts",
|
||||
"Distributed Denial of Service (DDoS): Overwhelming your server",
|
||||
"Phishing: Trick users into revealing credentials",
|
||||
"Malware: Viruses, ransomware, and spyware"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Essential Security Measures",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Web Application Firewall (WAF)",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "A WAF acts as a shield between your website and incoming traffic, blocking malicious requests before they reach your server. When evaluating security solutions, look for:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Real-time threat detection and blocking",
|
||||
"Protection against OWASP Top 10 vulnerabilities",
|
||||
"DDoS mitigation capabilities",
|
||||
"Regular rule updates to address new threats",
|
||||
"Minimal false positives to avoid blocking legitimate traffic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Malware Scanning and Removal",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Malware scanning helps detect malicious code before it causes damage. Effective scanning should include:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Core file integrity monitoring",
|
||||
"Theme and plugin scanning",
|
||||
"Database malware detection",
|
||||
"Scheduled automatic scans",
|
||||
"Notification system for detected threats"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "3. Login Security",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "The login page is often the first target for attackers. Strengthen login security by implementing:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Two-factor authentication (2FA)",
|
||||
"Strong password requirements",
|
||||
"Login attempt limits",
|
||||
"CAPTCHA to prevent automated attacks",
|
||||
"Unique login URLs (not /wp-admin)",
|
||||
"Email-based login notifications"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "4. User Access Management",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Not all users need full access. Implement the principle of least privilege:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Create role-specific user accounts",
|
||||
"Use WordPress built-in roles (Admin, Editor, Author, Contributor, Subscriber)",
|
||||
"Regularly audit user accounts and remove inactive ones",
|
||||
"Consider using multi-site for managing multiple properties",
|
||||
"Monitor user activity logs"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "5. File System Security",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Protect your files from unauthorized access and modification:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Disable file editing in the WordPress admin",
|
||||
"Set proper file permissions (directories 755, files 644)",
|
||||
"Protect configuration files (wp-config.php, .htaccess)",
|
||||
"Disable PHP execution in upload directories",
|
||||
"Use secure FTP (SFTP) for file transfers"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "6. Database Security",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your database contains all your content and user data. Protect it by:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Using strong database passwords",
|
||||
"Changing the database table prefix from wp_",
|
||||
"Limiting database user privileges",
|
||||
"Enabling SSL connections to the database",
|
||||
"Regular backups (see our backup guide)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "7. SSL/TLS Certificates",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "HTTPS encrypts data between your server and visitors, protecting sensitive information:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Install an SSL certificate (often free through Let's Encrypt)",
|
||||
"Force HTTPS for admin areas",
|
||||
"Implement HTTP to HTTPS redirects",
|
||||
"Use HSTS headers for enhanced security",
|
||||
"Monitor certificate expiration dates"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "8. Monitoring and Logging",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "You can't protect what you can't see. Implement monitoring to detect issues early:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Failed login attempt logging",
|
||||
"File change detection",
|
||||
"404 error monitoring (can indicate scanning)",
|
||||
"Resource usage monitoring",
|
||||
"Uptime monitoring",
|
||||
"Regular security audit reports"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Defense in Depth: Layered Security",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "No single security measure is foolproof. The most effective approach uses multiple layers of protection:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"At the network level: Firewall, CDN, DDoS protection",
|
||||
"At the server level: Proper configurations, monitoring",
|
||||
"At the application level: Security plugins, updates",
|
||||
"At the user level: Strong passwords, 2FA, access controls",
|
||||
"At the data level: Backups, encryption, access logs"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Choosing Security Solutions",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "When evaluating security tools, consider these factors:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Coverage: Does it address the threats relevant to your site?",
|
||||
"Performance: Will it slow down your website?",
|
||||
"Updates: Is it regularly updated for new threats?",
|
||||
"Support: Is help available when needed?",
|
||||
"Reviews: What do other users report?",
|
||||
"Cost: Does it fit your budget? Are there hidden fees?"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Important: Never run multiple security plugins simultaneously—they'll conflict and can actually create vulnerabilities."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "When Standard Solutions Aren't Enough",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "While standard security plugins work well for most sites, businesses with unique requirements may need custom solutions. This is where custom plugin development can help address specific security needs that off-the-shelf products don't cover."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Conclusion",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "WordPress security is not a set-it-and-forget-it task. It requires ongoing attention, regular updates, and a multi-layered approach. By understanding the threat landscape and implementing defense-in-depth strategies, you can significantly reduce your risk."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Remember: No single plugin or measure can guarantee 100% security. Combine good security practices—regular backups, strong passwords, keeping everything updated—with appropriate security tools. Stay vigilant, monitor for threats, and have a response plan ready."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"author": "Plugin Compass Team",
|
||||
"status": "published",
|
||||
"featured_image": "",
|
||||
"meta_title": "WordPress Security Essentials: Practical Guide | Plugin Compass",
|
||||
"meta_description": "Learn essential WordPress security practices. Understand firewalls, malware prevention, login security, and defense-in-depth strategies.",
|
||||
"category": "wordpress",
|
||||
"tags": ["wordpress", "security", "website protection", "malware", "firewall", "defense"],
|
||||
"published_at": "2026-01-15T10:00:00Z",
|
||||
"updated_at": "2026-02-20T10:00:00Z"
|
||||
}
|
||||
546
chat/blogs/seo/wordpress-seo-best-practices-2026.json
Normal file
546
chat/blogs/seo/wordpress-seo-best-practices-2026.json
Normal file
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"id": "wordpress-seo-best-practices-2026",
|
||||
"slug": "wordpress-seo-best-practices-2026",
|
||||
"type": "seo",
|
||||
"title": "WordPress SEO Best Practices for 2026: A Complete Guide",
|
||||
"excerpt": "Master WordPress SEO with this comprehensive guide. Learn on-page optimization, technical SEO, content strategies, and how to improve your search rankings.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Search engine optimization (SEO) remains one of the most effective ways to drive organic traffic to your WordPress site. With search algorithms evolving constantly, staying up-to-date with best practices is essential. This guide covers the most important SEO strategies for WordPress sites in 2026, from technical optimization to content creation."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Understanding SEO Fundamentals",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Before diving into specific techniques, it's important to understand what search engines are looking for:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Relevant, high-quality content that matches user intent",
|
||||
"Proper HTML structure and semantic markup",
|
||||
"Fast page loading times",
|
||||
"Mobile-friendly design",
|
||||
"Secure website (HTTPS)",
|
||||
"Good user experience and low bounce rates",
|
||||
"Authoritative backlinks from reputable sources"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Technical SEO for WordPress",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Site Speed Optimization",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Page speed is a confirmed ranking factor. Here's how to optimize your WordPress site:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Use a quality hosting provider appropriate for your traffic",
|
||||
"Implement caching (page, browser, and object caching)",
|
||||
"Optimize images (compress, use next-gen formats, lazy load)",
|
||||
"Minify CSS, JavaScript, and HTML",
|
||||
"Reduce server response time",
|
||||
"Use a Content Delivery Network (CDN)",
|
||||
"Eliminate render-blocking resources"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Mobile Optimization",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "With mobile-first indexing, your site must perform well on mobile devices:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Use a responsive theme that adapts to all screen sizes",
|
||||
"Ensure tap targets are appropriately sized",
|
||||
"Test with Google's Mobile-Friendly Test",
|
||||
"Avoid interstitials that block content",
|
||||
"Optimize for Core Web Vitals"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "3. XML Sitemaps",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Sitemaps help search engines discover and index your content:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Generate an XML sitemap automatically",
|
||||
"Include all important content pages",
|
||||
"Exclude admin pages, login, and duplicate content",
|
||||
"Submit to Google Search Console",
|
||||
"Update sitemap when adding new content"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "4. SSL and Security",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Security is a ranking factor and builds user trust:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Install an SSL certificate (often free via Let's Encrypt)",
|
||||
"Redirect HTTP to HTTPS",
|
||||
"Implement HSTS headers",
|
||||
"Keep WordPress, themes, and plugins updated",
|
||||
"Use security plugins for additional protection"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "5. URL Structure",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Clean, descriptive URLs help both users and search engines:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Use readable, keyword-rich slugs",
|
||||
"Keep URLs short and descriptive",
|
||||
"Use hyphens to separate words",
|
||||
"Avoid parameters when possible",
|
||||
"Implement canonical URLs to avoid duplicate content",
|
||||
"Maintain a consistent URL structure"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "On-Page SEO",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Title Tags and Meta Descriptions",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "These elements appear in search results and influence click-through rates:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Include primary keyword near the beginning",
|
||||
"Keep titles under 60 characters",
|
||||
"Write unique titles for each page",
|
||||
"Meta descriptions under 155 characters",
|
||||
"Include a clear call-to-action",
|
||||
"Make each description unique and compelling"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Heading Structure",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Proper heading hierarchy helps search engines understand content structure:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Use one H1 per page (usually the page title)",
|
||||
"Use H2 for major sections",
|
||||
"Use H3 for subsections",
|
||||
"Include keywords in headings naturally",
|
||||
"Don't skip heading levels",
|
||||
"Make headings descriptive and clear"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "3. Content Optimization",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Quality content is the foundation of SEO:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Write comprehensive, in-depth content",
|
||||
"Use keywords naturally throughout content",
|
||||
"Include related keywords and synonyms",
|
||||
"Add images with optimized alt text",
|
||||
"Use internal links to related content",
|
||||
"Update and improve existing content"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "4. Image Optimization",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Images can drive significant traffic but need proper optimization:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Use descriptive filenames with keywords",
|
||||
"Write detailed alt text for each image",
|
||||
"Compress images to reduce file size",
|
||||
"Use WebP or other modern formats",
|
||||
"Implement lazy loading",
|
||||
"Specify dimensions to prevent layout shifts"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "5. Internal Linking",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Internal links help search engines understand site structure:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Link to related content within your site",
|
||||
"Use descriptive anchor text",
|
||||
"Create a logical hierarchy",
|
||||
"Link from high-authority pages to important ones",
|
||||
"Don't over-optimize anchor text",
|
||||
"Regularly audit for broken internal links"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Schema Markup",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Schema markup helps search engines understand your content better and can lead to rich snippets:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Article schema for blog posts",
|
||||
"Organization schema for business info",
|
||||
"LocalBusiness schema for local SEO",
|
||||
"Product schema for e-commerce",
|
||||
"FAQ schema for question content",
|
||||
"Review schema for testimonials"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Content Strategy",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Keyword Research",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Understanding what people search for is fundamental:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Identify primary keywords for each page",
|
||||
"Find related long-tail keywords",
|
||||
"Understand search intent (informational, navigational, transactional)",
|
||||
"Analyze keyword difficulty and competition",
|
||||
"Look for questions people are asking",
|
||||
"Monitor ranking positions over time"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Content Quality",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Search engines prioritize content that provides value:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Answer questions comprehensively",
|
||||
"Provide unique insights and perspectives",
|
||||
"Use data and statistics to support points",
|
||||
"Include expert quotes or research",
|
||||
"Update content regularly",
|
||||
"Aim for comprehensive coverage of topics"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "3. Content Types",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Diversify your content to reach different audiences:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"How-to guides and tutorials",
|
||||
"Industry news and updates",
|
||||
"Case studies and success stories",
|
||||
"Infographics and visual content",
|
||||
"Videos and podcasts",
|
||||
"FAQ pages addressing common questions"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "User Experience and SEO",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "User signals influence rankings significantly:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Reduce bounce rate with engaging content",
|
||||
"Increase time on page with quality content",
|
||||
"Improve click-through rate with compelling titles",
|
||||
"Ensure easy navigation and clear structure",
|
||||
"Make content scannable with formatting",
|
||||
"Optimize for Core Web Vitals"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Monitoring and Analytics",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "You can't improve what you don't measure:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Set up Google Search Console",
|
||||
"Install Google Analytics 4",
|
||||
"Track keyword rankings",
|
||||
"Monitor organic traffic trends",
|
||||
"Analyze user behavior metrics",
|
||||
"Identify and fix crawl errors"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Common SEO Mistakes to Avoid",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Keyword stuffing and over-optimization",
|
||||
"Duplicate content issues",
|
||||
"Ignoring mobile optimization",
|
||||
"Neglecting technical SEO",
|
||||
"Buying links or link schemes",
|
||||
"Not updating content regularly",
|
||||
"Ignoring user experience signals"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Conclusion",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "SEO for WordPress requires attention to both technical elements and quality content. By focusing on site speed, mobile optimization, proper markup, and valuable content, you can improve your search rankings and drive more organic traffic."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Remember that SEO is a long-term strategy. Results don't happen overnight, but consistent effort in following these best practices will pay off over time. Stay updated with algorithm changes, monitor your performance, and continue improving your site."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"author": "Plugin Compass Team",
|
||||
"status": "published",
|
||||
"featured_image": "",
|
||||
"meta_title": "WordPress SEO Best Practices 2026 | Plugin Compass",
|
||||
"meta_description": "Complete WordPress SEO guide for 2026. Learn on-page optimization, technical SEO, content strategies, and how to improve search rankings.",
|
||||
"category": "wordpress",
|
||||
"tags": ["wordpress", "seo", "search engine optimization", "rankings", "traffic"],
|
||||
"published_at": "2026-02-20T10:00:00Z",
|
||||
"updated_at": "2026-02-20T10:00:00Z"
|
||||
}
|
||||
467
chat/blogs/seo/wordpress-theme-selection-guide.json
Normal file
467
chat/blogs/seo/wordpress-theme-selection-guide.json
Normal file
@@ -0,0 +1,467 @@
|
||||
{
|
||||
"id": "wordpress-theme-selection-guide",
|
||||
"slug": "wordpress-theme-selection-guide",
|
||||
"type": "seo",
|
||||
"title": "How to Choose the Right WordPress Theme: A Complete Guide",
|
||||
"excerpt": "Learn how to select the perfect WordPress theme for your website. Understand the key factors to consider including performance, customization, support, and long-term viability.",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your WordPress theme forms the foundation of your website's design and functionality. Choosing the right theme is one of the most important decisions you'll make, as it affects everything from user experience to search engine rankings. With thousands of themes available, making the right choice can feel overwhelming. This guide walks you through the key factors to consider when selecting a WordPress theme."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Why Theme Selection Matters",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your theme impacts far more than just aesthetics:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Performance: Poorly coded themes slow down your site",
|
||||
"Security: Outdated themes can have vulnerabilities",
|
||||
"Functionality: Some themes limit what you can do",
|
||||
"Mobile experience: Not all themes work well on mobile",
|
||||
"SEO: Theme code affects search rankings",
|
||||
"Maintenance: Difficult themes create ongoing problems",
|
||||
"Scalability: Some themes can't grow with your business"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Key Factors to Consider",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "1. Performance",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Site speed is crucial for user experience and SEO. When evaluating theme performance:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Look for lightweight themes (page size under 100KB is ideal)",
|
||||
"Check if the theme uses unnecessary libraries",
|
||||
"Consider themes with modular features (load only what you need)",
|
||||
"Test demo sites with PageSpeed Insights",
|
||||
"Avoid themes with excessive animations and effects",
|
||||
"Check if the theme includes built-in caching"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "quote",
|
||||
"data": {
|
||||
"text": "A fast theme is the foundation of a fast website. No amount of optimization can fix a poorly coded theme.",
|
||||
"caption": "WordPress Performance Expert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "2. Responsiveness and Mobile-First",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "With mobile-first indexing, your theme must work perfectly on all devices:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Verify the theme is fully responsive",
|
||||
"Test on multiple devices and screen sizes",
|
||||
"Check that mobile navigation works well",
|
||||
"Ensure tap targets are appropriately sized",
|
||||
"Look for themes that prioritize mobile performance",
|
||||
"Verify Core Web Vitals pass on mobile"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "3. Customization Options",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Consider how much you'll need to customize the theme:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Does the theme include a customizer for easy changes?",
|
||||
"Are there built-in layout options?",
|
||||
"Can you change colors and fonts easily?",
|
||||
"Does it support the page builder you want to use?",
|
||||
"Are there header and footer builder options?",
|
||||
"Does it include pre-built templates or require coding?"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "4. Plugin Compatibility",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Your theme should work well with essential plugins:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Test with popular SEO plugins",
|
||||
"Verify compatibility with caching plugins",
|
||||
"Check that form plugins display correctly",
|
||||
"Ensure e-commerce themes work with WooCommerce",
|
||||
"Look for themes that don't conflict with security plugins",
|
||||
"Test with any specific plugins you need"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "5. Support and Documentation",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Quality support can save you hours of frustration:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Check support response times and quality",
|
||||
"Look for comprehensive documentation",
|
||||
"Check if there's a knowledge base or tutorials",
|
||||
"Look for active community forums",
|
||||
"Consider premium themes with dedicated support",
|
||||
"Check developer track record and reputation"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "6. Update Frequency",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"Themes that aren't regularly updated can become a security risk:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Check when the theme was last updated",
|
||||
"Look for themes updated within the last 3-6 months",
|
||||
"Check changelog for update history",
|
||||
"Verify WordPress version compatibility",
|
||||
"Check PHP version requirements",
|
||||
"Consider themes with active development"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Types of WordPress Themes",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Multi-Purpose vs. Niche Themes",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Multi-purpose themes offer flexibility but may include unnecessary features:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Multi-purpose: Work for any type of site, more features, potentially larger",
|
||||
"Niche: Designed for specific industries, leaner, tailored features",
|
||||
"Consider your current and future needs"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Page Builder Compatibility",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Themes work differently with page builders:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Full-site editing themes (Gutenberg-native)",
|
||||
"Themes with built-in page builders",
|
||||
"Themes designed for Elementor, Beaver Builder, etc.",
|
||||
"Neutral themes that work with any builder"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Free vs. Premium Themes",
|
||||
"level": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Both free and premium options have their place:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Free themes: Good for simple sites, budget constraints",
|
||||
"Premium themes: More features, better support, regular updates",
|
||||
"Consider total cost including necessary add-ons"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Where to Find Quality Themes",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Source themes from reputable marketplaces:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"WordPress.org Theme Directory (reviewed and tested)",
|
||||
"ThemeForest (popular marketplace)",
|
||||
"Elegant Themes (Divi)",
|
||||
"GeneratePress (lightweight option)",
|
||||
"Astra (popular multi-purpose)",
|
||||
"StudioPress (Genesis framework)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Red Flags to Avoid",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Watch out for these warning signs:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"No updates in over a year",
|
||||
"Poor ratings or negative reviews",
|
||||
"Limited or no support",
|
||||
"Excessive bundled plugins",
|
||||
"Confusing or cluttered admin interface",
|
||||
"Not responsive on mobile",
|
||||
"Poor documentation"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Making Your Final Decision",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Follow this decision process:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"List your requirements (must-haves and nice-to-haves)",
|
||||
"Set a budget (including premium plugins you might need)",
|
||||
"Research and shortlist 3-5 themes",
|
||||
"Test demos thoroughly on multiple devices",
|
||||
"Check reviews and support quality",
|
||||
"Verify update history and compatibility",
|
||||
"Test with essential plugins before committing"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Theme Trial and Testing",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Before committing to a theme, test it thoroughly:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "ordered",
|
||||
"items": [
|
||||
"Install on a staging site",
|
||||
"Build out key page templates",
|
||||
"Test with real content",
|
||||
"Check page load times",
|
||||
"Verify plugin compatibility",
|
||||
"Test on multiple devices",
|
||||
"Evaluate the customizer options"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Long-Term Considerations",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Think about the future when choosing a theme:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"data": {
|
||||
"style": "unordered",
|
||||
"items": [
|
||||
"Will the theme support your growth?",
|
||||
"Is the developer likely to continue updates?",
|
||||
"Can you migrate to another theme later if needed?",
|
||||
"Does the theme follow WordPress best practices?",
|
||||
"Is there a roadmap for future development?",
|
||||
"Will it work with future WordPress versions?"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Conclusion",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Choosing the right WordPress theme is a critical decision that affects your entire online presence. Take your time, do your research, and don't rush the decision. Consider your current needs as well as future growth, and prioritize performance and quality over flashy features."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Remember that you can always change your theme later, but it's much easier to choose well from the start. Use the criteria in this guide to evaluate your options, and you'll be well on your way to finding the perfect theme for your WordPress site."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"author": "Plugin Compass Team",
|
||||
"status": "published",
|
||||
"featured_image": "",
|
||||
"meta_title": "How to Choose the Right WordPress Theme | Plugin Compass",
|
||||
"meta_description": "Complete guide to choosing the right WordPress theme. Learn about performance, customization, support, and how to avoid common mistakes.",
|
||||
"category": "wordpress",
|
||||
"tags": ["wordpress", "theme", "design", "website", "selection"],
|
||||
"published_at": "2026-02-20T10:00:00Z",
|
||||
"updated_at": "2026-02-20T10:00:00Z"
|
||||
}
|
||||
10
chat/package-lock.json
generated
10
chat/package-lock.json
generated
@@ -15,7 +15,6 @@
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^7.0.7",
|
||||
"pdfkit": "^0.17.2",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
@@ -1263,15 +1262,6 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -94,4 +95,4 @@
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -94,3 +95,4 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
@@ -33,6 +33,89 @@
|
||||
.ce-toolbar__content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* EditorJS Toolbar & Popup Fixes */
|
||||
.ce-toolbar__plus,
|
||||
.ce-toolbar__actions {
|
||||
background: var(--bg-1, #fff) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.ce-inline-toolbar {
|
||||
background: var(--bg-1, #fff) !important;
|
||||
border: 1px solid var(--border, #e5e7eb) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
.ce-inline-tool,
|
||||
.ce-inline-toolbar__dropdown,
|
||||
.ce-toolbar__plus {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ce-inline-tool:hover,
|
||||
.ce-inline-toolbar__dropdown:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.ce-inline-tool--active {
|
||||
background: var(--accent, #006B3D) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.ce-popover {
|
||||
background: var(--bg-1, #fff) !important;
|
||||
border: 1px solid var(--border, #e5e7eb) !important;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
.ce-popover__item {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ce-popover__item:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.ce-popover__item--active,
|
||||
.ce-popover__item--focused {
|
||||
background: rgba(0, 107, 61, 0.1) !important;
|
||||
}
|
||||
.ce-conversion-toolbar {
|
||||
background: var(--bg-1, #fff) !important;
|
||||
border: 1px solid var(--border, #e5e7eb) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
.ce-conversion-tool {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ce-conversion-tool:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.ce-settings {
|
||||
background: var(--bg-1, #fff) !important;
|
||||
border: 1px solid var(--border, #e5e7eb) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
.ce-settings__button {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ce-settings__button:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.cdx-settings-button {
|
||||
background: transparent !important;
|
||||
}
|
||||
.cdx-settings-button:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.ce-toolbox__button {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ce-toolbox__button:hover {
|
||||
background: rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.ce-toolbox__button--active {
|
||||
background: var(--accent, #006B3D) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.blog-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -245,6 +328,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -427,3 +511,4 @@
|
||||
<script src="/js/blog-admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost active" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -307,3 +308,4 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -80,3 +81,4 @@
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost active" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -535,3 +536,4 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -46,7 +47,7 @@
|
||||
<div>
|
||||
<div class="pill">Admin</div>
|
||||
<div class="title" style="margin-top: 6px;">Planning Control</div>
|
||||
<div class="crumb">Fallback-ready planning across OpenRouter, Mistral, Google, Groq, and NVIDIA.</div>
|
||||
<div class="crumb">Fallback-ready planning across OpenRouter, Mistral, Google, Groq, NVIDIA, and DeepInfra.</div>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button id="admin-refresh" class="ghost">Refresh</button>
|
||||
@@ -82,6 +83,7 @@
|
||||
<option value="google">Google</option>
|
||||
<option value="groq">Groq</option>
|
||||
<option value="nvidia">NVIDIA</option>
|
||||
<option value="deepinfra">DeepInfra</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -145,3 +147,4 @@
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -97,4 +98,4 @@
|
||||
</div>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -224,9 +224,10 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost active" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
<a class="ghost" href="/admin/login">Login</a>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -962,3 +963,4 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
95
chat/public/admin-system-tests.html
Normal file
95
chat/public/admin-system-tests.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Admin Panel - System Tests</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
|
||||
<!-- PostHog Analytics -->
|
||||
<script src="/posthog.js"></script>
|
||||
</head>
|
||||
<body data-page="system-tests">
|
||||
<div class="sidebar-overlay"></div>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">A</div>
|
||||
<div>
|
||||
<div class="brand-title">Admin</div>
|
||||
<div class="brand-sub">Site management</div>
|
||||
</div>
|
||||
<button id="close-sidebar" class="ghost" style="margin-left: auto; display: none;">×</button>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="section-heading">Navigation</div>
|
||||
<a class="ghost" href="/admin/build">Build models</a>
|
||||
<a class="ghost" href="/admin/plan">Plan models</a>
|
||||
<a class="ghost" href="/admin/plans">Plans</a>
|
||||
<a class="ghost" href="/admin/accounts">Accounts</a>
|
||||
<a class="ghost" href="/admin/affiliates">Affiliates</a>
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
<a class="ghost" href="/admin/blogs">Blog Management</a>
|
||||
<a class="ghost" href="/admin/login">Login</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="admin-shell">
|
||||
<div class="topbar" style="margin-bottom: 12px;">
|
||||
<button id="menu-toggle">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<div>
|
||||
<div class="pill">Admin</div>
|
||||
<div class="title" style="margin-top: 6px;">System Tests</div>
|
||||
<div class="crumb">Run end-to-end checks for database, accounts, and payments.</div>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button id="admin-refresh" class="ghost">Refresh</button>
|
||||
<button id="admin-logout" class="primary">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<header>
|
||||
<h3>Full system self-test</h3>
|
||||
<div class="pill">Diagnostics</div>
|
||||
</header>
|
||||
<p class="muted" style="margin-top:0;">
|
||||
Runs database/encryption checks, creates a temporary test account, and verifies payment configuration.
|
||||
If Dodo is configured, it will create a test checkout session (no charge is completed).
|
||||
</p>
|
||||
<div class="admin-actions">
|
||||
<button id="system-tests-run" class="primary">Run full self-test</button>
|
||||
<div class="status-line" id="system-tests-status"></div>
|
||||
</div>
|
||||
<div id="system-tests-output" class="admin-list" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<header>
|
||||
<h3>WordPress Validator MCP Test</h3>
|
||||
<div class="pill">MCP Server</div>
|
||||
</header>
|
||||
<p class="muted" style="margin-top:0;">
|
||||
Tests the WordPress Validator MCP server end-to-end: creates a minimal test plugin,
|
||||
runs the validator MCP tool, and verifies the response format.
|
||||
</p>
|
||||
<div class="admin-actions">
|
||||
<button id="validator-mcp-test-run" class="primary">Test Validator MCP</button>
|
||||
<div class="status-line" id="validator-mcp-status"></div>
|
||||
</div>
|
||||
<div id="validator-mcp-output" class="admin-list" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -184,6 +184,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -990,3 +991,4 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -93,3 +94,4 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
||||
<a class="ghost" href="/admin/tracking">Tracking</a>
|
||||
<a class="ghost" href="/admin/resources">Resources</a>
|
||||
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
||||
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
||||
@@ -120,10 +121,6 @@
|
||||
</header>
|
||||
<p style="margin-top:0; color: var(--muted);">These models are displayed to users in the builder dropdown for selection. This is separate from the OpenCode fallback chain.</p>
|
||||
<form id="public-model-form" class="admin-form">
|
||||
<label>
|
||||
Model ID (e.g., claude-3-5-sonnet, gpt-4o)
|
||||
<input id="public-model-name" type="text" placeholder="Enter model ID manually" required />
|
||||
</label>
|
||||
<label>
|
||||
Display name shown to users
|
||||
<input id="public-model-label" type="text" placeholder="Friendly label (e.g., Claude 3.5 Sonnet)" required />
|
||||
@@ -164,6 +161,16 @@
|
||||
<div id="opencode-models-list" class="admin-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Unavailable Models Section -->
|
||||
<div class="admin-card unavailable-models-section" style="margin-top: 16px; display: none;">
|
||||
<header>
|
||||
<h3>Unavailable Models</h3>
|
||||
<div class="pill" id="unavailable-models-count">0</div>
|
||||
</header>
|
||||
<p class="muted" style="margin-top:0;">These models were removed from the fallback chain because they are no longer available from OpenCode. You can re-add them if they become available again.</p>
|
||||
<div id="unavailable-models-list" class="admin-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Public Models List with Ordering -->
|
||||
<div class="admin-card" style="margin-top: 16px;">
|
||||
<header>
|
||||
@@ -243,44 +250,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-top: 16px;">
|
||||
<header>
|
||||
<h3>OpenCode Ultimate Backup Model</h3>
|
||||
<div class="pill">Fallback</div>
|
||||
</header>
|
||||
<p style="margin-top:0; color: var(--muted);">Configure the ultimate fallback model that will be used when all providers fail. This is the last-resort backup for reliability.</p>
|
||||
<form id="opencode-backup-form" class="admin-form">
|
||||
<label>
|
||||
OpenCode backup model
|
||||
<select id="opencode-backup"></select>
|
||||
</label>
|
||||
<div class="admin-actions">
|
||||
<button type="submit" class="primary">Save backup model</button>
|
||||
</div>
|
||||
<div class="status-line" id="opencode-backup-status"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-top: 16px;">
|
||||
<header>
|
||||
<h3>Auto Model for Hobby/Free Plan</h3>
|
||||
<div class="pill">Free Plan</div>
|
||||
</header>
|
||||
<p style="margin-top:0; color: var(--muted);">Select which model Hobby and Free plan users will automatically use. Paid plan users can select their own models.</p>
|
||||
<form id="auto-model-form" class="admin-form">
|
||||
<label>
|
||||
Model for hobby/free users
|
||||
<select id="auto-model-select">
|
||||
<option value="">Auto (use first configured model)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="admin-actions">
|
||||
<button type="submit" class="primary">Save auto model</button>
|
||||
</div>
|
||||
<div class="status-line" id="auto-model-status"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid" style="margin-top: 16px;">
|
||||
<div class="admin-card">
|
||||
<header>
|
||||
@@ -299,6 +268,7 @@
|
||||
<option value="nvidia">NVIDIA</option>
|
||||
<option value="chutes">Chutes</option>
|
||||
<option value="cerebras">Cerebras</option>
|
||||
<option value="deepinfra">DeepInfra</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="cohere">Cohere</option>
|
||||
@@ -354,3 +324,4 @@
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(() => {
|
||||
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode', 'cohere'];
|
||||
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'cohere'];
|
||||
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'deepinfra', 'ollama', 'opencode', 'cohere'];
|
||||
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'deepinfra', 'ollama', 'cohere'];
|
||||
const pageType = document?.body?.dataset?.page || 'build';
|
||||
console.log('Admin JS loaded, pageType:', pageType);
|
||||
const state = {
|
||||
@@ -12,10 +12,9 @@
|
||||
accounts: [],
|
||||
affiliates: [],
|
||||
withdrawals: [],
|
||||
planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] },
|
||||
planSettings: { provider: 'openrouter', planningChain: [] },
|
||||
providerLimits: {},
|
||||
providerUsage: [],
|
||||
opencodeBackupModel: '',
|
||||
providerOptions: [],
|
||||
providerModels: {},
|
||||
tokenRates: {},
|
||||
@@ -33,9 +32,12 @@
|
||||
reloadOpencodeModels: document.getElementById('reload-opencode-models'),
|
||||
opencodeModelsList: document.getElementById('opencode-models-list'),
|
||||
opencodeModelsCount: document.getElementById('opencode-models-count'),
|
||||
// Unavailable Models
|
||||
unavailableModelsSection: document.querySelector('.unavailable-models-section'),
|
||||
unavailableModelsList: document.getElementById('unavailable-models-list'),
|
||||
unavailableModelsCount: document.getElementById('unavailable-models-count'),
|
||||
// Public Models (user-facing selection)
|
||||
publicModelForm: document.getElementById('public-model-form'),
|
||||
publicModelName: document.getElementById('public-model-name'),
|
||||
publicModelLabel: document.getElementById('public-model-label'),
|
||||
publicModelTier: document.getElementById('public-model-tier'),
|
||||
publicModelIcon: document.getElementById('public-model-icon'),
|
||||
@@ -69,12 +71,8 @@
|
||||
orBackup2: document.getElementById('or-backup2'),
|
||||
orBackup3: document.getElementById('or-backup3'),
|
||||
orStatus: document.getElementById('or-status'),
|
||||
autoModelForm: document.getElementById('auto-model-form'),
|
||||
autoModelSelect: document.getElementById('auto-model-select'),
|
||||
autoModelStatus: document.getElementById('auto-model-status'),
|
||||
planProviderForm: document.getElementById('plan-provider-form'),
|
||||
planProvider: document.getElementById('plan-provider'),
|
||||
freePlanModel: document.getElementById('free-plan-model'),
|
||||
planProviderStatus: document.getElementById('plan-provider-status'),
|
||||
planPriorityList: document.getElementById('plan-priority-list'),
|
||||
addPlanRow: document.getElementById('add-plan-row'),
|
||||
@@ -120,9 +118,6 @@
|
||||
// Cancel messages UI
|
||||
cancelAllMessages: document.getElementById('cancel-all-messages'),
|
||||
cancelMessagesStatus: document.getElementById('cancel-messages-status'),
|
||||
opencodeBackupForm: document.getElementById('opencode-backup-form'),
|
||||
opencodeBackup: document.getElementById('opencode-backup'),
|
||||
opencodeBackupStatus: document.getElementById('opencode-backup-status'),
|
||||
externalTestingRun: document.getElementById('external-testing-run'),
|
||||
externalTestingStatus: document.getElementById('external-testing-status'),
|
||||
externalTestingOutput: document.getElementById('external-testing-output'),
|
||||
@@ -130,10 +125,13 @@
|
||||
ollamaTestRun: document.getElementById('ollama-test-run'),
|
||||
ollamaTestStatus: document.getElementById('ollama-test-status'),
|
||||
ollamaTestOutput: document.getElementById('ollama-test-output'),
|
||||
systemTestsRun: document.getElementById('system-tests-run'),
|
||||
systemTestsStatus: document.getElementById('system-tests-status'),
|
||||
systemTestsOutput: document.getElementById('system-tests-output'),
|
||||
validatorMcpTestRun: document.getElementById('validator-mcp-test-run'),
|
||||
validatorMcpStatus: document.getElementById('validator-mcp-status'),
|
||||
validatorMcpOutput: document.getElementById('validator-mcp-output'),
|
||||
};
|
||||
console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm);
|
||||
console.log('Element check - opencodeBackup:', el.opencodeBackup);
|
||||
console.log('Element check - opencodeBackupStatus:', el.opencodeBackupStatus);
|
||||
|
||||
function ensureAvailableDatalist() {
|
||||
if (el.availableModelDatalist) return el.availableModelDatalist;
|
||||
@@ -205,12 +203,6 @@
|
||||
el.providerLimitStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setAutoModelStatus(msg, isError = false) {
|
||||
if (!el.autoModelStatus) return;
|
||||
el.autoModelStatus.textContent = msg || '';
|
||||
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setPublicModelStatus(msg, isError = false) {
|
||||
if (!el.publicModelStatus) return;
|
||||
el.publicModelStatus.textContent = msg || '';
|
||||
@@ -229,18 +221,134 @@
|
||||
el.providerChainStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setOpencodeBackupStatus(msg, isError = false) {
|
||||
if (!el.opencodeBackupStatus) return;
|
||||
el.opencodeBackupStatus.textContent = msg || '';
|
||||
el.opencodeBackupStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setExternalTestingStatus(msg, isError = false) {
|
||||
if (!el.externalTestingStatus) return;
|
||||
el.externalTestingStatus.textContent = msg || '';
|
||||
el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setSystemTestsStatus(msg, isError = false) {
|
||||
if (!el.systemTestsStatus) return;
|
||||
el.systemTestsStatus.textContent = msg || '';
|
||||
el.systemTestsStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function setValidatorMcpStatus(msg, isError = false) {
|
||||
if (!el.validatorMcpStatus) return;
|
||||
el.validatorMcpStatus.textContent = msg || '';
|
||||
el.validatorMcpStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||
}
|
||||
|
||||
function renderValidatorMcpOutput(data) {
|
||||
if (!el.validatorMcpOutput) return;
|
||||
el.validatorMcpOutput.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const summaryRow = document.createElement('div');
|
||||
summaryRow.className = 'admin-row';
|
||||
const summaryLabel = document.createElement('div');
|
||||
summaryLabel.style.minWidth = '180px';
|
||||
summaryLabel.style.color = 'var(--muted)';
|
||||
summaryLabel.textContent = 'Test Summary';
|
||||
const summaryValue = document.createElement('div');
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.textContent = data.ok ? 'PASSED' : 'FAILED';
|
||||
statusSpan.style.color = data.ok ? 'var(--shopify-green)' : 'var(--danger)';
|
||||
statusSpan.style.fontWeight = '600';
|
||||
summaryValue.appendChild(statusSpan);
|
||||
|
||||
if (data.durationMs) {
|
||||
const timing = document.createElement('span');
|
||||
timing.textContent = ` (${data.durationMs}ms)`;
|
||||
timing.style.color = 'var(--muted)';
|
||||
timing.style.marginLeft = '8px';
|
||||
summaryValue.appendChild(timing);
|
||||
}
|
||||
|
||||
summaryRow.appendChild(summaryLabel);
|
||||
summaryRow.appendChild(summaryValue);
|
||||
el.validatorMcpOutput.appendChild(summaryRow);
|
||||
|
||||
if (data.error) {
|
||||
const errorRow = document.createElement('div');
|
||||
errorRow.className = 'admin-row';
|
||||
errorRow.style.background = 'rgba(220, 38, 38, 0.1)';
|
||||
errorRow.style.padding = '12px';
|
||||
errorRow.style.borderRadius = '6px';
|
||||
errorRow.style.marginTop = '8px';
|
||||
|
||||
const errorLabel = document.createElement('div');
|
||||
errorLabel.style.minWidth = '180px';
|
||||
const errorStrong = document.createElement('strong');
|
||||
errorStrong.textContent = 'Error';
|
||||
errorStrong.style.color = 'var(--danger)';
|
||||
errorLabel.appendChild(errorStrong);
|
||||
|
||||
const errorValue = document.createElement('div');
|
||||
errorValue.textContent = data.error;
|
||||
errorValue.style.color = 'var(--danger)';
|
||||
errorValue.style.fontFamily = 'monospace';
|
||||
errorValue.style.fontSize = '12px';
|
||||
errorValue.style.wordBreak = 'break-word';
|
||||
|
||||
errorRow.appendChild(errorLabel);
|
||||
errorRow.appendChild(errorValue);
|
||||
el.validatorMcpOutput.appendChild(errorRow);
|
||||
}
|
||||
|
||||
const details = [
|
||||
['MCP Server Path', data.mcpServerPath || '—'],
|
||||
['Validation Script', data.validationScriptPath || '—'],
|
||||
['Test Plugin Path', data.testPluginPath || '—'],
|
||||
['Tool Invoked', data.toolInvoked ? 'Yes' : 'No'],
|
||||
['Response Valid', data.responseValid ? 'Yes' : 'No'],
|
||||
];
|
||||
|
||||
details.forEach(([label, value]) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'admin-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.style.minWidth = '180px';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = label;
|
||||
labelWrap.appendChild(strong);
|
||||
const valueWrap = document.createElement('div');
|
||||
valueWrap.textContent = value;
|
||||
row.appendChild(labelWrap);
|
||||
row.appendChild(valueWrap);
|
||||
el.validatorMcpOutput.appendChild(row);
|
||||
});
|
||||
|
||||
if (data.validationResult) {
|
||||
const resultSection = document.createElement('div');
|
||||
resultSection.style.marginTop = '12px';
|
||||
resultSection.style.padding = '12px';
|
||||
resultSection.style.background = 'var(--bg-subtle)';
|
||||
resultSection.style.borderRadius = '6px';
|
||||
|
||||
const resultHeader = document.createElement('div');
|
||||
resultHeader.style.fontWeight = '600';
|
||||
resultHeader.style.marginBottom = '8px';
|
||||
resultHeader.textContent = 'Validation Result';
|
||||
resultSection.appendChild(resultHeader);
|
||||
|
||||
const resultPre = document.createElement('pre');
|
||||
resultPre.style.margin = '0';
|
||||
resultPre.style.padding = '8px';
|
||||
resultPre.style.background = 'var(--bg)';
|
||||
resultPre.style.borderRadius = '4px';
|
||||
resultPre.style.fontSize = '11px';
|
||||
resultPre.style.overflow = 'auto';
|
||||
resultPre.style.maxHeight = '200px';
|
||||
resultPre.textContent = typeof data.validationResult === 'string'
|
||||
? data.validationResult
|
||||
: JSON.stringify(data.validationResult, null, 2);
|
||||
resultSection.appendChild(resultPre);
|
||||
|
||||
el.validatorMcpOutput.appendChild(resultSection);
|
||||
}
|
||||
}
|
||||
|
||||
function renderExternalTestingConfig(config) {
|
||||
if (!el.externalTestingConfig) return;
|
||||
el.externalTestingConfig.innerHTML = '';
|
||||
@@ -357,6 +465,66 @@
|
||||
renderExternalTestingConfig(data.config || {});
|
||||
}
|
||||
|
||||
function renderSystemTestsOutput(data) {
|
||||
if (!el.systemTestsOutput) return;
|
||||
el.systemTestsOutput.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const summary = data.summary || { total: 0, passed: 0, failed: 0, skipped: 0 };
|
||||
const summaryRow = document.createElement('div');
|
||||
summaryRow.className = 'admin-row';
|
||||
const summaryLabel = document.createElement('div');
|
||||
summaryLabel.style.minWidth = '180px';
|
||||
summaryLabel.style.color = 'var(--muted)';
|
||||
summaryLabel.textContent = 'Summary';
|
||||
const summaryValue = document.createElement('div');
|
||||
summaryValue.textContent = `${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped (${data.durationMs || 0}ms)`;
|
||||
summaryRow.appendChild(summaryLabel);
|
||||
summaryRow.appendChild(summaryValue);
|
||||
el.systemTestsOutput.appendChild(summaryRow);
|
||||
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
results.forEach((result) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'admin-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.style.minWidth = '180px';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = result.name || 'Check';
|
||||
labelWrap.appendChild(strong);
|
||||
|
||||
const valueWrap = document.createElement('div');
|
||||
const status = document.createElement('span');
|
||||
const statusText = (result.status || 'unknown').toUpperCase();
|
||||
status.textContent = statusText;
|
||||
if (result.status === 'passed') status.style.color = 'var(--shopify-green)';
|
||||
else if (result.status === 'failed') status.style.color = 'var(--danger)';
|
||||
else status.style.color = 'var(--muted)';
|
||||
|
||||
valueWrap.appendChild(status);
|
||||
if (result.details) {
|
||||
const sep = document.createElement('span');
|
||||
sep.textContent = ' - ';
|
||||
valueWrap.appendChild(sep);
|
||||
const details = document.createElement('span');
|
||||
details.textContent = result.details;
|
||||
valueWrap.appendChild(details);
|
||||
}
|
||||
|
||||
if (typeof result.durationMs === 'number') {
|
||||
const timing = document.createElement('span');
|
||||
timing.textContent = ` (${result.durationMs}ms)`;
|
||||
timing.style.color = 'var(--muted)';
|
||||
timing.style.marginLeft = '4px';
|
||||
valueWrap.appendChild(timing);
|
||||
}
|
||||
|
||||
row.appendChild(labelWrap);
|
||||
row.appendChild(valueWrap);
|
||||
el.systemTestsOutput.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ollama Test UI ---
|
||||
function setOllamaTestStatus(msg, isError = false) {
|
||||
if (!el.ollamaTestStatus) return;
|
||||
@@ -626,17 +794,22 @@
|
||||
function renderOpencodeModels() {
|
||||
if (!el.opencodeModelsList) return;
|
||||
el.opencodeModelsList.innerHTML = '';
|
||||
if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = state.opencodeModels.length.toString();
|
||||
|
||||
if (!state.opencodeModels.length) {
|
||||
const availableModels = state.opencodeModels.filter((m) => m.available !== false);
|
||||
const unavailableModels = state.opencodeModels.filter((m) => m.available === false);
|
||||
|
||||
if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = availableModels.length.toString();
|
||||
|
||||
if (!availableModels.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'muted';
|
||||
empty.textContent = 'No OpenCode models configured. Add models to enable fallback chain for build execution.';
|
||||
el.opencodeModelsList.appendChild(empty);
|
||||
renderUnavailableModels(unavailableModels);
|
||||
return;
|
||||
}
|
||||
|
||||
state.opencodeModels.forEach((m, idx) => {
|
||||
availableModels.forEach((m, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'provider-row slim';
|
||||
|
||||
@@ -696,10 +869,11 @@
|
||||
upBtn.title = 'Move up';
|
||||
upBtn.disabled = idx === 0;
|
||||
upBtn.addEventListener('click', async () => {
|
||||
const next = [...state.opencodeModels];
|
||||
const next = [...availableModels];
|
||||
const [item] = next.splice(idx, 1);
|
||||
next.splice(Math.max(0, idx - 1), 0, item);
|
||||
await persistOpencodeModelsOrder(next);
|
||||
const fullNext = [...next, ...unavailableModels];
|
||||
await persistOpencodeModelsOrder(fullNext);
|
||||
});
|
||||
headerActions.appendChild(upBtn);
|
||||
|
||||
@@ -708,12 +882,13 @@
|
||||
downBtn.className = 'ghost';
|
||||
downBtn.textContent = '↓';
|
||||
downBtn.title = 'Move down';
|
||||
downBtn.disabled = idx === state.opencodeModels.length - 1;
|
||||
downBtn.disabled = idx === availableModels.length - 1;
|
||||
downBtn.addEventListener('click', async () => {
|
||||
const next = [...state.opencodeModels];
|
||||
const next = [...availableModels];
|
||||
const [item] = next.splice(idx, 1);
|
||||
next.splice(Math.min(state.opencodeModels.length, idx + 1), 0, item);
|
||||
await persistOpencodeModelsOrder(next);
|
||||
next.splice(Math.min(availableModels.length, idx + 1), 0, item);
|
||||
const fullNext = [...next, ...unavailableModels];
|
||||
await persistOpencodeModelsOrder(fullNext);
|
||||
});
|
||||
headerActions.appendChild(downBtn);
|
||||
|
||||
@@ -806,6 +981,114 @@
|
||||
row.appendChild(header);
|
||||
el.opencodeModelsList.appendChild(row);
|
||||
});
|
||||
|
||||
renderUnavailableModels(unavailableModels);
|
||||
}
|
||||
|
||||
// Render unavailable models list with re-add option
|
||||
function renderUnavailableModels(models) {
|
||||
if (!el.unavailableModelsList || !el.unavailableModelsSection) return;
|
||||
|
||||
if (!models.length) {
|
||||
el.unavailableModelsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
el.unavailableModelsSection.style.display = 'block';
|
||||
el.unavailableModelsList.innerHTML = '';
|
||||
|
||||
if (el.unavailableModelsCount) {
|
||||
el.unavailableModelsCount.textContent = models.length.toString();
|
||||
}
|
||||
|
||||
models.forEach((m) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'provider-row slim unavailable-model';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'provider-row-header';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'model-chip';
|
||||
|
||||
// Unavailable badge
|
||||
const unavailableBadge = document.createElement('span');
|
||||
unavailableBadge.className = 'pill';
|
||||
unavailableBadge.style.background = 'var(--danger)';
|
||||
unavailableBadge.textContent = 'Unavailable';
|
||||
info.appendChild(unavailableBadge);
|
||||
|
||||
if (m.icon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = m.icon;
|
||||
img.alt = '';
|
||||
img.style.filter = 'grayscale(100%)';
|
||||
info.appendChild(img);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = m.label || m.name;
|
||||
label.style.opacity = '0.6';
|
||||
info.appendChild(label);
|
||||
|
||||
const namePill = document.createElement('span');
|
||||
namePill.className = 'pill';
|
||||
namePill.textContent = m.name;
|
||||
namePill.style.opacity = '0.6';
|
||||
info.appendChild(namePill);
|
||||
|
||||
if (m.unavailableSince) {
|
||||
const sincePill = document.createElement('span');
|
||||
sincePill.className = 'pill';
|
||||
sincePill.textContent = `Since ${new Date(m.unavailableSince).toLocaleDateString()}`;
|
||||
info.appendChild(sincePill);
|
||||
}
|
||||
|
||||
header.appendChild(info);
|
||||
|
||||
const headerActions = document.createElement('div');
|
||||
headerActions.className = 'provider-row-actions';
|
||||
|
||||
// Re-add button
|
||||
const readdBtn = document.createElement('button');
|
||||
readdBtn.className = 'primary';
|
||||
readdBtn.textContent = 'Re-add';
|
||||
readdBtn.addEventListener('click', async () => {
|
||||
readdBtn.disabled = true;
|
||||
readdBtn.textContent = 'Checking...';
|
||||
try {
|
||||
await api(`/api/admin/models/${m.id}/readd`, { method: 'POST' });
|
||||
await loadConfigured();
|
||||
setStatus('Model re-added to fallback chain');
|
||||
setTimeout(() => setStatus(''), 2000);
|
||||
} catch (err) {
|
||||
setStatus(err.message, true);
|
||||
readdBtn.disabled = false;
|
||||
readdBtn.textContent = 'Re-add';
|
||||
}
|
||||
});
|
||||
headerActions.appendChild(readdBtn);
|
||||
|
||||
// Delete button
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'ghost';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
delBtn.disabled = true;
|
||||
try {
|
||||
await api(`/api/admin/models/${m.id}?type=opencode`, { method: 'DELETE' });
|
||||
await loadConfigured();
|
||||
} catch (err) {
|
||||
setStatus(err.message, true);
|
||||
}
|
||||
delBtn.disabled = false;
|
||||
});
|
||||
headerActions.appendChild(delBtn);
|
||||
|
||||
header.appendChild(headerActions);
|
||||
row.appendChild(header);
|
||||
el.unavailableModelsList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Render Public models list with up/down ordering
|
||||
@@ -1566,11 +1849,6 @@
|
||||
renderAvailable();
|
||||
syncAvailableModelDatalist();
|
||||
|
||||
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
||||
? state.planSettings.freePlanModel
|
||||
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
||||
populateAutoModelOptions(selectedAutoModel);
|
||||
|
||||
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
|
||||
}
|
||||
|
||||
@@ -1588,11 +1866,6 @@
|
||||
state.configured = data.models || []; // Legacy support
|
||||
renderOpencodeModels();
|
||||
renderPublicModels();
|
||||
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
||||
? state.planSettings.freePlanModel
|
||||
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
||||
populateAutoModelOptions(selectedAutoModel);
|
||||
populateFreePlanModelOptions(selectedAutoModel);
|
||||
syncAvailableModelDatalist();
|
||||
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
|
||||
}
|
||||
@@ -1678,7 +1951,6 @@
|
||||
if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? '';
|
||||
if (el.limitRph) el.limitRph.value = target.requestsPerHour ?? '';
|
||||
if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? '';
|
||||
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
|
||||
}
|
||||
|
||||
async function loadProviderLimits() {
|
||||
@@ -1695,12 +1967,9 @@
|
||||
if (!state.providerLimits[p]) state.providerLimits[p] = {};
|
||||
});
|
||||
state.providerUsage = data.usage || [];
|
||||
state.opencodeBackupModel = data.opencodeBackupModel || '';
|
||||
renderProviderOptions();
|
||||
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider');
|
||||
renderProviderUsage();
|
||||
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
|
||||
populateOpencodeBackupOptions(state.opencodeBackupModel);
|
||||
// refresh datalist with provider-specific models
|
||||
syncAvailableModelDatalist();
|
||||
renderPlanPriority();
|
||||
@@ -1836,187 +2105,22 @@
|
||||
}
|
||||
|
||||
async function loadPlanProviderSettings() {
|
||||
if (!el.planProviderForm && !el.autoModelForm && !el.planPriorityList) return;
|
||||
if (!el.planProviderForm && !el.planPriorityList) return;
|
||||
try {
|
||||
const data = await api('/api/admin/plan-settings');
|
||||
state.planSettings = {
|
||||
provider: 'openrouter',
|
||||
freePlanModel: '',
|
||||
planningChain: [],
|
||||
...(data || {}),
|
||||
};
|
||||
if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter';
|
||||
populateAutoModelOptions(state.planSettings.freePlanModel || '');
|
||||
populateFreePlanModelOptions(state.planSettings.freePlanModel || '');
|
||||
renderPlanPriority();
|
||||
} catch (err) {
|
||||
if (el.planProviderForm) setPlanProviderStatus(err.message, true);
|
||||
if (el.autoModelForm) setAutoModelStatus(err.message, true);
|
||||
if (el.planPriorityList) setPlanChainStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function populateAutoModelOptions(selectedValue) {
|
||||
if (!el.autoModelSelect) return;
|
||||
|
||||
const normalizeTier = (tier) => {
|
||||
const normalized = String(tier || 'free').trim().toLowerCase();
|
||||
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
||||
};
|
||||
|
||||
// Use publicModels if available, fallback to configured for legacy support
|
||||
const configured = state.publicModels.length > 0 ? state.publicModels : (Array.isArray(state.configured) ? state.configured : []);
|
||||
const configuredByName = new Map();
|
||||
configured.forEach((m) => {
|
||||
const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '';
|
||||
if (name) configuredByName.set(name, m);
|
||||
});
|
||||
|
||||
const current = typeof selectedValue === 'string' ? selectedValue : el.autoModelSelect.value;
|
||||
|
||||
el.autoModelSelect.innerHTML = '';
|
||||
const auto = document.createElement('option');
|
||||
auto.value = '';
|
||||
auto.textContent = 'Auto (first free model)';
|
||||
el.autoModelSelect.appendChild(auto);
|
||||
|
||||
const freeModels = configured
|
||||
.filter((m) => normalizeTier(m.tier) === 'free')
|
||||
.map((m) => ({
|
||||
name: (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '',
|
||||
label: (m && (m.label || m.name || m.id)) ? String(m.label || m.name || m.id).trim() : '',
|
||||
}))
|
||||
.filter((m) => m.name);
|
||||
|
||||
const freeGroup = document.createElement('optgroup');
|
||||
freeGroup.label = 'Free-tier models';
|
||||
|
||||
const freeNames = new Set();
|
||||
freeModels
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.forEach((m) => {
|
||||
freeNames.add(m.name);
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name;
|
||||
opt.textContent = m.label || m.name;
|
||||
freeGroup.appendChild(opt);
|
||||
});
|
||||
|
||||
const discoveredNames = getAvailableModelNames()
|
||||
.map((name) => String(name || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((name, idx, arr) => arr.indexOf(name) === idx)
|
||||
.filter((name) => !freeNames.has(name));
|
||||
|
||||
const discoveredGroup = document.createElement('optgroup');
|
||||
discoveredGroup.label = 'Other discovered models';
|
||||
|
||||
discoveredNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((name) => {
|
||||
const configuredModel = configuredByName.get(name);
|
||||
const tier = configuredModel ? normalizeTier(configuredModel.tier) : null;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
if (!configuredModel) {
|
||||
opt.textContent = `${name} (unpublished)`;
|
||||
} else {
|
||||
opt.textContent = `${name} (${tier.toUpperCase()})`;
|
||||
if (tier !== 'free') opt.disabled = true;
|
||||
}
|
||||
discoveredGroup.appendChild(opt);
|
||||
});
|
||||
|
||||
const hasFree = freeGroup.children.length > 0;
|
||||
const hasDiscovered = discoveredGroup.children.length > 0;
|
||||
|
||||
if (hasFree) {
|
||||
el.autoModelSelect.appendChild(freeGroup);
|
||||
}
|
||||
|
||||
if (hasDiscovered) {
|
||||
el.autoModelSelect.appendChild(discoveredGroup);
|
||||
}
|
||||
|
||||
if (!hasFree && !hasDiscovered) {
|
||||
const note = document.createElement('option');
|
||||
note.value = '__none__';
|
||||
note.textContent = '(No models discovered yet)';
|
||||
note.disabled = true;
|
||||
el.autoModelSelect.appendChild(note);
|
||||
}
|
||||
|
||||
const currentName = (current || '').trim();
|
||||
if (currentName && !Array.from(el.autoModelSelect.options).some((opt) => opt.value === currentName)) {
|
||||
const orphan = document.createElement('option');
|
||||
orphan.value = currentName;
|
||||
orphan.textContent = `${currentName} (current selection)`;
|
||||
el.autoModelSelect.appendChild(orphan);
|
||||
}
|
||||
|
||||
el.autoModelSelect.value = currentName;
|
||||
}
|
||||
|
||||
function populateFreePlanModelOptions(selectedValue) {
|
||||
if (!el.freePlanModel) return;
|
||||
const current = selectedValue || el.freePlanModel.value;
|
||||
el.freePlanModel.innerHTML = '';
|
||||
const auto = document.createElement('option');
|
||||
auto.value = '';
|
||||
auto.textContent = 'Auto (use default)';
|
||||
el.freePlanModel.appendChild(auto);
|
||||
(state.configured || []).forEach((m) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name || m.id || '';
|
||||
opt.textContent = m.label || m.name || m.id || '';
|
||||
el.freePlanModel.appendChild(opt);
|
||||
});
|
||||
if (current !== undefined && current !== null) {
|
||||
el.freePlanModel.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
function populateOpencodeBackupOptions(selectedValue) {
|
||||
console.log('populateOpencodeBackupOptions called with:', selectedValue);
|
||||
if (!el.opencodeBackup) {
|
||||
console.log('el.opencodeBackup is null, returning early');
|
||||
return;
|
||||
}
|
||||
console.log('el.opencodeBackup found, populating...');
|
||||
const current = selectedValue || el.opencodeBackup.value;
|
||||
el.opencodeBackup.innerHTML = '';
|
||||
|
||||
const allModels = new Set();
|
||||
(state.available || []).forEach((m) => {
|
||||
const name = m.name || m.id || m;
|
||||
if (name) allModels.add(name);
|
||||
});
|
||||
(state.configured || []).forEach((m) => {
|
||||
if (m.name) allModels.add(m.name);
|
||||
});
|
||||
Object.values(state.providerModels || {}).forEach((arr) => {
|
||||
(arr || []).forEach((name) => { if (name) allModels.add(name); });
|
||||
});
|
||||
|
||||
console.log('Found models:', Array.from(allModels));
|
||||
const sorted = Array.from(allModels).filter(Boolean).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const none = document.createElement('option');
|
||||
none.value = '';
|
||||
none.textContent = 'None (no backup)';
|
||||
el.opencodeBackup.appendChild(none);
|
||||
|
||||
sorted.forEach((name) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
el.opencodeBackup.appendChild(opt);
|
||||
});
|
||||
|
||||
if (current) el.opencodeBackup.value = current;
|
||||
console.log('Dropdown populated with', sorted.length + 1, 'options');
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
@@ -2532,7 +2636,7 @@
|
||||
const loaders = [
|
||||
() => ((el.availableModels || el.planPriorityList) ? loadAvailable() : null),
|
||||
() => ((el.iconSelect || el.iconList) ? loadIcons() : null),
|
||||
() => (el.configuredList ? loadConfigured() : null),
|
||||
() => ((el.opencodeModelsList || el.publicModelsList) ? loadConfigured() : null),
|
||||
() => (el.orForm ? loadOpenRouterSettings() : null),
|
||||
() => (el.mistralForm ? loadMistralSettings() : null),
|
||||
() => ((el.autoModelForm || el.planProviderForm || el.planPriorityList) ? loadPlanProviderSettings() : null),
|
||||
@@ -2627,16 +2731,11 @@
|
||||
if (el.publicModelForm) {
|
||||
el.publicModelForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = el.publicModelName.value.trim();
|
||||
const label = el.publicModelLabel.value.trim();
|
||||
const icon = el.publicModelIcon ? el.publicModelIcon.value : '';
|
||||
const tier = el.publicModelTier ? el.publicModelTier.value : 'free';
|
||||
const supportsMedia = el.publicModelMedia ? el.publicModelMedia.checked : false;
|
||||
|
||||
if (!name) {
|
||||
setPublicModelStatus('Model ID is required.', true);
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
setPublicModelStatus('Display name is required.', true);
|
||||
return;
|
||||
@@ -2648,7 +2747,6 @@
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'public',
|
||||
name,
|
||||
label,
|
||||
icon,
|
||||
tier,
|
||||
@@ -2656,7 +2754,6 @@
|
||||
}),
|
||||
});
|
||||
setPublicModelStatus('Saved');
|
||||
el.publicModelName.value = '';
|
||||
el.publicModelLabel.value = '';
|
||||
await loadConfigured();
|
||||
} catch (err) {
|
||||
@@ -2750,47 +2847,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (el.opencodeBackupForm) {
|
||||
el.opencodeBackupForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const opencodeBackupModel = el.opencodeBackup ? el.opencodeBackup.value.trim() : '';
|
||||
|
||||
setOpencodeBackupStatus('Saving...');
|
||||
try {
|
||||
const res = await api('/api/admin/provider-limits', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'opencode', scope: 'provider', model: '', tokensPerMinute: '', tokensPerDay: '', requestsPerMinute: '', requestsPerDay: '', opencodeBackupModel }),
|
||||
});
|
||||
// update local state and refresh dropdowns
|
||||
state.opencodeBackupModel = res.opencodeBackupModel || opencodeBackupModel || '';
|
||||
populateOpencodeBackupOptions(state.opencodeBackupModel);
|
||||
setOpencodeBackupStatus('Saved');
|
||||
setTimeout(() => setOpencodeBackupStatus(''), 3000);
|
||||
} catch (err) {
|
||||
setOpencodeBackupStatus(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (el.autoModelForm) {
|
||||
el.autoModelForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const freePlanModel = el.autoModelSelect ? el.autoModelSelect.value.trim() : '';
|
||||
|
||||
setAutoModelStatus('Saving...');
|
||||
try {
|
||||
await api('/api/admin/plan-settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ freePlanModel }),
|
||||
});
|
||||
setAutoModelStatus('Saved! Free plan users will use this model.');
|
||||
setTimeout(() => setAutoModelStatus(''), 3000);
|
||||
} catch (err) {
|
||||
setAutoModelStatus(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (el.planProviderForm) {
|
||||
el.planProviderForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2831,14 +2887,13 @@
|
||||
const payload = {
|
||||
provider,
|
||||
scope,
|
||||
model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(),
|
||||
tokensPerMinute: Number(el.limitTpm.value || 0),
|
||||
tokensPerHour: Number(el.limitTph.value || 0),
|
||||
tokensPerDay: Number(el.limitTpd.value || 0),
|
||||
requestsPerMinute: Number(el.limitRpm.value || 0),
|
||||
requestsPerHour: Number(el.limitRph.value || 0),
|
||||
requestsPerDay: Number(el.limitRpd.value || 0),
|
||||
opencodeBackupModel: el.limitBackup ? el.limitBackup.value.trim() : '',
|
||||
model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : (el.limitModel?.value?.trim() || ''),
|
||||
tokensPerMinute: Number(el.limitTpm?.value || 0),
|
||||
tokensPerHour: Number(el.limitTph?.value || 0),
|
||||
tokensPerDay: Number(el.limitTpd?.value || 0),
|
||||
requestsPerMinute: Number(el.limitRpm?.value || 0),
|
||||
requestsPerHour: Number(el.limitRph?.value || 0),
|
||||
requestsPerDay: Number(el.limitRpd?.value || 0),
|
||||
};
|
||||
setProviderLimitStatus('Saving...');
|
||||
try {
|
||||
@@ -2955,6 +3010,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (el.systemTestsRun) {
|
||||
el.systemTestsRun.addEventListener('click', async () => {
|
||||
el.systemTestsRun.disabled = true;
|
||||
setSystemTestsStatus('Running system tests...');
|
||||
if (el.systemTestsOutput) el.systemTestsOutput.innerHTML = '';
|
||||
try {
|
||||
const data = await api('/api/admin/system-tests', { method: 'POST' });
|
||||
renderSystemTestsOutput(data || null);
|
||||
setSystemTestsStatus(data && data.ok ? 'System tests passed.' : 'System tests completed with failures.', !data || !data.ok);
|
||||
} catch (err) {
|
||||
setSystemTestsStatus(err.message || 'System tests failed.', true);
|
||||
} finally {
|
||||
el.systemTestsRun.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ollama Test button handler
|
||||
if (el.ollamaTestRun) {
|
||||
el.ollamaTestRun.addEventListener('click', async () => {
|
||||
@@ -2982,6 +3054,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
// WordPress Validator MCP Test button handler
|
||||
if (el.validatorMcpTestRun) {
|
||||
el.validatorMcpTestRun.addEventListener('click', async () => {
|
||||
el.validatorMcpTestRun.disabled = true;
|
||||
setValidatorMcpStatus('Testing WordPress Validator MCP...');
|
||||
if (el.validatorMcpOutput) el.validatorMcpOutput.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await api('/api/admin/validator-mcp-test', { method: 'POST' });
|
||||
renderValidatorMcpOutput(data);
|
||||
|
||||
if (data.ok) {
|
||||
setValidatorMcpStatus(`Test passed! (${data.durationMs}ms)`);
|
||||
} else {
|
||||
setValidatorMcpStatus(`Test failed: ${data.error || 'Unknown error'}`, true);
|
||||
}
|
||||
} catch (err) {
|
||||
setValidatorMcpStatus(err.message || 'Test failed', true);
|
||||
if (el.validatorMcpOutput) {
|
||||
renderValidatorMcpOutput({ ok: false, error: err.message || 'Request failed' });
|
||||
}
|
||||
} finally {
|
||||
el.validatorMcpTestRun.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (el.logout) {
|
||||
el.logout.addEventListener('click', async () => {
|
||||
await api('/api/admin/logout', { method: 'POST' }).catch(() => { });
|
||||
|
||||
@@ -1429,66 +1429,93 @@ function classifyStatusMessage(msg) {
|
||||
|
||||
if (!text) return { userText: '', adminText: '' };
|
||||
|
||||
// Mask all OpenCode, provider, and internal error messages with branded message
|
||||
const internalErrorPatterns = [
|
||||
/opencode/i,
|
||||
/openrouter/i,
|
||||
/mistral/i,
|
||||
/anthropic/i,
|
||||
/error:.*\d{3}/i,
|
||||
/exit code/i,
|
||||
/stderr/i,
|
||||
/stdout/i,
|
||||
/provider.*error/i,
|
||||
/api.*error/i,
|
||||
/rate limit/i,
|
||||
/quota/i,
|
||||
/insufficient/i,
|
||||
/unauthorized/i,
|
||||
/forbidden/i,
|
||||
/internal server error/i,
|
||||
/service unavailable/i,
|
||||
/gateway/i,
|
||||
/timeout/i,
|
||||
/connection.*refused/i,
|
||||
/connection.*lost/i,
|
||||
/process.*exited/i,
|
||||
/tool.*call.*format/i,
|
||||
/malformed/i,
|
||||
/invalid.*edit/i,
|
||||
/proper prefixing/i,
|
||||
/session terminated/i,
|
||||
/early termination/i,
|
||||
/model.*not found/i,
|
||||
/unknown model/i,
|
||||
/context length/i,
|
||||
/token limit/i,
|
||||
];
|
||||
|
||||
const isInternalError = internalErrorPatterns.some(pattern => pattern.test(text));
|
||||
|
||||
if (isInternalError) {
|
||||
return {
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.startsWith('no models configured')) {
|
||||
return {
|
||||
userText: 'No models are configured. Please contact support.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.startsWith('model load failed:')) {
|
||||
return {
|
||||
userText: 'Models are currently unavailable. Please contact support.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.startsWith('planning failed:')) {
|
||||
return {
|
||||
userText: 'Planning is currently unavailable. Please contact support.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
// Surface missing provider API keys to the user with actionable text
|
||||
if (lower.includes('missing provider api keys') || lower.includes('no configured planning providers')) {
|
||||
// Try to extract provider list from the message if present
|
||||
const m = text.match(/Missing provider API keys:\s*([^\n\r]+)/i);
|
||||
const providers = m ? m[1].trim() : null;
|
||||
return {
|
||||
userText: providers
|
||||
? `Planning unavailable: missing API keys for ${providers}. Please set the environment variables (e.g. GROQ_API_KEY) or configure providers in Admin.`
|
||||
: 'Planning unavailable: missing provider API keys. Please check server configuration.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('openrouter api key') || lower.includes('openrouter request failed')) {
|
||||
return {
|
||||
userText: 'Planning is currently unavailable. Please contact support.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.startsWith('warning: opencode cli not available')) {
|
||||
return {
|
||||
userText: 'Builder service is currently unavailable. Please contact support.',
|
||||
userText: 'Plugin Compass failed. Please try again.',
|
||||
adminText: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('proper prefixing') ||
|
||||
lower.includes('tool call format') ||
|
||||
lower.includes('tool call prefix') ||
|
||||
lower.includes('session terminated') ||
|
||||
lower.includes('early termination')) {
|
||||
return {
|
||||
userText: 'Connection interrupted. Resuming...',
|
||||
adminText: `Early termination detected: ${text}`,
|
||||
type: 'warning'
|
||||
};
|
||||
}
|
||||
|
||||
return { userText: text, adminText: '' };
|
||||
}
|
||||
|
||||
@@ -2152,14 +2179,16 @@ function renderMessages(session) {
|
||||
const err = document.createElement('div');
|
||||
err.className = 'body';
|
||||
err.style.color = 'var(--danger)';
|
||||
err.textContent = msg.error;
|
||||
const { userText: maskedError } = classifyStatusMessage(msg.error);
|
||||
err.textContent = maskedError;
|
||||
assistantCard.appendChild(err);
|
||||
}
|
||||
if ((!msg.reply || !msg.reply.length) && (!msg.partialOutput || !msg.partialOutput.length) && msg.opencodeSummary) {
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'body';
|
||||
summary.style.color = 'var(--muted)';
|
||||
summary.textContent = `Opencode output: ${msg.opencodeSummary}`;
|
||||
const { userText: maskedSummary } = classifyStatusMessage(msg.opencodeSummary);
|
||||
summary.textContent = maskedSummary;
|
||||
assistantCard.appendChild(summary);
|
||||
}
|
||||
|
||||
@@ -3161,7 +3190,9 @@ function streamMessage(sessionId, messageId) {
|
||||
// Update session list (no-op in builder)
|
||||
renderSessions();
|
||||
} else if (data.type === 'error') {
|
||||
message.error = data.error || 'Unknown error';
|
||||
const rawError = data.error || 'Unknown error';
|
||||
const { userText: maskedError } = classifyStatusMessage(rawError);
|
||||
message.error = maskedError;
|
||||
message.reply = data.content || message.partialOutput || '';
|
||||
message.status = 'error';
|
||||
message.finishedAt = data.timestamp;
|
||||
@@ -3181,7 +3212,7 @@ function streamMessage(sessionId, messageId) {
|
||||
scrollChatToBottom();
|
||||
|
||||
if (!message.isBackgroundContinuation) {
|
||||
setStatus('Error: ' + (data.error || 'Unknown error'));
|
||||
setStatus(rawError);
|
||||
}
|
||||
|
||||
stopUsagePolling();
|
||||
@@ -3650,14 +3681,6 @@ function getBackupModel(currentModel) {
|
||||
const configuredModels = (state.models || []).map(normalize).filter(Boolean);
|
||||
const current = (currentModel || '').trim();
|
||||
|
||||
const preferredBackup = normalize(window.providerLimits?.opencodeBackupModel || '');
|
||||
|
||||
// If we have a preferred backup model, use it if it's different from current
|
||||
// Don't require it to be in configured models since OpenCode CLI can access models directly
|
||||
if (preferredBackup && preferredBackup !== current) {
|
||||
return preferredBackup;
|
||||
}
|
||||
|
||||
if (!configuredModels.length) return null;
|
||||
|
||||
// If current model is auto/default or not in list, pick the first configured model
|
||||
@@ -4829,15 +4852,8 @@ window.addEventListener('focus', () => {
|
||||
|
||||
async function loadProviderLimits() {
|
||||
try {
|
||||
const data = await api('/api/provider-limits');
|
||||
if (data?.opencodeBackupModel) {
|
||||
window.providerLimits = {
|
||||
opencodeBackupModel: data.opencodeBackupModel
|
||||
};
|
||||
console.log('[PROVIDER-LIMITS] Loaded backup model:', window.providerLimits.opencodeBackupModel);
|
||||
}
|
||||
await api('/api/provider-limits');
|
||||
} catch (err) {
|
||||
console.warn('[PROVIDER-LIMITS] Failed to load provider limits:', err);
|
||||
window.providerLimits = window.providerLimits || {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -59,6 +65,23 @@
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.featured-card {
|
||||
background: linear-gradient(135deg, #1e3a2f 0%, #0d1f17 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
.category-tab {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.category-tab.active {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- PostHog Analytics -->
|
||||
@@ -87,17 +110,58 @@
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-gradient-to-br from-brand-600 to-brand-800 text-white py-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4">News & Updates</h1>
|
||||
<p class="text-xl text-brand-100 max-w-2xl mx-auto">
|
||||
<header class="bg-gradient-to-br from-brand-600 via-brand-700 to-brand-900 text-white py-20 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.05\"%3E%3Cpath d=\"M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-sm font-medium mb-6">
|
||||
<i class="fas fa-rss text-brand-200"></i>
|
||||
<span>Latest Updates</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6 leading-tight">News & Updates</h1>
|
||||
<p class="text-xl text-brand-100 max-w-2xl mx-auto leading-relaxed">
|
||||
Stay up to date with the latest features, improvements, and announcements from Plugin Compass
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="bg-white border-b border-gray-200 sticky top-16 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center gap-2 py-4 overflow-x-auto" id="category-tabs">
|
||||
<button class="category-tab active px-4 py-2 rounded-full text-sm font-medium border border-gray-200 whitespace-nowrap" data-category="all">
|
||||
All Posts
|
||||
</button>
|
||||
<button class="category-tab px-4 py-2 rounded-full text-sm font-medium border border-gray-200 text-gray-600 hover:border-gray-300 whitespace-nowrap" data-category="announcements">
|
||||
Announcements
|
||||
</button>
|
||||
<button class="category-tab px-4 py-2 rounded-full text-sm font-medium border border-gray-200 text-gray-600 hover:border-gray-300 whitespace-nowrap" data-category="updates">
|
||||
Updates
|
||||
</button>
|
||||
<button class="category-tab px-4 py-2 rounded-full text-sm font-medium border border-gray-200 text-gray-600 hover:border-gray-300 whitespace-nowrap" data-category="tutorials">
|
||||
Tutorials
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Featured Post Section -->
|
||||
<div id="featured-section" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-12 hidden">
|
||||
<div class="mb-6">
|
||||
<span class="inline-flex items-center gap-2 text-sm font-semibold text-brand-600">
|
||||
<i class="fas fa-star"></i> Featured
|
||||
</span>
|
||||
</div>
|
||||
<div id="featured-post" class="featured-card rounded-2xl overflow-hidden shadow-2xl">
|
||||
<!-- Featured post will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Posts Grid -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div id="posts-header" class="flex items-center justify-between mb-8 hidden">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Recent Posts</h2>
|
||||
<span id="posts-count" class="text-sm text-gray-500"></span>
|
||||
</div>
|
||||
<div id="posts-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Posts will be loaded here -->
|
||||
</div>
|
||||
@@ -164,6 +228,8 @@
|
||||
|
||||
<script>
|
||||
let posts = [];
|
||||
let filteredPosts = [];
|
||||
let currentCategory = 'all';
|
||||
let offset = 0;
|
||||
let limit = 9;
|
||||
let loading = false;
|
||||
@@ -173,8 +239,100 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPosts();
|
||||
setupInfiniteScroll();
|
||||
setupCategoryTabs();
|
||||
});
|
||||
|
||||
// Setup category tabs
|
||||
function setupCategoryTabs() {
|
||||
const tabs = document.querySelectorAll('.category-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentCategory = tab.dataset.category;
|
||||
filterAndRenderPosts();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter and render posts
|
||||
function filterAndRenderPosts() {
|
||||
const container = document.getElementById('posts-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (currentCategory === 'all') {
|
||||
filteredPosts = [...posts];
|
||||
} else {
|
||||
filteredPosts = posts.filter(p =>
|
||||
(p.category || '').toLowerCase() === currentCategory.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Update posts count
|
||||
document.getElementById('posts-header').classList.remove('hidden');
|
||||
document.getElementById('posts-count').textContent = `${filteredPosts.length} article${filteredPosts.length !== 1 ? 's' : ''}`;
|
||||
|
||||
// Show/hide featured section
|
||||
const featuredSection = document.getElementById('featured-section');
|
||||
if (currentCategory === 'all' && filteredPosts.length > 0) {
|
||||
renderFeaturedPost(filteredPosts[0]);
|
||||
featuredSection.classList.remove('hidden');
|
||||
renderPosts(filteredPosts.slice(1));
|
||||
} else {
|
||||
featuredSection.classList.add('hidden');
|
||||
renderPosts(filteredPosts);
|
||||
}
|
||||
|
||||
// Hide no-more-posts if we have results
|
||||
if (filteredPosts.length > 0) {
|
||||
document.getElementById('no-more-posts').classList.add('hidden');
|
||||
document.getElementById('empty-state').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Render featured post
|
||||
function renderFeaturedPost(post) {
|
||||
const container = document.getElementById('featured-post');
|
||||
const readTime = calculateReadTime(post.content);
|
||||
|
||||
container.innerHTML = `
|
||||
<a href="/blog/${escapeHtml(post.slug)}" class="block md:flex">
|
||||
<div class="md:w-1/2 h-64 md:h-auto relative overflow-hidden">
|
||||
${post.featured_image
|
||||
? `<img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="w-full h-full object-cover">`
|
||||
: `<div class="w-full h-full min-h-[300px] bg-gradient-to-br from-brand-400/20 to-brand-600/20 flex items-center justify-center">
|
||||
<i class="fas fa-newspaper text-6xl text-white/30"></i>
|
||||
</div>`
|
||||
}
|
||||
<div class="absolute top-4 left-4">
|
||||
<span class="px-3 py-1 bg-brand-600 text-white rounded-full text-xs font-semibold uppercase tracking-wide">Featured</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2 p-8 md:p-12 flex flex-col justify-center">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="px-3 py-1 bg-white/10 text-white rounded-full text-xs font-medium">${escapeHtml(post.category || 'News')}</span>
|
||||
<span class="text-brand-200 text-sm flex items-center gap-1">
|
||||
<i class="far fa-clock"></i> ${readTime} min read
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-white mb-4 leading-tight">${escapeHtml(post.title)}</h2>
|
||||
<p class="text-brand-100 mb-6 line-clamp-3">${escapeHtml(post.excerpt || '')}</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white font-medium text-sm">${escapeHtml(post.author || 'Plugin Compass Team')}</p>
|
||||
<p class="text-brand-300 text-xs">${formatDate(post.published_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// Load posts
|
||||
async function loadPosts() {
|
||||
if (loading || !hasMore) return;
|
||||
@@ -188,13 +346,14 @@
|
||||
|
||||
if (data.posts && data.posts.length > 0) {
|
||||
posts = [...posts, ...data.posts];
|
||||
renderPosts(data.posts);
|
||||
offset += data.posts.length;
|
||||
|
||||
if (data.posts.length < limit) {
|
||||
hasMore = false;
|
||||
document.getElementById('no-more-posts').classList.remove('hidden');
|
||||
}
|
||||
|
||||
filterAndRenderPosts();
|
||||
} else {
|
||||
hasMore = false;
|
||||
if (posts.length === 0) {
|
||||
@@ -212,10 +371,10 @@
|
||||
}
|
||||
|
||||
// Render posts
|
||||
function renderPosts(newPosts) {
|
||||
function renderPosts(postsToRender) {
|
||||
const container = document.getElementById('posts-container');
|
||||
|
||||
newPosts.forEach(post => {
|
||||
postsToRender.forEach(post => {
|
||||
const card = createPostCard(post);
|
||||
container.appendChild(card);
|
||||
});
|
||||
@@ -224,10 +383,11 @@
|
||||
// Create post card element
|
||||
function createPostCard(post) {
|
||||
const article = document.createElement('article');
|
||||
article.className = 'bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow fade-in';
|
||||
article.className = 'bg-white rounded-xl shadow-md overflow-hidden card-hover fade-in border border-gray-100';
|
||||
|
||||
const readTime = calculateReadTime(post.content);
|
||||
const imageHtml = post.featured_image
|
||||
? `<div class="h-48 overflow-hidden"><img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="w-full h-full object-cover hover:scale-105 transition-transform duration-300"></div>`
|
||||
? `<div class="h-48 overflow-hidden"><img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="w-full h-full object-cover hover:scale-105 transition-transform duration-500"></div>`
|
||||
: `<div class="h-48 bg-gradient-to-br from-brand-100 to-brand-200 flex items-center justify-center"><i class="fas fa-newspaper text-4xl text-brand-400"></i></div>`;
|
||||
|
||||
article.innerHTML = `
|
||||
@@ -236,18 +396,22 @@
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="px-3 py-1 bg-brand-100 text-brand-700 rounded-full text-xs font-medium">${escapeHtml(post.category || 'News')}</span>
|
||||
<span class="text-gray-400 text-sm">${formatDate(post.published_at)}</span>
|
||||
<span class="text-gray-400 text-xs flex items-center gap-1">
|
||||
<i class="far fa-clock"></i> ${readTime} min
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-2 line-clamp-2 hover:text-brand-600 transition-colors">${escapeHtml(post.title)}</h2>
|
||||
<p class="text-gray-600 line-clamp-3 mb-4">${escapeHtml(post.excerpt || '')}</p>
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-2 line-clamp-2 hover:text-brand-600 transition-colors">${escapeHtml(post.title)}</h2>
|
||||
<p class="text-gray-600 text-sm line-clamp-3 mb-4">${escapeHtml(post.excerpt || '')}</p>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center">
|
||||
<i class="fas fa-user text-brand-600 text-sm"></i>
|
||||
<i class="fas fa-user text-brand-600 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">${escapeHtml(post.author || 'Plugin Compass Team')}</span>
|
||||
<span class="text-xs text-gray-600">${escapeHtml(post.author || 'Plugin Compass Team')}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>${formatDate(post.published_at)}</span>
|
||||
</div>
|
||||
<span class="text-brand-600 font-medium text-sm">Read more →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -256,6 +420,19 @@
|
||||
return article;
|
||||
}
|
||||
|
||||
// Calculate reading time
|
||||
function calculateReadTime(content) {
|
||||
if (!content) return 3;
|
||||
let text = '';
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (content.blocks && Array.isArray(content.blocks)) {
|
||||
text = content.blocks.map(b => b.data?.text || '').join(' ');
|
||||
}
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
|
||||
// Setup infinite scroll
|
||||
function setupInfiniteScroll() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
@@ -282,7 +459,7 @@
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1161,4 +1161,29 @@ textarea:focus {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Unavailable models styling */
|
||||
.unavailable-models-section {
|
||||
background: rgba(176, 0, 32, 0.02);
|
||||
border-color: rgba(176, 0, 32, 0.15);
|
||||
}
|
||||
|
||||
.unavailable-model {
|
||||
opacity: 0.7;
|
||||
filter: blur(0.5px);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.unavailable-model:hover {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.unavailable-model .model-chip {
|
||||
filter: grayscale(30%);
|
||||
}
|
||||
|
||||
.unavailable-model img {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { DATA_ROOT, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || '/home/web/data/.data';
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const USE_JSON_DATABASE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
|
||||
|
||||
async function initializeDatabase() {
|
||||
@@ -33,7 +33,15 @@ async function initializeDatabase() {
|
||||
|
||||
if (dbExists) {
|
||||
console.log('✅ Database already exists:', DATABASE_PATH);
|
||||
|
||||
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (persistedKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
|
||||
// Verify encryption key is set
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set!');
|
||||
@@ -48,6 +56,14 @@ async function initializeDatabase() {
|
||||
console.log('🔧 Database not found, setting up new database...');
|
||||
|
||||
// Generate encryption key if not provided
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (persistedKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
const generatedKey = crypto.randomBytes(32).toString('hex');
|
||||
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
|
||||
@@ -57,7 +73,7 @@ async function initializeDatabase() {
|
||||
console.log('⚠️ Add this to your environment configuration to persist it!');
|
||||
|
||||
// Save to a file for persistence
|
||||
const keyFile = path.join(dataDir, '.encryption_key');
|
||||
const keyFile = KEY_FILE || path.join(dataDir, '.encryption_key');
|
||||
fs.writeFileSync(keyFile, generatedKey, { mode: 0o600 });
|
||||
console.log('⚠️ Saved to:', keyFile);
|
||||
}
|
||||
@@ -86,8 +102,8 @@ async function initializeDatabase() {
|
||||
}
|
||||
|
||||
// Check if there are JSON files to migrate
|
||||
const usersFile = path.join(DATA_ROOT, 'users.json');
|
||||
const sessionsFile = path.join(DATA_ROOT, 'user-sessions.json');
|
||||
const usersFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'users.json');
|
||||
const sessionsFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'user-sessions.json');
|
||||
|
||||
const hasJsonData = fs.existsSync(usersFile) || fs.existsSync(sessionsFile);
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const userRepo = require('../src/repositories/userRepository');
|
||||
const { initEncryption, encrypt } = require('../src/utils/encryption');
|
||||
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
|
||||
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
|
||||
const USERS_FILE = path.join(DATA_ROOT, 'users.json');
|
||||
const SESSIONS_FILE = path.join(DATA_ROOT, 'user-sessions.json');
|
||||
const AFFILIATES_FILE = path.join(DATA_ROOT, 'affiliates.json');
|
||||
const USERS_FILE = path.join(STATE_DIR, 'users.json');
|
||||
const SESSIONS_FILE = path.join(STATE_DIR, 'user-sessions.json');
|
||||
const AFFILIATES_FILE = path.join(STATE_DIR, 'affiliates.json');
|
||||
|
||||
async function loadJsonFile(filePath, defaultValue = []) {
|
||||
try {
|
||||
@@ -44,30 +44,88 @@ async function migrateUsers() {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, email_encrypted, name, name_encrypted, password_hash,
|
||||
providers, email_verified, verification_token, verification_expires_at,
|
||||
reset_token, reset_expires_at, plan, billing_status, billing_email,
|
||||
payment_method_last4, subscription_renews_at, referred_by_affiliate_code,
|
||||
affiliate_attribution_at, affiliate_payouts, two_factor_secret,
|
||||
two_factor_enabled, created_at, updated_at, last_login_at, data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
email_encrypted = excluded.email_encrypted,
|
||||
name = excluded.name,
|
||||
name_encrypted = excluded.name_encrypted,
|
||||
password_hash = excluded.password_hash,
|
||||
providers = excluded.providers,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at,
|
||||
reset_token = excluded.reset_token,
|
||||
reset_expires_at = excluded.reset_expires_at,
|
||||
plan = excluded.plan,
|
||||
billing_status = excluded.billing_status,
|
||||
billing_email = excluded.billing_email,
|
||||
payment_method_last4 = excluded.payment_method_last4,
|
||||
subscription_renews_at = excluded.subscription_renews_at,
|
||||
referred_by_affiliate_code = excluded.referred_by_affiliate_code,
|
||||
affiliate_attribution_at = excluded.affiliate_attribution_at,
|
||||
affiliate_payouts = excluded.affiliate_payouts,
|
||||
two_factor_secret = excluded.two_factor_secret,
|
||||
two_factor_enabled = excluded.two_factor_enabled,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
data = excluded.data
|
||||
`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existing = userRepo.getUserById(user.id);
|
||||
if (existing) {
|
||||
console.log(` Skipping existing user: ${user.email}`);
|
||||
success++;
|
||||
const normalizedEmail = (user.email || '').trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
console.log(' Skipping user without email');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
userRepo.createUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name || null,
|
||||
passwordHash: user.passwordHash || user.password_hash,
|
||||
providers: user.providers || [],
|
||||
emailVerified: user.emailVerified,
|
||||
verificationToken: user.verificationToken || null,
|
||||
verificationExpiresAt: user.verificationExpiresAt || null,
|
||||
plan: user.plan || 'hobby',
|
||||
billingStatus: user.billingStatus || 'active',
|
||||
billingEmail: user.billingEmail || user.email
|
||||
});
|
||||
const passwordHash = user.passwordHash || user.password_hash || user.password || '';
|
||||
const providers = Array.isArray(user.providers) ? user.providers : [];
|
||||
const affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||||
const dataPayload = JSON.stringify({ ...user, email: normalizedEmail || user.email || '' });
|
||||
const emailEncrypted = normalizedEmail ? encrypt(normalizedEmail) : '';
|
||||
const nameEncrypted = user.name ? encrypt(user.name) : null;
|
||||
const twoFactorEncrypted = user.twoFactorSecret ? encrypt(user.twoFactorSecret) : null;
|
||||
|
||||
stmt.run(
|
||||
user.id,
|
||||
normalizedEmail,
|
||||
emailEncrypted,
|
||||
user.name || null,
|
||||
nameEncrypted,
|
||||
passwordHash,
|
||||
JSON.stringify(providers),
|
||||
user.emailVerified ? 1 : 0,
|
||||
user.verificationToken || null,
|
||||
user.verificationExpiresAt || null,
|
||||
user.resetToken || null,
|
||||
user.resetExpiresAt || null,
|
||||
user.plan || 'hobby',
|
||||
user.billingStatus || 'active',
|
||||
user.billingEmail || user.email || '',
|
||||
user.paymentMethodLast4 || '',
|
||||
user.subscriptionRenewsAt || null,
|
||||
user.referredByAffiliateCode || null,
|
||||
user.affiliateAttributionAt || null,
|
||||
JSON.stringify(affiliatePayouts),
|
||||
twoFactorEncrypted,
|
||||
user.twoFactorEnabled ? 1 : 0,
|
||||
user.createdAt || Date.now(),
|
||||
user.updatedAt || user.createdAt || Date.now(),
|
||||
user.lastLoginAt || null,
|
||||
dataPayload
|
||||
);
|
||||
|
||||
console.log(` ✓ Migrated user: ${user.email}`);
|
||||
success++;
|
||||
@@ -99,8 +157,19 @@ async function migrateSessions() {
|
||||
let expired = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
const sessionRepo = require('../src/repositories/sessionRepository');
|
||||
|
||||
const userExistsStmt = db.prepare('SELECT id FROM users WHERE id = ?');
|
||||
const sessionStmt = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, user_id, token, refresh_token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at, last_accessed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
token = excluded.token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_accessed_at = excluded.last_accessed_at
|
||||
`);
|
||||
|
||||
for (const [token, session] of Object.entries(sessions)) {
|
||||
try {
|
||||
// Skip expired sessions
|
||||
@@ -110,25 +179,25 @@ async function migrateSessions() {
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = userRepo.getUserById(session.userId);
|
||||
const user = userExistsStmt.get(session.userId);
|
||||
if (!user) {
|
||||
console.log(` Skipping session for non-existent user: ${session.userId}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create session in database
|
||||
sessionRepo.createSession({
|
||||
id: session.id || require('crypto').randomUUID(),
|
||||
userId: session.userId,
|
||||
token: token,
|
||||
deviceFingerprint: session.deviceFingerprint || null,
|
||||
ipAddress: session.ipAddress || null,
|
||||
userAgent: session.userAgent || null,
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt || now,
|
||||
lastAccessedAt: session.lastAccessedAt || now
|
||||
});
|
||||
sessionStmt.run(
|
||||
token,
|
||||
session.userId,
|
||||
token,
|
||||
null,
|
||||
session.deviceFingerprint || null,
|
||||
session.ipAddress || null,
|
||||
session.userAgent || null,
|
||||
session.expiresAt,
|
||||
session.createdAt || now,
|
||||
session.lastAccessedAt || now
|
||||
);
|
||||
|
||||
success++;
|
||||
} catch (error) {
|
||||
@@ -156,38 +225,53 @@ async function migrateAffiliates() {
|
||||
let failed = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO affiliate_accounts (
|
||||
id, email, name, password_hash, codes, earnings, commission_rate,
|
||||
created_at, updated_at, last_login_at, last_payout_at,
|
||||
email_verified, verification_token, verification_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
name = excluded.name,
|
||||
password_hash = excluded.password_hash,
|
||||
codes = excluded.codes,
|
||||
earnings = excluded.earnings,
|
||||
commission_rate = excluded.commission_rate,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
last_payout_at = excluded.last_payout_at,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at
|
||||
`);
|
||||
|
||||
for (const affiliate of affiliates) {
|
||||
try {
|
||||
// Check if user exists
|
||||
const user = userRepo.getUserById(affiliate.userId);
|
||||
if (!user) {
|
||||
console.log(` Skipping affiliate for non-existent user: ${affiliate.userId}`);
|
||||
if (!affiliate.email) {
|
||||
console.log(' Skipping affiliate without email');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert affiliate
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO affiliates (
|
||||
id, user_id, codes, earnings, commission_rate,
|
||||
total_referrals, total_earnings_cents, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const affiliateEmail = (affiliate.email || '').trim().toLowerCase();
|
||||
stmt.run(
|
||||
affiliate.id || require('crypto').randomUUID(),
|
||||
affiliate.userId,
|
||||
affiliateEmail,
|
||||
affiliate.name || '',
|
||||
affiliate.password || affiliate.passwordHash || '',
|
||||
JSON.stringify(affiliate.codes || []),
|
||||
JSON.stringify(affiliate.earnings || []),
|
||||
affiliate.commissionRate || 0.15,
|
||||
affiliate.totalReferrals || 0,
|
||||
affiliate.totalEarningsCents || 0,
|
||||
affiliate.createdAt || Date.now(),
|
||||
affiliate.updatedAt || Date.now()
|
||||
affiliate.createdAt || new Date().toISOString(),
|
||||
affiliate.updatedAt || affiliate.createdAt || new Date().toISOString(),
|
||||
affiliate.lastLoginAt || null,
|
||||
affiliate.lastPayoutAt || null,
|
||||
affiliate.emailVerified ? 1 : 0,
|
||||
affiliate.verificationToken || null,
|
||||
affiliate.verificationExpiresAt || null
|
||||
);
|
||||
|
||||
console.log(` ✓ Migrated affiliate for user: ${user.email}`);
|
||||
console.log(` ✓ Migrated affiliate: ${affiliate.email}`);
|
||||
success++;
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed to migrate affiliate:`, error.message);
|
||||
@@ -229,7 +313,7 @@ async function createBackup() {
|
||||
|
||||
async function runMigration() {
|
||||
console.log('🔄 Starting database migration...');
|
||||
console.log(' Source: JSON files in', DATA_ROOT);
|
||||
console.log(' Source: JSON files in', STATE_DIR);
|
||||
console.log(' Target: Database at', DATABASE_PATH);
|
||||
|
||||
// Check if database exists
|
||||
@@ -239,13 +323,21 @@ async function runMigration() {
|
||||
}
|
||||
|
||||
// Initialize encryption
|
||||
if (!DATABASE_ENCRYPTION_KEY) {
|
||||
let encryptionKey = DATABASE_ENCRYPTION_KEY;
|
||||
if (!encryptionKey && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (encryptionKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
initEncryption(DATABASE_ENCRYPTION_KEY);
|
||||
initEncryption(encryptionKey);
|
||||
console.log('✅ Encryption initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize encryption:', error.message);
|
||||
@@ -254,7 +346,12 @@ async function runMigration() {
|
||||
|
||||
// Initialize database
|
||||
try {
|
||||
initDatabase(DATABASE_PATH, { verbose: false });
|
||||
initDatabase(DATABASE_PATH, {
|
||||
verbose: false,
|
||||
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
console.log('✅ Database connected');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to database:', error.message);
|
||||
|
||||
@@ -8,12 +8,13 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const { DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
|
||||
const WAL_MODE = process.env.DATABASE_WAL_MODE !== '0' && process.env.DATABASE_WAL_MODE !== 'false';
|
||||
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
|
||||
async function setupDatabase() {
|
||||
console.log('🔧 Setting up database...');
|
||||
@@ -26,14 +27,35 @@ async function setupDatabase() {
|
||||
console.log(' Created data directory:', dataDir);
|
||||
}
|
||||
|
||||
let encryptionKey = DATABASE_ENCRYPTION_KEY;
|
||||
|
||||
// Check if encryption key is provided
|
||||
if (!DATABASE_ENCRYPTION_KEY) {
|
||||
if (!encryptionKey) {
|
||||
if (fs.existsSync(KEY_FILE)) {
|
||||
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (encryptionKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!encryptionKey) {
|
||||
console.warn('⚠️ WARNING: No DATABASE_ENCRYPTION_KEY found!');
|
||||
console.warn('⚠️ Generating a random key for this session (not persistent).');
|
||||
console.warn('⚠️ For production, set DATABASE_ENCRYPTION_KEY environment variable.');
|
||||
console.warn('⚠️ Generate one with: openssl rand -hex 32');
|
||||
const generatedKey = crypto.randomBytes(32).toString('hex');
|
||||
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
|
||||
encryptionKey = generatedKey;
|
||||
try {
|
||||
const keyDir = path.dirname(KEY_FILE);
|
||||
if (!fs.existsSync(keyDir)) fs.mkdirSync(keyDir, { recursive: true });
|
||||
fs.writeFileSync(KEY_FILE, generatedKey, { mode: 0o600 });
|
||||
console.log('⚠️ Saved generated key to:', KEY_FILE);
|
||||
} catch (err) {
|
||||
console.warn('⚠️ Failed to persist encryption key:', err.message);
|
||||
}
|
||||
console.log('✅ Generated temporary encryption key');
|
||||
} else {
|
||||
console.log('✅ Using encryption key from environment');
|
||||
@@ -41,7 +63,7 @@ async function setupDatabase() {
|
||||
|
||||
// Initialize encryption
|
||||
try {
|
||||
initEncryption(process.env.DATABASE_ENCRYPTION_KEY);
|
||||
initEncryption(encryptionKey);
|
||||
console.log('✅ Encryption initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize encryption:', error.message);
|
||||
@@ -52,7 +74,10 @@ async function setupDatabase() {
|
||||
try {
|
||||
initDatabase(DATABASE_PATH, {
|
||||
verbose: false,
|
||||
walMode: WAL_MODE
|
||||
walMode: WAL_MODE,
|
||||
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
console.log('✅ Database initialized');
|
||||
} catch (error) {
|
||||
@@ -70,7 +95,15 @@ async function setupDatabase() {
|
||||
// Execute the entire schema as one block
|
||||
// SQLite can handle multiple statements with exec()
|
||||
db.exec(schema);
|
||||
|
||||
|
||||
// Add missing columns if this is an upgraded database
|
||||
const userColumns = db.prepare('PRAGMA table_info(users)').all();
|
||||
const userColumnNames = new Set(userColumns.map((c) => c.name));
|
||||
if (!userColumnNames.has('data')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN data TEXT');
|
||||
console.log('✅ Added users.data column');
|
||||
}
|
||||
|
||||
console.log('✅ Database schema created');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create schema:', error.message);
|
||||
|
||||
69
chat/scripts/verify-migration.js
Normal file
69
chat/scripts/verify-migration.js
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify JSON -> Database migration counts
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
function loadJson(filePath, fallback) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw || JSON.stringify(fallback));
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKey() {
|
||||
if (process.env.DATABASE_ENCRYPTION_KEY) return process.env.DATABASE_ENCRYPTION_KEY.trim();
|
||||
if (KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const key = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (key) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = key;
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const key = resolveKey();
|
||||
if (!key) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set (and no key file found).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
initEncryption(key);
|
||||
const useSqlcipher = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
initDatabase(DB_PATH, {
|
||||
sqlcipherKey: useSqlcipher ? key : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
|
||||
const db = getDatabase();
|
||||
const now = Date.now();
|
||||
|
||||
const usersJson = loadJson(path.join(STATE_DIR, 'users.json'), []);
|
||||
const sessionsJson = loadJson(path.join(STATE_DIR, 'user-sessions.json'), {});
|
||||
const affiliatesJson = loadJson(path.join(STATE_DIR, 'affiliates.json'), []);
|
||||
|
||||
const usersDb = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const sessionsDb = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE expires_at > ?').get(now).count;
|
||||
const affiliatesDb = db.prepare('SELECT COUNT(*) as count FROM affiliate_accounts').get().count;
|
||||
|
||||
const activeJsonSessions = Object.values(sessionsJson || {}).filter((session) => session?.expiresAt && session.expiresAt > now).length;
|
||||
|
||||
console.log('Migration verification:');
|
||||
console.log(` Users: JSON=${Array.isArray(usersJson) ? usersJson.length : 0} DB=${usersDb}`);
|
||||
console.log(` Sessions (active): JSON=${activeJsonSessions} DB=${sessionsDb}`);
|
||||
console.log(` Affiliates: JSON=${Array.isArray(affiliatesJson) ? affiliatesJson.length : 0} DB=${affiliatesDb}`);
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
||||
main();
|
||||
1399
chat/server.js
1399
chat/server.js
File diff suppressed because it is too large
Load Diff
21
chat/src/database/config.js
Normal file
21
chat/src/database/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
|
||||
function resolvePath(root, value, fallback) {
|
||||
if (!value) return fallback;
|
||||
if (path.isAbsolute(value)) return value;
|
||||
return path.join(root, value);
|
||||
}
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
|
||||
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
|
||||
const DEFAULT_DB_PATH = path.join(DATA_ROOT, '.data', 'shopify_ai.db');
|
||||
const DB_PATH = resolvePath(DATA_ROOT, process.env.DATABASE_PATH, DEFAULT_DB_PATH);
|
||||
const DEFAULT_KEY_FILE = path.join(path.dirname(DB_PATH), '.encryption_key');
|
||||
const KEY_FILE = resolvePath(DATA_ROOT, process.env.DATABASE_KEY_FILE, DEFAULT_KEY_FILE);
|
||||
|
||||
module.exports = {
|
||||
DATA_ROOT,
|
||||
STATE_DIR,
|
||||
DB_PATH,
|
||||
KEY_FILE
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Database connection module with SQLite support
|
||||
* Uses better-sqlite3 for synchronous operations
|
||||
* Note: SQLCipher support requires special compilation, using AES-256-GCM encryption at field level instead
|
||||
* Note: SQLCipher support requires special compilation and is enabled via configuration
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -11,6 +11,10 @@ const fs = require('fs');
|
||||
let db = null;
|
||||
let dbPath = null;
|
||||
|
||||
function escapeSqliteString(value) {
|
||||
return String(value || '').replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
* @param {string} databasePath - Path to the database file
|
||||
@@ -43,6 +47,18 @@ function initDatabase(databasePath, options = {}) {
|
||||
|
||||
db = new Database(databasePath, dbOptions);
|
||||
|
||||
// SQLCipher support (optional)
|
||||
if (options.sqlcipherKey) {
|
||||
const escapedKey = escapeSqliteString(options.sqlcipherKey);
|
||||
db.pragma(`key = '${escapedKey}'`);
|
||||
if (options.cipherCompatibility) {
|
||||
db.pragma(`cipher_compatibility = ${Number(options.cipherCompatibility)}`);
|
||||
}
|
||||
if (options.kdfIter) {
|
||||
db.pragma(`kdf_iter = ${Number(options.kdfIter)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
if (options.walMode !== false) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
two_factor_enabled INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
last_login_at INTEGER,
|
||||
data TEXT -- Full user payload (JSON)
|
||||
);
|
||||
|
||||
-- Sessions table for active user sessions
|
||||
@@ -92,6 +93,24 @@ CREATE TABLE IF NOT EXISTS affiliates (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Affiliate accounts (used by current app)
|
||||
CREATE TABLE IF NOT EXISTS affiliate_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
codes TEXT NOT NULL DEFAULT '[]', -- JSON array of tracking codes
|
||||
earnings TEXT NOT NULL DEFAULT '[]', -- JSON array of earnings
|
||||
commission_rate REAL NOT NULL DEFAULT 0.15,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
last_payout_at TEXT,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at TEXT
|
||||
);
|
||||
|
||||
-- Withdrawals table
|
||||
CREATE TABLE IF NOT EXISTS withdrawals (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -172,6 +191,7 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_token_jti ON token_blacklist(token_jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires_at ON token_blacklist(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliates_user_id ON affiliates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliate_accounts_email ON affiliate_accounts(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_withdrawals_affiliate_id ON withdrawals(affiliate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_user_id ON feature_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||
|
||||
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 };
|
||||
@@ -4,62 +4,71 @@
|
||||
"name": "Site Announcement Banners",
|
||||
"description": "Create and manage announcement banners that display at the top of your site with scheduling capabilities. Features admin management, scheduling, and responsive design.",
|
||||
"image": "/chat/templates/images/announcements.jpg",
|
||||
"category": "Content"
|
||||
"category": "Content",
|
||||
"folder": "Announcements"
|
||||
},
|
||||
{
|
||||
"id": "change-login-url",
|
||||
"name": "Secure Login URL",
|
||||
"description": "Hide your WordPress login page with a custom URL slug to improve security and prevent automated brute force attacks.",
|
||||
"image": "/chat/templates/images/change-login-url.jpg",
|
||||
"category": "Security"
|
||||
"category": "Security",
|
||||
"folder": "Change Login URL"
|
||||
},
|
||||
{
|
||||
"id": "changelog",
|
||||
"name": "Product Changelog Display",
|
||||
"description": "Showcase your product updates with a beautiful changelog page. Custom post type, shortcode support, and automatic page creation.",
|
||||
"image": "/chat/templates/images/changelog.jpg",
|
||||
"category": "Content"
|
||||
"category": "Content",
|
||||
"folder": "Changelog Plugin"
|
||||
},
|
||||
{
|
||||
"id": "community-suggestions",
|
||||
"name": "Feature Request Board",
|
||||
"description": "Let your community submit and vote on feature suggestions. Perfect for gathering user feedback and prioritizing development.",
|
||||
"image": "/chat/templates/images/community-suggestions.jpg",
|
||||
"category": "Community"
|
||||
"category": "Community",
|
||||
"folder": "Community Suggestions"
|
||||
},
|
||||
{
|
||||
"id": "faq-manager",
|
||||
"name": "FAQ Page Builder",
|
||||
"description": "Build beautiful FAQ pages with drag-and-drop reordering. Includes accordion styling, accessibility support, and automatic page creation.",
|
||||
"image": "/chat/templates/images/faq-manager.jpg",
|
||||
"category": "Content"
|
||||
"category": "Content",
|
||||
"folder": "FAQ Manager"
|
||||
},
|
||||
{
|
||||
"id": "form-builder",
|
||||
"name": "Drag & Drop Form Builder",
|
||||
"description": "Create custom forms with an intuitive drag-and-drop interface. Track submissions, manage responses, and embed forms anywhere.",
|
||||
"image": "/chat/templates/images/form-builder.jpg",
|
||||
"category": "Forms"
|
||||
"category": "Forms",
|
||||
"folder": "Form Builder"
|
||||
},
|
||||
{
|
||||
"id": "headers-footers",
|
||||
"name": "Header & Footer Scripts",
|
||||
"description": "Easily add custom code to your site's header and footer. Perfect for analytics tracking, ad pixels, and custom JavaScript.",
|
||||
"image": "/chat/templates/images/headers-footers.jpg",
|
||||
"category": "Utilities"
|
||||
"category": "Utilities",
|
||||
"folder": "headers and footers"
|
||||
},
|
||||
{
|
||||
"id": "membership",
|
||||
"name": "Membership & Subscriptions",
|
||||
"description": "Monetize your content with membership plans. Stripe-powered payments, subscription management, and content access control.",
|
||||
"image": "/chat/templates/images/membership.jpg",
|
||||
"category": "E-commerce"
|
||||
"category": "E-commerce",
|
||||
"folder": "Membership"
|
||||
},
|
||||
{
|
||||
"id": "scroll-to-top",
|
||||
"name": "Back to Top Button",
|
||||
"description": "Add a customizable scroll-to-top button to your site. Choose styles, colors, positions, and enable/disable on mobile devices.",
|
||||
"image": "/chat/templates/images/scroll-to-top.jpg",
|
||||
"category": "Utilities"
|
||||
"category": "Utilities",
|
||||
"folder": "scroll to bottom"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -90,13 +90,20 @@ 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:-}
|
||||
- DATABASE_PATH=${DATABASE_PATH:-}
|
||||
- DATABASE_ENCRYPTION_KEY=${DATABASE_ENCRYPTION_KEY:-}
|
||||
- DATABASE_KEY_FILE=${DATABASE_KEY_FILE:-}
|
||||
- DATABASE_BACKUP_ENABLED=${DATABASE_BACKUP_ENABLED:-1}
|
||||
- DATABASE_WAL_MODE=${DATABASE_WAL_MODE:-1}
|
||||
- DATABASE_USE_SQLCIPHER=${DATABASE_USE_SQLCIPHER:-1}
|
||||
- DATABASE_CIPHER_COMPAT=${DATABASE_CIPHER_COMPAT:-4}
|
||||
- DATABASE_KDF_ITER=${DATABASE_KDF_ITER:-64000}
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
- JWT_ACCESS_TOKEN_TTL=${JWT_ACCESS_TOKEN_TTL:-900}
|
||||
- JWT_REFRESH_TOKEN_TTL=${JWT_REFRESH_TOKEN_TTL:-604800}
|
||||
@@ -122,6 +129,8 @@ services:
|
||||
- KILO_API_KEY=${KILO_API_KEY:-}
|
||||
# Bytez
|
||||
- BYTEZ_API_KEY=${BYTEZ_API_KEY:-}
|
||||
# DeepInfra
|
||||
- DEEPINFRA_API_KEY=${DEEPINFRA_API_KEY:-}
|
||||
# Ollama
|
||||
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
||||
- OLLAMA_API_URL=${OLLAMA_API_URL:-}
|
||||
|
||||
183
improvements to builder and opencode cli.txt
Normal file
183
improvements to builder and opencode cli.txt
Normal file
@@ -0,0 +1,183 @@
|
||||
## Background
|
||||
The Plugin Compass builder is public-facing and currently exposes OpenCode-specific error messages and codes to end users. This is confusing and leaks
|
||||
internal detail. Additionally, fallback behavior mixes client and server responsibilities in a way that can trigger the wrong recovery action (e.g.,
|
||||
switching models when a malformed edit should trigger a “continue”). Provider rate limits are configured in the admin panel, but the runtime behavior
|
||||
should distinguish between minute limits (wait and retry) vs. hour/day limits (skip to next model). Finally, the OpenCode CLI must reliably include
|
||||
WordPress validation instructions and ensure the WordPress validator tool is actually callable, while preserving token extraction to keep usage accurate.
|
||||
|
||||
## Goals
|
||||
1. **Public UX**: Replace builder error messaging with a safe, branded message.
|
||||
2. **Fallback correctness**:
|
||||
- Malformed edits → send “continue” to OpenCode (same model/session).
|
||||
- Provider/quota errors → switch to next model/provider.
|
||||
3. **Rate-limit behavior**:
|
||||
- Minute limits → wait until reset, then retry same provider/model.
|
||||
- Hour/day limits → skip immediately to next model.
|
||||
- Automatically return to a provider once limits lift.
|
||||
4. **WordPress validation**:
|
||||
- Ensure `wordpress-plugin.txt` prompt is always applied in builder sessions.
|
||||
- Ensure `wordpress-validator:validate_wordpress_plugin` is callable.
|
||||
5. **Token extraction safety**: Errors/continuations should not break token usage reporting in the builder UI.
|
||||
|
||||
## Non-Goals
|
||||
- Changing plan models or public model list behavior.
|
||||
- Redesigning the builder UI or admin UI.
|
||||
- Modifying export/ZIP workflows.
|
||||
|
||||
---
|
||||
|
||||
## Current State Summary (from repo)
|
||||
- Builder UI is in `chat/public/builder.js` and `chat/public/builder.html`.
|
||||
- Fallback logic for OpenCode is primarily in `chat/server.js`:
|
||||
- `sendToOpencodeWithFallback`
|
||||
- `isEarlyTerminationError`
|
||||
- `classifyProviderError`
|
||||
- `isProviderLimited`
|
||||
- WordPress prompt file exists at:
|
||||
- `opencode/packages/opencode/src/session/prompt/wordpress-plugin.txt`
|
||||
- WordPress validator tool exists as a built-in tool:
|
||||
- `opencode/packages/opencode/src/tool/validate-wordpress-plugin.ts`
|
||||
- MCP server for WP CLI testing is already wired in `chat/server.js`.
|
||||
|
||||
---
|
||||
|
||||
## Plan
|
||||
|
||||
### 1) Builder Error Masking (Public UI)
|
||||
**Files**: `chat/public/builder.js`
|
||||
|
||||
**Changes**
|
||||
- When rendering assistant errors, replace any OpenCode or provider error text with:
|
||||
- **“Plugin Compass failed. Please try again.”**
|
||||
- Apply the same masking to `setStatus()` paths for OpenCode errors in builder UI.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- Builder page never shows “OpenCode failed” or raw exit codes.
|
||||
- Admin/internal logs remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### 2) Malformed Edit → Continue (OpenCode)
|
||||
**Files**: `chat/server.js`
|
||||
|
||||
**Changes**
|
||||
- Extend `isEarlyTerminationError()` to treat malformed edit cases as early termination:
|
||||
- Add patterns like `/error:.*malformed edit/i` and `/error:.*invalid edit/i`.
|
||||
- This ensures `sendToOpencodeWithFallback` issues a `[CONTINUE]` retry in the same model/session before switching.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- Malformed edit errors trigger a “continue” attempt.
|
||||
- Only after `MAX_CONTINUE_ATTEMPTS` does it switch models.
|
||||
|
||||
---
|
||||
|
||||
### 3) Provider Errors → Switch Model
|
||||
**Files**: `chat/server.js`
|
||||
|
||||
**Changes**
|
||||
- Expand `classifyProviderError()` to detect provider quota/billing messages:
|
||||
- “key limit reached”, “quota exceeded”, “insufficient quota”, “payment required”, etc.
|
||||
- Return `{ action: 'switch' }` for these errors.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- Quota/billing errors cause immediate fallback to next model/provider.
|
||||
|
||||
---
|
||||
|
||||
### 4) Rate Limits: Minute vs Hour/Day
|
||||
**Files**: `chat/server.js`
|
||||
|
||||
**Changes**
|
||||
- Extend `isProviderLimited()` to return `retryAfterMs` when the limit field is per-minute.
|
||||
- In `sendToOpencodeWithFallback`:
|
||||
- If the limit is **minute-based**, wait for reset and retry the same provider/model.
|
||||
- If **hour/day**, skip immediately to next model.
|
||||
- Use provider usage timestamps to compute `retryAfterMs`.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- Minute limit → waits until reset, retries same model.
|
||||
- Hour/day limit → switches to next model without waiting.
|
||||
- When a provider’s limit clears, it is retried in chain order.
|
||||
|
||||
---
|
||||
|
||||
### 5) WordPress Validator MCP + Prompt Enforcement
|
||||
**Files**
|
||||
- `opencode/mcp-servers/wordpress-validator/*` (new)
|
||||
- `chat/server.js`
|
||||
- `opencode/packages/opencode/src/session/prompt/wordpress-plugin.txt`
|
||||
- `opencode/packages/opencode/src/session/system.ts`
|
||||
|
||||
**Changes**
|
||||
1. **MCP Server**
|
||||
- Create `wordpress-validator` MCP server wrapping the same validation script.
|
||||
- Tool name: `validate_wordpress_plugin`.
|
||||
2. **Wire MCP server for builder**
|
||||
- Use `OPENCODE_EXTRA_MCP_SERVERS` in `chat/server.js` to enable this MCP server for builder sessions.
|
||||
3. **Prompt update**
|
||||
- Update `wordpress-plugin.txt` to reference both:
|
||||
- `wordpress-validator:validate_wordpress_plugin` (MCP)
|
||||
- `validate_wordpress_plugin` (built-in)
|
||||
4. **Force WordPress prompts**
|
||||
- Add `OPENCODE_FORCE_WORDPRESS=1` option in `system.ts` to always append WordPress prompts for builder sessions.
|
||||
- Set this env var when running OpenCode from the builder.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- WordPress validator tool is callable and not blocked by tool registry.
|
||||
- WordPress prompt always included for builder sessions.
|
||||
|
||||
---
|
||||
|
||||
### 6) Token Extraction & Usage Safety
|
||||
**Files**: `chat/server.js`, `chat/public/builder.js`
|
||||
|
||||
**Changes**
|
||||
- Ensure token usage is recorded on:
|
||||
- Successful completion
|
||||
- Continuations
|
||||
- Error states with estimated tokens
|
||||
- Ensure builder calls `loadUsageSummary()` after completion/error even if SSE fails.
|
||||
|
||||
**Acceptance Criteria**
|
||||
- Usage meter updates after errors and continuations.
|
||||
- No “missing token usage” regressions in builder.
|
||||
|
||||
---
|
||||
|
||||
## Public API / Interface Changes
|
||||
- New env flag:
|
||||
- `OPENCODE_FORCE_WORDPRESS=1`
|
||||
- New MCP server:
|
||||
- `wordpress-validator` (exposed through `OPENCODE_EXTRA_MCP_SERVERS`)
|
||||
|
||||
---
|
||||
|
||||
## Testing & Validation
|
||||
|
||||
2. Malformed edit → continue attempt occurs before model switch.
|
||||
3. Provider minute limit → waits until reset then retries same model.
|
||||
4. Provider hour/day limit → switches immediately.
|
||||
5. Validator MCP tool works; WordPress prompt is applied.
|
||||
|
||||
---
|
||||
|
||||
## Risks / Mitigations
|
||||
- **Risk**: Overeager fallback if error matching is too broad.
|
||||
- Mitigation: Keep strict patterns and prefer explicit error prefixes.
|
||||
- **Risk**: Waiting too long on minute limits.
|
||||
- Mitigation: Compute retry after using usage timestamps and cap to 60s.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
- Builder runs in the chat server context using OpenCode CLI.
|
||||
- Admin limits are authoritative for provider restrictions.
|
||||
- WordPress validator script exists at `scripts/validate-wordpress-plugin.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
1. Builder error masking
|
||||
2. Fallback and limit corrections
|
||||
3. MCP server + prompt enforcement
|
||||
4. Validation + testing
|
||||
370
opencode/mcp-servers/wordpress-validator/index.js
Normal file
370
opencode/mcp-servers/wordpress-validator/index.js
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { z } from "zod"
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const DEFAULT_TIMEOUT = 120000
|
||||
|
||||
const ValidateToolInputJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["plugin_path"],
|
||||
properties: {
|
||||
plugin_path: {
|
||||
type: "string",
|
||||
description: "Absolute or relative path to the WordPress plugin directory to validate"
|
||||
},
|
||||
verbose: {
|
||||
type: "boolean",
|
||||
description: "Include full script output in addition to structured results (default: false)"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "wordpress-validator",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "validate_wordpress_plugin",
|
||||
description: `Validates a WordPress plugin for security vulnerabilities, coding standards violations, and common runtime errors. Runs comprehensive static analysis including:
|
||||
- Forbidden/dangerous function detection
|
||||
- SQL injection pattern detection
|
||||
- XSS and input sanitization checks
|
||||
- Nonce and capability verification
|
||||
- PHP syntax validation
|
||||
- Duplicate class/function detection
|
||||
- Class loading validation
|
||||
- File path security checks
|
||||
- WordPress deprecated function detection
|
||||
|
||||
CRITICAL: This tool MUST be called before completing ANY WordPress plugin work. Do NOT mark work complete until validation passes.
|
||||
|
||||
Returns structured results with severity categorization. Use verbose=true only if you need full output.`,
|
||||
inputSchema: ValidateToolInputJsonSchema,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const toolName = req.params.name
|
||||
const args = (req.params.arguments ?? {})
|
||||
|
||||
if (toolName === "validate_wordpress_plugin") {
|
||||
const parsed = z
|
||||
.object({
|
||||
plugin_path: z.string().min(1),
|
||||
verbose: z.boolean().optional().default(false),
|
||||
})
|
||||
.safeParse(args && typeof args === "object" ? args : {})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
passed: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
summary: `Validation failed: ${parsed.error.message}`,
|
||||
issues: [{ severity: "error", category: "input", message: parsed.error.message }]
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const pluginPath = parsed.data.plugin_path
|
||||
const verbose = parsed.data.verbose || false
|
||||
const resolvedPath = path.isAbsolute(pluginPath)
|
||||
? pluginPath
|
||||
: path.resolve(process.cwd(), pluginPath)
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
const stat = await fs.stat(resolvedPath)
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
passed: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
summary: `Validation failed: Not a directory: ${resolvedPath}`,
|
||||
issues: [{ severity: "error", category: "path", message: "Plugin path must be a directory" }]
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
passed: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
summary: `Validation failed: Directory not found: ${resolvedPath}`,
|
||||
issues: [{ severity: "error", category: "path", message: `Plugin directory does not exist: ${resolvedPath}` }]
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Find the validation script
|
||||
const scriptDir = path.resolve(__dirname, "../../../../scripts")
|
||||
const bashScript = path.join(scriptDir, "validate-wordpress-plugin.sh")
|
||||
|
||||
// Check if script exists
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
await fs.access(bashScript)
|
||||
} catch {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
passed: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
summary: "Validation failed: Validation script not found",
|
||||
issues: [{ severity: "error", category: "setup", message: `Validation script not found: ${bashScript}` }]
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Run the validation script
|
||||
const output = await runValidationScript(bashScript, resolvedPath, DEFAULT_TIMEOUT)
|
||||
|
||||
// Parse the validation output
|
||||
const result = parseValidationOutput(output)
|
||||
|
||||
// Build concise summary
|
||||
const summaryParts = []
|
||||
|
||||
if (result.errorCount > 0) {
|
||||
summaryParts.push(`${result.errorCount} errors`)
|
||||
result.passed = false
|
||||
}
|
||||
|
||||
if (result.warningCount > 0) {
|
||||
summaryParts.push(`${result.warningCount} warnings`)
|
||||
}
|
||||
|
||||
if (result.errorCount === 0 && result.warningCount === 0) {
|
||||
summaryParts.push("SUCCESS")
|
||||
result.passed = true
|
||||
}
|
||||
|
||||
result.summary = result.passed
|
||||
? "All validation checks passed"
|
||||
: `Validation failed: ${summaryParts.join(", ")}`
|
||||
|
||||
// Build output string
|
||||
let outputText = `<validation_result>\n`
|
||||
outputText += `<summary>${result.summary}</summary>\n\n`
|
||||
|
||||
const errors = result.issues.filter(i => i.severity === "error")
|
||||
const warnings = result.issues.filter(i => i.severity === "warning")
|
||||
|
||||
if (errors.length > 0) {
|
||||
outputText += `<errors count="${errors.length}">\n`
|
||||
errors.slice(0, 10).forEach(issue => {
|
||||
outputText += ` [${issue.category}] ${issue.file ? `${issue.file}:${issue.line} - ` : ""}${issue.message}\n`
|
||||
})
|
||||
if (errors.length > 10) {
|
||||
outputText += ` ... and ${errors.length - 10} more errors\n`
|
||||
}
|
||||
outputText += `</errors>\n\n`
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
outputText += `<warnings count="${warnings.length}">\n`
|
||||
warnings.slice(0, 5).forEach(issue => {
|
||||
outputText += ` [${issue.category}] ${issue.file ? `${issue.file}:${issue.line} - ` : ""}${issue.message}\n`
|
||||
})
|
||||
if (warnings.length > 5) {
|
||||
outputText += ` ... and ${warnings.length - 5} more warnings\n`
|
||||
}
|
||||
outputText += `</warnings>\n`
|
||||
}
|
||||
|
||||
outputText += `</validation_result>`
|
||||
|
||||
if (verbose) {
|
||||
outputText += `\n\n<raw_output>\n${output.slice(-3000)}\n</raw_output>`
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: outputText }],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
|
||||
isError: true,
|
||||
}
|
||||
})
|
||||
|
||||
async function runValidationScript(scriptPath, pluginPath, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("bash", [scriptPath, pluginPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
|
||||
let output = ""
|
||||
proc.stdout?.on("data", (chunk) => { output += chunk.toString() })
|
||||
proc.stderr?.on("data", (chunk) => { output += chunk.toString() })
|
||||
|
||||
let timedOut = false
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc.kill("SIGTERM")
|
||||
setTimeout(() => proc.kill("SIGKILL"), 5000)
|
||||
}, timeout)
|
||||
|
||||
proc.on("exit", () => {
|
||||
clearTimeout(timeoutTimer)
|
||||
if (timedOut) {
|
||||
output += "\n\n[Validation timed out after " + timeout + "ms]"
|
||||
}
|
||||
resolve(output)
|
||||
})
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timeoutTimer)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseValidationOutput(output) {
|
||||
const result = {
|
||||
passed: true,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
issues: [],
|
||||
summary: "",
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.includes("✗") || trimmed.includes("FATAL") || trimmed.includes("ERROR")) {
|
||||
const issue = parseIssueLine(trimmed, "error")
|
||||
if (issue && !isDuplicateIssue(result.issues, issue)) {
|
||||
result.issues.push(issue)
|
||||
result.errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes("⚠") || trimmed.includes("WARNING")) {
|
||||
const issue = parseIssueLine(trimmed, "warning")
|
||||
if (issue && !isDuplicateIssue(result.issues, issue)) {
|
||||
result.issues.push(issue)
|
||||
result.warningCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function parseIssueLine(line, severity) {
|
||||
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "")
|
||||
|
||||
const fileMatch = cleanLine.match(/([\w\/\\.-]+\.php):(\d+)/)
|
||||
const file = fileMatch ? fileMatch[1] : undefined
|
||||
const lineNum = fileMatch ? parseInt(fileMatch[2], 10) : undefined
|
||||
|
||||
const categoryMatch = cleanLine.match(/\[\d+\/\d+\]\s+Checking\s+(?:for\s+)?(.+?)\.\.\./i)
|
||||
const category = categoryMatch ? categoryMatch[1] : extractCategory(cleanLine)
|
||||
|
||||
let message = cleanLine
|
||||
.replace(/^\s*[✗⚠]\s*/, "")
|
||||
.replace(/^\s*FATAL:\s*/i, "")
|
||||
.replace(/^\s*ERROR:\s*/i, "")
|
||||
.replace(/^\s*WARNING:\s*/i, "")
|
||||
.replace(/^\s*SECURITY\s+RISK\s+in\s+/, "")
|
||||
.replace(/^\s*SQL\s+INJECTION\s+RISK\s+in\s+/, "")
|
||||
.replace(/^\s*Found\s+/, "")
|
||||
.trim()
|
||||
|
||||
if (!message || message.length < 10) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
severity,
|
||||
category,
|
||||
file,
|
||||
line: lineNum,
|
||||
message: message.substring(0, 200),
|
||||
}
|
||||
}
|
||||
|
||||
function extractCategory(line) {
|
||||
if (line.includes("forbidden") || line.includes("eval") || line.includes("exec")) {
|
||||
return "security"
|
||||
}
|
||||
if (line.includes("SQL") || line.includes("wpdb")) {
|
||||
return "sql-injection"
|
||||
}
|
||||
if (line.includes("XSS") || line.includes("sanitize") || line.includes("escape")) {
|
||||
return "xss"
|
||||
}
|
||||
if (line.includes("syntax") || line.includes("parse")) {
|
||||
return "syntax"
|
||||
}
|
||||
if (line.includes("duplicate") || line.includes("redeclare")) {
|
||||
return "duplicates"
|
||||
}
|
||||
if (line.includes("missing") || line.includes("undefined")) {
|
||||
return "undefined"
|
||||
}
|
||||
if (line.includes("class") || line.includes("function")) {
|
||||
return "structure"
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
function isDuplicateIssue(issues, newIssue) {
|
||||
return issues.some(i =>
|
||||
i.message === newIssue.message &&
|
||||
i.file === newIssue.file &&
|
||||
i.line === newIssue.line
|
||||
)
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
14
opencode/mcp-servers/wordpress-validator/package.json
Normal file
14
opencode/mcp-servers/wordpress-validator/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "wordpress-validator",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "MCP server for WordPress plugin validation",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.22.0"
|
||||
}
|
||||
}
|
||||
14
opencode/mcp-servers/wp-cli-testing/package.json
Normal file
14
opencode/mcp-servers/wp-cli-testing/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "wp-cli-testing",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "MCP server for external WordPress CLI testing",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.22.0"
|
||||
}
|
||||
}
|
||||
@@ -171,3 +171,58 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.providerForm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-sans);
|
||||
|
||||
select {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fallbackLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Model } from "@opencode-ai/console-core/model.js"
|
||||
import { Model, ZenData, ProviderPreference } from "@opencode-ai/console-core/model.js"
|
||||
import { query, action, useParams, createAsync, json } from "@solidjs/router"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createMemo, For, Show, createSignal, createEffect } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import styles from "./model-section.module.css"
|
||||
import { querySessionInfo } from "../common"
|
||||
import {
|
||||
@@ -20,6 +19,18 @@ import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formError } from "~/lib/form-error"
|
||||
|
||||
const OPENROUTER_PROVIDERS = [
|
||||
{ value: "", label: "Auto (default)" },
|
||||
{ value: "anthropic", label: "Anthropic only" },
|
||||
{ value: "openai", label: "OpenAI only" },
|
||||
{ value: "google", label: "Google only" },
|
||||
{ value: "together", label: "Together AI only" },
|
||||
{ value: "fireworks", label: "Fireworks AI only" },
|
||||
{ value: "groq", label: "Groq only" },
|
||||
{ value: "deepinfra", label: "DeepInfra only" },
|
||||
{ value: "perplexity", label: "Perplexity only" },
|
||||
]
|
||||
|
||||
const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("claude")) return "Anthropic"
|
||||
if (modelId.startsWith("gpt")) return "OpenAI"
|
||||
@@ -35,8 +46,10 @@ const getModelLab = (modelId: string) => {
|
||||
const getModelsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const zenData = ZenData.list()
|
||||
const preferences = await Model.listProviderPreferences()
|
||||
return {
|
||||
all: Object.entries(ZenData.list().models)
|
||||
all: Object.entries(zenData.models)
|
||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
||||
.filter(([id, _model]) => !id.startsWith("alpha-"))
|
||||
.sort(([idA, modelA], [idB, modelB]) => {
|
||||
@@ -53,7 +66,16 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
const modelBName = Array.isArray(modelB) ? modelB[0].name : modelB.name
|
||||
return modelAName.localeCompare(modelBName)
|
||||
})
|
||||
.map(([id, model]) => ({ id, name: Array.isArray(model) ? model[0].name : model.name })),
|
||||
.map(([id, model]) => {
|
||||
const modelData = Array.isArray(model) ? model[0] : model
|
||||
const hasOpenRouter = modelData.providers.some((p) => p.id === "openrouter")
|
||||
return {
|
||||
id,
|
||||
name: modelData.name,
|
||||
hasOpenRouter,
|
||||
providerPreference: preferences[id],
|
||||
}
|
||||
}),
|
||||
disabled: await Model.listDisabled(),
|
||||
}
|
||||
}, workspaceID)
|
||||
@@ -78,6 +100,33 @@ const updateModel = action(async (form: FormData) => {
|
||||
)
|
||||
}, "model.toggle")
|
||||
|
||||
const updateProviderPreference = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const model = form.get("model")?.toString()
|
||||
if (!model) return { error: formError.modelRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const providerValue = form.get("provider")?.toString()
|
||||
const allowFallbacks = form.get("allowFallbacks")?.toString() === "true"
|
||||
|
||||
return json(
|
||||
withActor(async () => {
|
||||
if (!providerValue) {
|
||||
await Model.deleteProviderPreference({ model })
|
||||
} else {
|
||||
await Model.setProviderPreference({
|
||||
model,
|
||||
preference: {
|
||||
order: [providerValue],
|
||||
allow_fallbacks: allowFallbacks,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, workspaceID),
|
||||
{ revalidate: getModelsInfo.key },
|
||||
)
|
||||
}, "model.providerPreference")
|
||||
|
||||
export function ModelSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
@@ -110,13 +159,16 @@ export function ModelSection() {
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.models.table.model")}</th>
|
||||
<th></th>
|
||||
<th>Provider</th>
|
||||
<th>{i18n.t("workspace.models.table.enabled")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={modelsWithLab()}>
|
||||
{({ id, name, lab }) => {
|
||||
{({ id, name, lab, hasOpenRouter, providerPreference }) => {
|
||||
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(id))
|
||||
const currentProvider = () => providerPreference?.order?.[0] ?? ""
|
||||
const allowFallbacks = () => providerPreference?.allow_fallbacks ?? true
|
||||
return (
|
||||
<tr data-slot="model-row" data-disabled={!isEnabled()}>
|
||||
<td data-slot="model-name">
|
||||
@@ -147,6 +199,43 @@ export function ModelSection() {
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="model-lab">{lab}</td>
|
||||
<td data-slot="model-provider">
|
||||
<Show when={hasOpenRouter}>
|
||||
<form action={updateProviderPreference} method="post" class={styles.providerForm}>
|
||||
<input type="hidden" name="model" value={id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<select
|
||||
name="provider"
|
||||
disabled={!userInfo()?.isAdmin}
|
||||
onChange={(e) => {
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form) form.requestSubmit()
|
||||
}}
|
||||
>
|
||||
<For each={OPENROUTER_PROVIDERS}>
|
||||
{(option) => (
|
||||
<option value={option.value} selected={currentProvider() === option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<label class={styles.fallbackLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowFallbacks"
|
||||
checked={allowFallbacks()}
|
||||
disabled={!userInfo()?.isAdmin || !currentProvider()}
|
||||
onChange={(e) => {
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form) form.requestSubmit()
|
||||
}}
|
||||
/>
|
||||
<span>Allow fallbacks</span>
|
||||
</label>
|
||||
</form>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="model-toggle">
|
||||
<form action={updateModel} method="post">
|
||||
<input type="hidden" name="model" value={id} />
|
||||
|
||||
@@ -8,11 +8,12 @@ import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { ZenData, Model, ProviderPreference } from "@opencode-ai/console-core/model.js"
|
||||
import { Black, BlackData } from "@opencode-ai/console-core/black.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { ModelProviderPreferenceTable } from "@opencode-ai/console-core/schema/model-provider-preference.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import {
|
||||
AuthError,
|
||||
@@ -85,6 +86,7 @@ export async function handler(
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
const billingSource = validateBilling(authInfo, modelInfo)
|
||||
const providerPreference = await getProviderPreference(authInfo, modelInfo.id)
|
||||
|
||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||
const providerInfo = selectProvider(
|
||||
@@ -107,6 +109,14 @@ export async function handler(
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.id === "openrouter" && providerPreference && {
|
||||
provider: {
|
||||
...(providerPreference.order && { order: providerPreference.order }),
|
||||
...(providerPreference.only && { only: providerPreference.only }),
|
||||
...(providerPreference.ignore && { ignore: providerPreference.ignore }),
|
||||
allow_fallbacks: providerPreference.allow_fallbacks ?? true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
@@ -589,6 +599,29 @@ export async function handler(
|
||||
providerInfo.apiKey = authInfo.provider.credentials
|
||||
}
|
||||
|
||||
async function getProviderPreference(authInfo: AuthInfo, modelId: string): Promise<ProviderPreference | undefined> {
|
||||
if (!authInfo) return undefined
|
||||
try {
|
||||
const result = await Database.use((db) =>
|
||||
db
|
||||
.select({ preference: ModelProviderPreferenceTable.provider_preference })
|
||||
.from(ModelProviderPreferenceTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ModelProviderPreferenceTable.workspaceID, authInfo.workspaceID),
|
||||
eq(ModelProviderPreferenceTable.model, modelId),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
)
|
||||
|
||||
if (result.length === 0) return undefined
|
||||
return JSON.parse(result[0].preference) as ProviderPreference
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { Database } from "./drizzle"
|
||||
import { ModelTable } from "./schema/model.sql"
|
||||
import { ModelProviderPreferenceTable } from "./schema/model-provider-preference.sql"
|
||||
import { Identifier } from "./identifier"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
@@ -91,6 +92,14 @@ export namespace ZenData {
|
||||
})
|
||||
}
|
||||
|
||||
export const ProviderPreferenceSchema = z.object({
|
||||
order: z.array(z.string()).optional(),
|
||||
only: z.array(z.string()).optional(),
|
||||
ignore: z.array(z.string()).optional(),
|
||||
allow_fallbacks: z.boolean().optional(),
|
||||
})
|
||||
export type ProviderPreference = z.infer<typeof ProviderPreferenceSchema>
|
||||
|
||||
export namespace Model {
|
||||
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||
Actor.assertAdmin()
|
||||
@@ -143,4 +152,77 @@ export namespace Model {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const setProviderPreference = fn(
|
||||
z.object({
|
||||
model: z.string(),
|
||||
preference: ProviderPreferenceSchema,
|
||||
}),
|
||||
({ model, preference }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.insert(ModelProviderPreferenceTable)
|
||||
.values({
|
||||
id: Identifier.create("model_provider_pref"),
|
||||
workspaceID: Actor.workspace(),
|
||||
model: model,
|
||||
provider_preference: JSON.stringify(preference),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
provider_preference: JSON.stringify(preference),
|
||||
timeDeleted: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const getProviderPreference = fn(
|
||||
z.object({ model: z.string() }),
|
||||
({ model }) => {
|
||||
return Database.use(async (db) => {
|
||||
const result = await db
|
||||
.select({ preference: ModelProviderPreferenceTable.provider_preference })
|
||||
.from(ModelProviderPreferenceTable)
|
||||
.where(and(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace()), eq(ModelProviderPreferenceTable.model, model)))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) return undefined
|
||||
try {
|
||||
return ProviderPreferenceSchema.parse(JSON.parse(result[0].preference))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const deleteProviderPreference = fn(
|
||||
z.object({ model: z.string() }),
|
||||
({ model }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((db) =>
|
||||
db.delete(ModelProviderPreferenceTable).where(and(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace()), eq(ModelProviderPreferenceTable.model, model))),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const listProviderPreferences = fn(z.void(), () => {
|
||||
return Database.use(async (db) => {
|
||||
const results = await db
|
||||
.select({ model: ModelProviderPreferenceTable.model, preference: ModelProviderPreferenceTable.provider_preference })
|
||||
.from(ModelProviderPreferenceTable)
|
||||
.where(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace()))
|
||||
|
||||
const parsed: Record<string, ProviderPreference> = {}
|
||||
for (const row of results) {
|
||||
try {
|
||||
parsed[row.model] = ProviderPreferenceSchema.parse(JSON.parse(row.preference))
|
||||
} catch {}
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { mysqlTable, varchar, text, integer, uniqueIndex } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const ModelProviderPreferenceTable = mysqlTable(
|
||||
"model_provider_preference",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
model: varchar("model", { length: 128 }).notNull(),
|
||||
provider_preference: text("provider_preference").notNull(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("model_provider_preference_workspace_model").on(table.workspaceID, table.model)],
|
||||
)
|
||||
@@ -1030,6 +1030,15 @@ export namespace Config {
|
||||
)
|
||||
.optional()
|
||||
.describe("Variant-specific configuration"),
|
||||
providerPreference: z
|
||||
.object({
|
||||
order: z.array(z.string()).optional().describe("Provider routing order (e.g., ['anthropic', 'openai'])"),
|
||||
only: z.array(z.string()).optional().describe("Whitelist of providers to use"),
|
||||
ignore: z.array(z.string()).optional().describe("Blacklist of providers to skip"),
|
||||
allow_fallbacks: z.boolean().optional().describe("Allow fallback to other providers (default: true)"),
|
||||
})
|
||||
.optional()
|
||||
.describe("OpenRouter provider preference for this model"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
@@ -940,7 +940,10 @@ export namespace Provider {
|
||||
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
|
||||
options: mergeDeep(
|
||||
mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
|
||||
model.providerPreference ? { providerPreference: model.providerPreference } : {}
|
||||
),
|
||||
limit: {
|
||||
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
|
||||
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
|
||||
@@ -1177,6 +1180,27 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
// Inject OpenRouter provider preference for OpenRouter requests
|
||||
if (model.providerID === "openrouter" && opts.body && opts.method === "POST") {
|
||||
const providerPref = model.options?.providerPreference as {
|
||||
order?: string[]
|
||||
only?: string[]
|
||||
ignore?: string[]
|
||||
allow_fallbacks?: boolean
|
||||
} | undefined
|
||||
|
||||
if (providerPref && (providerPref.order || providerPref.only || providerPref.ignore)) {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
body.provider = {
|
||||
...(providerPref.order && { order: providerPref.order }),
|
||||
...(providerPref.only && { only: providerPref.only }),
|
||||
...(providerPref.ignore && { ignore: providerPref.ignore }),
|
||||
allow_fallbacks: providerPref.allow_fallbacks ?? true,
|
||||
}
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
return fetchFn(input, {
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
|
||||
@@ -462,9 +462,7 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
export function shouldLimitToolLoopForModel(model: Provider.Model): boolean {
|
||||
if (model.providerID === "chutes") return true
|
||||
const url = model.api.url?.toLowerCase() || ""
|
||||
return url.includes("chutes.ai") || url.includes("/chutes/")
|
||||
return false
|
||||
}
|
||||
|
||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
|
||||
@@ -487,25 +485,13 @@ export namespace LLM {
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldLimitToolLoop(input: {
|
||||
function shouldLimitToolLoop(input: {
|
||||
model: Provider.Model
|
||||
provider: Provider.Info
|
||||
options: Record<string, any>
|
||||
activeTools: string[]
|
||||
}) {
|
||||
if (input.activeTools.length === 0) return false
|
||||
if (input.model.providerID === "chutes") return true
|
||||
|
||||
const url = [
|
||||
input.model.api.url,
|
||||
input.provider.options?.baseURL,
|
||||
input.provider.options?.baseUrl,
|
||||
input.options?.baseURL,
|
||||
input.options?.baseUrl,
|
||||
]
|
||||
.filter((x) => typeof x === "string")
|
||||
.map((x) => x.toLowerCase())
|
||||
|
||||
return url.some((x) => x.includes("chutes.ai") || x.includes("/chutes/"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
155
opencode/packages/opencode/src/session/prompt/chutes.txt
Normal file
155
opencode/packages/opencode/src/session/prompt/chutes.txt
Normal file
@@ -0,0 +1,155 @@
|
||||
You are PluginCompass, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using PluginCompass
|
||||
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
|
||||
|
||||
When the user directly asks about PluginCompass (eg 'can PluginCompass do...', 'does PluginCompass have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from PluginCompass docs at https://opencode.ai
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: 2 + 2
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: is 11 a prime number?
|
||||
assistant: Yes
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to list files in the current directory?
|
||||
assistant: ls
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to watch files in the current directory?
|
||||
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
||||
npm run dev
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: How many golf balls fit inside a jetta?
|
||||
assistant: 150000
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what files are in the directory src/?
|
||||
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||
user: which file contains the implementation of foo?
|
||||
assistant: src/foo.c
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||
</example>
|
||||
|
||||
# Proactiveness
|
||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||
2. Not surprising the user with actions you take without asking
|
||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
|
||||
# Following conventions
|
||||
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
|
||||
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
|
||||
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
|
||||
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
|
||||
|
||||
# Code style
|
||||
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||
- Implement the solution using all tools available to you
|
||||
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
||||
- You have the capability to call multiple tools in a single response. If you intend to run tools in parallel, Maximize use of parallel calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead run them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead of using placeholders or guessing missing parameters in tool calls.
|
||||
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. Use dedicated tools: Read for reading files, Edit for modifying files, and Write only when creating new files. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
|
||||
# Cost Optimization for Chutes AI
|
||||
|
||||
IMPORTANT: Each tool call to this API counts as a separate costed request. To minimize costs and improve efficiency, you should ALWAYS batch multiple independent operations into a single batch tool call whenever possible.
|
||||
|
||||
## ALWAYS Use the Batch Tool for Multiple Operations
|
||||
|
||||
The `batch` tool is your PRIMARY tool for efficiency. USE IT PROACTIVELY for any multiple independent operations.
|
||||
|
||||
**When you have multiple independent operations, ALWAYS combine them into a single batch call:**
|
||||
|
||||
- Reading multiple files → ONE batch call with multiple read operations
|
||||
- Searching with grep + glob + reading results → ONE batch call
|
||||
- Multiple file edits on different files → ONE batch call
|
||||
- Multiple bash commands that don't depend on each other → ONE batch call
|
||||
|
||||
**Example - Instead of:**
|
||||
```
|
||||
tool_call: read file1.ts
|
||||
tool_call: read file2.ts
|
||||
tool_call: read file3.ts
|
||||
tool_call: grep pattern
|
||||
```
|
||||
|
||||
**DO THIS:**
|
||||
```
|
||||
tool_call: batch {
|
||||
tool_calls: [
|
||||
{"tool": "read", "parameters": {"filePath": "file1.ts"}},
|
||||
{"tool": "read", "parameters": {"filePath": "file2.ts"}},
|
||||
{"tool": "read", "parameters": {"filePath": "file3.ts"}},
|
||||
{"tool": "grep", "parameters": {"pattern": "..."}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
- Reduces API round-trips by 3-5x
|
||||
- Lowers total cost significantly
|
||||
- Provides faster responses
|
||||
|
||||
**Exceptions - Do NOT batch when:**
|
||||
- One operation depends on the result of another (read → edit same file)
|
||||
- Sequential stateful operations where order matters
|
||||
|
||||
Batching tool calls was proven to yield 2-5x efficiency gain and provides much better UX.
|
||||
@@ -14,26 +14,30 @@ CRITICAL SECURITY REQUIREMENTS
|
||||
|
||||
You must never try to or attempt to or ask the user for permission to edit files outside of the workspace you are editing in.
|
||||
|
||||
CRITICAL VALIDATION REQUIREMENTS - YOU MUST USE THE MCP TOOL:
|
||||
CRITICAL VALIDATION REQUIREMENTS - YOU MUST USE VALIDATION TOOLS:
|
||||
|
||||
You have access to a built-in MCP tool called `wordpress-validator:validate_wordpress_plugin` that runs comprehensive validation checks. This tool MUST be called before completing ANY WordPress plugin work.
|
||||
You have access to validation tools for WordPress plugins. One of these tools MUST be called before completing ANY WordPress plugin work.
|
||||
|
||||
AVAILABLE VALIDATION TOOLS (use either):
|
||||
1. MCP Tool: `wordpress-validator:validate_wordpress_plugin` - Preferred when available
|
||||
2. Built-in Tool: `validate_wordpress_plugin` - Fallback if MCP not available
|
||||
|
||||
MANDATORY VALIDATION WORKFLOW:
|
||||
1. After creating or modifying any WordPress plugin files, you MUST call the MCP validation tool
|
||||
1. After creating or modifying any WordPress plugin files, you MUST call a validation tool
|
||||
2. Use the tool with: `{ "plugin_path": "/absolute/path/to/plugin" }`
|
||||
3. The tool will return a JSON result with a summary
|
||||
4. If validation passes: You will see "✓ All validation checks passed"
|
||||
4. If validation passes: You will see "All validation checks passed"
|
||||
5. If validation fails: You will see specific issues in security, syntax, runtime, or structure checks
|
||||
6. Do NOT mark the work complete until you see "✓ All validation checks passed"
|
||||
6. Do NOT mark the work complete until validation passes
|
||||
7. If validation fails, fix all reported issues and re-run the tool until it passes
|
||||
|
||||
The MCP tool performs the following checks:
|
||||
The validation tools perform the following checks:
|
||||
- Security: Forbidden functions, SQL injection patterns, XSS vulnerabilities, nonce/capability checks
|
||||
- Syntax: PHP syntax validation, coding standards, undefined variables
|
||||
- Runtime: Duplicate declarations, missing includes, undefined classes/functions
|
||||
- Structure: Plugin headers, file organization, proper WordPress patterns
|
||||
|
||||
CRITICAL: Do not use the old bash scripts directly. Always use the MCP tool for validation.
|
||||
CRITICAL: Always use one of the validation tools before marking work complete.
|
||||
|
||||
STYLING REQUIREMENTS (CRITICAL):
|
||||
9. **Admin Panel Styling:**
|
||||
|
||||
@@ -6,6 +6,7 @@ import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_CHUTES from "./prompt/chutes.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||
@@ -29,6 +30,13 @@ export namespace SystemPrompt {
|
||||
return wordpressDetectionCache
|
||||
}
|
||||
|
||||
// Check for forced WordPress mode via environment variable
|
||||
if (process.env.OPENCODE_FORCE_WORDPRESS === '1') {
|
||||
wordpressDetectionCache = true
|
||||
wordpressDetectionCacheTime = now
|
||||
return true
|
||||
}
|
||||
|
||||
const cwd = Instance.directory
|
||||
if (!cwd) {
|
||||
return false
|
||||
@@ -95,6 +103,12 @@ export namespace SystemPrompt {
|
||||
return hasPluginName || (hasPluginUri && hasWordPressFunctions)
|
||||
}
|
||||
|
||||
export function isChutesModel(model: Provider.Model): boolean {
|
||||
if (model.providerID === "chutes") return true
|
||||
const url = model.api.url?.toLowerCase() || ""
|
||||
return url.includes("chutes.ai") || url.includes("/chutes/")
|
||||
}
|
||||
|
||||
export async function provider(model: Provider.Model, isWordPress: boolean = false) {
|
||||
const basePrompts: string[] = []
|
||||
|
||||
@@ -104,6 +118,7 @@ export namespace SystemPrompt {
|
||||
else if (model.api.id.includes("gemini-")) basePrompts.push(PROMPT_GEMINI)
|
||||
else if (model.api.id.includes("claude")) basePrompts.push(PROMPT_ANTHROPIC)
|
||||
else if (model.api.id.toLowerCase().includes("trinity")) basePrompts.push(PROMPT_TRINITY)
|
||||
else if (isChutesModel(model)) basePrompts.push(PROMPT_CHUTES)
|
||||
else basePrompts.push(PROMPT_ANTHROPIC_WITHOUT_TODO)
|
||||
|
||||
if (isWordPress) {
|
||||
|
||||
@@ -125,16 +125,43 @@ export namespace ToolRegistry {
|
||||
return all().then((x) => x.map((t) => t.id))
|
||||
}
|
||||
|
||||
export async function tools(
|
||||
export async function tools(
|
||||
model: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const tools = await all()
|
||||
const custom = await state().then((x) => x.custom)
|
||||
const config = await Config.get()
|
||||
const isChutes = model.providerID === "chutes"
|
||||
const batchEnabled = config.experimental?.batch_tool === true || isChutes
|
||||
|
||||
const baseTools: Tool.Info[] = [
|
||||
InvalidTool,
|
||||
...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
EditTool,
|
||||
WriteTool,
|
||||
TaskTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
ApplyPatchTool,
|
||||
ValidateWordPressPluginTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(batchEnabled ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...custom,
|
||||
]
|
||||
|
||||
const result = await Promise.all(
|
||||
tools
|
||||
baseTools
|
||||
.filter((t) => {
|
||||
// Enable websearch/codesearch for zen users OR via enable flag
|
||||
if (t.id === "codesearch" || t.id === "websearch") {
|
||||
|
||||
@@ -316,15 +316,15 @@ fi
|
||||
|
||||
if [ -f "$CHAT_APP_DIR/server.js" ]; then
|
||||
log "Launching chat service on ${CHAT_HOST}:${CHAT_PORT} from $CHAT_APP_DIR"
|
||||
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR}"
|
||||
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR node "$CHAT_APP_DIR/server.js" 2>&1 &
|
||||
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR} CHAT_APP_DIR=${CHAT_APP_DIR}"
|
||||
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR CHAT_APP_DIR=$CHAT_APP_DIR node "$CHAT_APP_DIR/server.js" 2>&1 &
|
||||
CHAT_PID=$!
|
||||
log "Chat service started with PID: $CHAT_PID"
|
||||
elif [ -f "$CHAT_APP_FALLBACK/server.js" ]; then
|
||||
log "Primary chat service not found at $CHAT_APP_DIR, trying fallback at $CHAT_APP_FALLBACK"
|
||||
log "Launching chat service on ${CHAT_HOST}:${CHAT_PORT} from $CHAT_APP_FALLBACK"
|
||||
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR}"
|
||||
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR node "$CHAT_APP_FALLBACK/server.js" 2>&1 &
|
||||
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR} CHAT_APP_DIR=${CHAT_APP_FALLBACK}"
|
||||
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR CHAT_APP_DIR=$CHAT_APP_FALLBACK node "$CHAT_APP_FALLBACK/server.js" 2>&1 &
|
||||
CHAT_PID=$!
|
||||
log "Chat service started with PID: $CHAT_PID"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user