- Moved sticky positioning from mobile-only to global CSS for .top-left-actions - Buttons now stay fixed at top above content on all screen sizes - Includes various other app updates (version management, server improvements)
18583 lines
708 KiB
JavaScript
18583 lines
708 KiB
JavaScript
// Simple replacement server using the same functionality as the previous chat server
|
||
// This file is a fresh, cleanized version that includes robust model discovery
|
||
|
||
const http = require('http');
|
||
const fs = require('fs/promises');
|
||
const fsSync = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { spawn } = require('child_process');
|
||
const { randomUUID, randomBytes } = require('crypto');
|
||
const archiver = require('archiver');
|
||
const AdmZip = require('adm-zip');
|
||
const bcrypt = require('bcrypt');
|
||
const jwt = require('jsonwebtoken');
|
||
const nodemailer = require('nodemailer');
|
||
const PDFDocument = require('pdfkit');
|
||
const security = require('./security');
|
||
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
|
||
const blogSystem = require('./blog-system');
|
||
const versionManager = require('./src/utils/versionManager');
|
||
|
||
let sharp = null;
|
||
try {
|
||
// Optional dependency (native). If missing, server will store originals.
|
||
sharp = require('sharp');
|
||
} catch (_) {
|
||
sharp = null;
|
||
}
|
||
|
||
const KIB = 1024;
|
||
const MIB = KIB * 1024;
|
||
const GIB = MIB * 1024;
|
||
const TIB = GIB * 1024;
|
||
|
||
function parseMemoryValue(raw) {
|
||
if (raw === undefined || raw === null) return 0;
|
||
const str = String(raw).trim();
|
||
const match = str.match(/^([0-9.]+)\s*([kKmMgGtT]i?)?(?:[bB])?$/);
|
||
if (!match) return Number(str) || 0;
|
||
const value = parseFloat(match[1]);
|
||
const unit = (match[2] || '').toLowerCase();
|
||
// Binary unit multipliers (KiB, MiB, GiB, TiB)
|
||
const multipliers = { '': 1, k: KIB, ki: KIB, m: MIB, mi: MIB, g: GIB, gi: GIB, t: TIB, ti: TIB };
|
||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||
return Math.round(value * (multipliers[unit] || 1));
|
||
}
|
||
|
||
/**
|
||
* Normalize interval values so misconfigured env vars can't create tight loops.
|
||
* @param {string|number|undefined} raw
|
||
* @param {number} fallback
|
||
* @param {number} min
|
||
* @returns {number}
|
||
*/
|
||
function resolveIntervalMs(raw, fallback, min) {
|
||
const parsed = Number(raw);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||
return Math.max(parsed, min);
|
||
}
|
||
|
||
function resolvePositiveInt(raw, fallback) {
|
||
const parsed = Number(raw);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||
return Math.round(parsed);
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
if (ms < 1000) return Math.round(ms) + 'ms';
|
||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||
if (ms < 3600000) return (ms / 60000).toFixed(1) + 'm';
|
||
if (ms < 86400000) return (ms / 3600000).toFixed(1) + 'h';
|
||
return (ms / 86400000).toFixed(1) + 'd';
|
||
}
|
||
|
||
function detectDockerLimits(baseMemoryBytes, baseCpuCores, repoRoot) {
|
||
let memoryBytes = baseMemoryBytes;
|
||
let cpuCores = baseCpuCores;
|
||
const root = repoRoot || process.cwd();
|
||
// Best-effort parse of docker compose files without adding a YAML dependency; unexpected formats fall back to provided defaults.
|
||
const candidates = ['stack-portainer.yml', 'docker-compose.yml'];
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const content = fsSync.readFileSync(path.join(root, candidate), 'utf8');
|
||
const memMatch = content.match(/memory:\s*['"]?([0-9.]+\s*[kKmMgGtT]i?[bB]?)/);
|
||
if (memMatch) {
|
||
const parsed = parseMemoryValue(memMatch[1]);
|
||
if (parsed > 0) memoryBytes = parsed;
|
||
}
|
||
const cpuMatch = content.match(/cpus:\s*['"]?([0-9.]+)/);
|
||
if (cpuMatch) {
|
||
const parsedCpu = Number(cpuMatch[1]);
|
||
if (Number.isFinite(parsedCpu) && parsedCpu > 0) cpuCores = parsedCpu;
|
||
}
|
||
} catch (_) {
|
||
// Ignore missing or unreadable docker config files and fall back to defaults
|
||
}
|
||
}
|
||
return { memoryBytes, cpuCores };
|
||
}
|
||
|
||
const PORT = Number(process.env.CHAT_PORT || 4000);
|
||
const HOST = process.env.CHAT_HOST || '0.0.0.0';
|
||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
|
||
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
|
||
const STATE_FILE = path.join(STATE_DIR, 'sessions.json');
|
||
const WORKSPACES_ROOT = path.join(DATA_ROOT, 'apps');
|
||
const STATIC_ROOT = path.join(__dirname, 'public');
|
||
const UPLOADS_DIR = path.join(STATE_DIR, 'uploads');
|
||
const REPO_ROOT = process.env.CHAT_REPO_ROOT || process.cwd();
|
||
const OPENCODE_REPO_ROOT = path.join(REPO_ROOT, 'opencode');
|
||
const OPENCODE_REPO_CLI = path.join(OPENCODE_REPO_ROOT, 'packages', 'opencode', 'bin', 'opencode');
|
||
const OPENCODE_PROMPT_DIR = path.join(OPENCODE_REPO_ROOT, 'packages', 'opencode', 'src', 'session', 'prompt');
|
||
const OPENCODE_REQUIRE_REPO = process.env.OPENCODE_REQUIRE_REPO !== 'false';
|
||
const DEFAULT_OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||
const OPENROUTER_API_URL = process.env.OPENROUTER_API_URL || DEFAULT_OPENROUTER_API_URL;
|
||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_TOKEN || '';
|
||
// Separate paid API key for premium models (optional)
|
||
const OPENROUTER_PAID_API_KEY = process.env.OPENROUTER_PAID_API_KEY || process.env.OPENROUTER_PAID_API_TOKEN || OPENROUTER_API_KEY;
|
||
const OPENCODE_OLLAMA_API_KEY = process.env.OPENCODE_OLLAMA_API_KEY || '';
|
||
const OPENCODE_OLLAMA_BASE_URL = process.env.OPENCODE_OLLAMA_BASE_URL || 'https://ollama.plugincompass.com';
|
||
const OPENCODE_OLLAMA_MODEL = process.env.OPENCODE_OLLAMA_MODEL || 'qwen3:0.6b';
|
||
const OPENCODE_OLLAMA_PROVIDER = process.env.OPENCODE_OLLAMA_PROVIDER || 'openai';
|
||
|
||
// Ollama self-hosted (planning provider) - can be set via OLLAMA_* env vars or legacy OPENCODE_OLLAMA_* vars
|
||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL || OPENCODE_OLLAMA_BASE_URL || '';
|
||
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || process.env.OLLAMA_API_TOKEN || OPENCODE_OLLAMA_API_KEY || '';
|
||
const OLLAMA_DEFAULT_MODEL = process.env.OLLAMA_DEFAULT_MODEL || OPENCODE_OLLAMA_MODEL || '';
|
||
const OPENROUTER_MODEL_PRIMARY = process.env.OPENROUTER_MODEL_PRIMARY || process.env.OPENROUTER_MODEL || '';
|
||
const OPENROUTER_MODEL_BACKUP_1 = process.env.OPENROUTER_MODEL_BACKUP_1 || process.env.OPENROUTER_MODEL_BACKUP1 || '';
|
||
const OPENROUTER_MODEL_BACKUP_2 = process.env.OPENROUTER_MODEL_BACKUP_2 || process.env.OPENROUTER_MODEL_BACKUP2 || '';
|
||
const OPENROUTER_MODEL_BACKUP_3 = process.env.OPENROUTER_MODEL_BACKUP_3 || process.env.OPENROUTER_MODEL_BACKUP3 || '';
|
||
const OPENROUTER_FALLBACK_MODELS = process.env.OPENROUTER_FALLBACK_MODELS
|
||
? process.env.OPENROUTER_FALLBACK_MODELS.split(',').map((m) => m.trim()).filter(Boolean)
|
||
: [];
|
||
const OPENROUTER_STATIC_FALLBACK_MODELS = [
|
||
'anthropic/claude-3.5-sonnet',
|
||
'openai/gpt-4o-mini',
|
||
'mistralai/mistral-large-latest',
|
||
'google/gemini-flash-1.5',
|
||
];
|
||
const OPENROUTER_DEFAULT_MODEL = process.env.OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini';
|
||
const OPENROUTER_PLAN_PROMPT_PATH = process.env.OPENROUTER_PLAN_PROMPT_PATH || path.join(STATIC_ROOT, 'openrouter-plan-prompt.txt');
|
||
const OPENROUTER_APP_NAME = process.env.OPENROUTER_APP_NAME || 'Shopify AI App Builder';
|
||
const OPENROUTER_SITE_URL = process.env.OPENROUTER_SITE_URL || process.env.OPENROUTER_SITE || '';
|
||
const OPENROUTER_ERROR_DETAIL_LIMIT = 400;
|
||
|
||
// External directory restriction for OpenCode (auto-deny based on app ID)
|
||
const ENABLE_EXTERNAL_DIR_RESTRICTION = process.env.ENABLE_EXTERNAL_DIR_RESTRICTION !== 'false';
|
||
let warnedOpenRouterApiUrl = false;
|
||
|
||
// Mistral configuration
|
||
const DEFAULT_MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
||
const MISTRAL_API_URL = process.env.MISTRAL_API_URL || DEFAULT_MISTRAL_API_URL;
|
||
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY || '';
|
||
const MISTRAL_MODEL_PRIMARY = process.env.MISTRAL_MODEL_PRIMARY || process.env.MISTRAL_MODEL || '';
|
||
const MISTRAL_MODEL_BACKUP_1 = process.env.MISTRAL_MODEL_BACKUP_1 || process.env.MISTRAL_MODEL_BACKUP1 || '';
|
||
const MISTRAL_MODEL_BACKUP_2 = process.env.MISTRAL_MODEL_BACKUP_2 || process.env.MISTRAL_MODEL_BACKUP2 || '';
|
||
const MISTRAL_MODEL_BACKUP_3 = process.env.MISTRAL_MODEL_BACKUP_3 || process.env.MISTRAL_MODEL_BACKUP3 || '';
|
||
const MISTRAL_DEFAULT_MODEL = process.env.MISTRAL_DEFAULT_MODEL || 'mistral-large-latest';
|
||
const MISTRAL_ERROR_DETAIL_LIMIT = 400;
|
||
|
||
// Optional direct provider credentials (set via environment variables)
|
||
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || process.env.GOOGLE_API_TOKEN || '';
|
||
const GOOGLE_API_URL = process.env.GOOGLE_API_URL || 'https://generativelanguage.googleapis.com/v1beta2';
|
||
const GROQ_API_KEY = process.env.GROQ_API_KEY || process.env.GROQ_API_TOKEN || '';
|
||
const GROQ_API_URL = process.env.GROQ_API_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||
const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY || process.env.NVIDIA_API_TOKEN || '';
|
||
const NVIDIA_API_URL = process.env.NVIDIA_API_URL || 'https://api.nvidia.com/v1';
|
||
const CHUTES_API_KEY = process.env.PLUGIN_COMPASS_CHUTES_API_KEY || process.env.CHUTES_API_KEY || process.env.CHUTES_API_TOKEN || '';
|
||
const CHUTES_API_URL = process.env.CHUTES_API_URL || 'https://api.chutes.ai/v1';
|
||
|
||
const PROVIDER_LIMITS_FILE = path.join(STATE_DIR, 'provider-limits.json');
|
||
const PROVIDER_USAGE_FILE = path.join(STATE_DIR, 'provider-usage.json');
|
||
const TOKEN_USAGE_FILE = path.join(STATE_DIR, 'token-usage.json');
|
||
const EXTERNAL_TEST_USAGE_FILE = path.join(STATE_DIR, 'external-test-usage.json');
|
||
const TOPUP_SESSIONS_FILE = path.join(STATE_DIR, 'topup-sessions.json');
|
||
const TOPUP_PENDING_FILE = path.join(STATE_DIR, 'topup-pending.json');
|
||
const PAYG_SESSIONS_FILE = path.join(STATE_DIR, 'payg-sessions.json');
|
||
const PAYG_PENDING_FILE = path.join(STATE_DIR, 'payg-pending.json');
|
||
const SUBSCRIPTION_SESSIONS_FILE = path.join(STATE_DIR, 'subscription-sessions.json');
|
||
const SUBSCRIPTION_PENDING_FILE = path.join(STATE_DIR, 'subscription-pending.json');
|
||
const PLAN_TOKENS_FILE = path.join(STATE_DIR, 'plan-tokens.json');
|
||
const TOKEN_RATES_FILE = path.join(STATE_DIR, 'token-rates.json');
|
||
const FEATURE_REQUESTS_FILE = path.join(STATE_DIR, 'feature-requests.json');
|
||
const CONTACT_MESSAGES_FILE = path.join(STATE_DIR, 'contact-messages.json');
|
||
const INVOICES_FILE = path.join(STATE_DIR, 'invoices.json');
|
||
const INVOICES_DIR = path.join(STATE_DIR, 'invoices');
|
||
// One-off top-up discounts (Business 2.5%, Enterprise 5%; boost add-ons keep higher 10%/25% rates)
|
||
const BUSINESS_TOPUP_DISCOUNT = 0.025;
|
||
const ENTERPRISE_TOPUP_DISCOUNT = 0.05;
|
||
const MIN_PAYMENT_AMOUNT = (() => {
|
||
const parsed = Number(process.env.DODO_MIN_AMOUNT || 50);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 50;
|
||
})();
|
||
const DODO_PAYMENTS_API_KEY = process.env.DODO_PAYMENTS_API_KEY || process.env.DODO_API_KEY || '';
|
||
const DODO_ENVIRONMENT = (process.env.DODO_PAYMENTS_ENV || process.env.DODO_ENVIRONMENT || process.env.DODO_ENV || 'test').toLowerCase();
|
||
const DODO_BASE_URL = DODO_ENVIRONMENT.includes('live') ? 'https://live.dodopayments.com' : 'https://test.dodopayments.com';
|
||
const DODO_ENABLED = Boolean(DODO_PAYMENTS_API_KEY);
|
||
// Token top-up product IDs (4 options × 3 currencies)
|
||
const TOPUP_PRODUCT_IDS = {
|
||
// Top-up Option 1 (100,000 tokens)
|
||
topup_1_usd: process.env.DODO_TOPUP_1_USD || '',
|
||
topup_1_gbp: process.env.DODO_TOPUP_1_GBP || '',
|
||
topup_1_eur: process.env.DODO_TOPUP_1_EUR || '',
|
||
|
||
// Top-up Option 2 (5,000,000 tokens)
|
||
topup_2_usd: process.env.DODO_TOPUP_2_USD || '',
|
||
topup_2_gbp: process.env.DODO_TOPUP_2_GBP || '',
|
||
topup_2_eur: process.env.DODO_TOPUP_2_EUR || '',
|
||
|
||
// Top-up Option 3 (20,000,000 tokens)
|
||
topup_3_usd: process.env.DODO_TOPUP_3_USD || '',
|
||
topup_3_gbp: process.env.DODO_TOPUP_3_GBP || '',
|
||
topup_3_eur: process.env.DODO_TOPUP_3_EUR || '',
|
||
|
||
// Top-up Option 4 (50,000,000 tokens)
|
||
topup_4_usd: process.env.DODO_TOPUP_4_USD || '',
|
||
topup_4_gbp: process.env.DODO_TOPUP_4_GBP || '',
|
||
topup_4_eur: process.env.DODO_TOPUP_4_EUR || '',
|
||
};
|
||
const TOPUP_TOKENS = {
|
||
topup_1: resolvePositiveInt(process.env.DODO_TOPUP_TOKENS_1, 100_000),
|
||
topup_2: resolvePositiveInt(process.env.DODO_TOPUP_TOKENS_2, 5_000_000),
|
||
topup_3: resolvePositiveInt(process.env.DODO_TOPUP_TOKENS_3, 20_000_000),
|
||
topup_4: resolvePositiveInt(process.env.DODO_TOPUP_TOKENS_4, 50_000_000),
|
||
};
|
||
|
||
// Pay-as-you-go (overage) product IDs (1 option × 3 currencies)
|
||
const PAYG_PRODUCT_IDS = {
|
||
usd: process.env.DODO_PAYG_USD || '',
|
||
gbp: process.env.DODO_PAYG_GBP || '',
|
||
eur: process.env.DODO_PAYG_EUR || '',
|
||
};
|
||
|
||
// Pay-as-you-go pricing in minor units per PAYG unit (defaults: $2.50 / €2.50 / £2.00 per 1M tokens)
|
||
const PAYG_UNIT_TOKENS = Math.max(1, Number(process.env.DODO_PAYG_UNIT_TOKENS || 1_000_000));
|
||
const PAYG_PRICES = {
|
||
usd: Number(process.env.DODO_PAYG_USD_AMOUNT || 250),
|
||
gbp: Number(process.env.DODO_PAYG_GBP_AMOUNT || 200),
|
||
eur: Number(process.env.DODO_PAYG_EUR_AMOUNT || 250),
|
||
};
|
||
const PAYG_MIN_TOKENS = Math.max(0, Number(process.env.DODO_PAYG_MIN_TOKENS || 0));
|
||
const PAYG_ENABLED = (process.env.DODO_PAYG_ENABLED || '1') !== '0';
|
||
|
||
// Usage-based billing events (Dodo meters)
|
||
const DODO_USAGE_EVENTS_ENABLED = (process.env.DODO_USAGE_EVENTS_ENABLED || '1') !== '0';
|
||
const DODO_USAGE_EVENT_NAME = process.env.DODO_USAGE_EVENT_NAME || 'token.usage';
|
||
const DODO_USAGE_EVENT_COST_FIELD = process.env.DODO_USAGE_EVENT_COST_FIELD || 'costCents';
|
||
const DODO_USAGE_EVENT_TOKENS_FIELD = process.env.DODO_USAGE_EVENT_TOKENS_FIELD || 'billableTokens';
|
||
|
||
// Subscription product IDs for plans (plan_billing_currency format)
|
||
const SUBSCRIPTION_PRODUCT_IDS = {
|
||
// Starter Plan Products
|
||
starter_monthly_usd: process.env.DODO_STARTER_MONTHLY_USD || 'prod_starter_monthly_usd',
|
||
starter_yearly_usd: process.env.DODO_STARTER_YEARLY_USD || 'prod_starter_yearly_usd',
|
||
starter_monthly_gbp: process.env.DODO_STARTER_MONTHLY_GBP || 'prod_starter_monthly_gbp',
|
||
starter_yearly_gbp: process.env.DODO_STARTER_YEARLY_GBP || 'prod_starter_yearly_gbp',
|
||
starter_monthly_eur: process.env.DODO_STARTER_MONTHLY_EUR || 'prod_starter_monthly_eur',
|
||
starter_yearly_eur: process.env.DODO_STARTER_YEARLY_EUR || 'prod_starter_yearly_eur',
|
||
|
||
// Professional Plan Products
|
||
professional_monthly_usd: process.env.DODO_PROFESSIONAL_MONTHLY_USD || 'prod_professional_monthly_usd',
|
||
professional_yearly_usd: process.env.DODO_PROFESSIONAL_YEARLY_USD || 'prod_professional_yearly_usd',
|
||
professional_monthly_gbp: process.env.DODO_PROFESSIONAL_MONTHLY_GBP || 'prod_professional_monthly_gbp',
|
||
professional_yearly_gbp: process.env.DODO_PROFESSIONAL_YEARLY_GBP || 'prod_professional_yearly_gbp',
|
||
professional_monthly_eur: process.env.DODO_PROFESSIONAL_MONTHLY_EUR || 'prod_professional_monthly_eur',
|
||
professional_yearly_eur: process.env.DODO_PROFESSIONAL_YEARLY_EUR || 'prod_professional_yearly_eur',
|
||
|
||
// Enterprise Plan Products
|
||
enterprise_monthly_usd: process.env.DODO_ENTERPRISE_MONTHLY_USD || 'prod_enterprise_monthly_usd',
|
||
enterprise_yearly_usd: process.env.DODO_ENTERPRISE_YEARLY_USD || 'prod_enterprise_yearly_usd',
|
||
enterprise_monthly_gbp: process.env.DODO_ENTERPRISE_MONTHLY_GBP || 'prod_enterprise_monthly_gbp',
|
||
enterprise_yearly_gbp: process.env.DODO_ENTERPRISE_YEARLY_GBP || 'prod_enterprise_yearly_gbp',
|
||
enterprise_monthly_eur: process.env.DODO_ENTERPRISE_MONTHLY_EUR || 'prod_enterprise_monthly_eur',
|
||
enterprise_yearly_eur: process.env.DODO_ENTERPRISE_YEARLY_EUR || 'prod_enterprise_yearly_eur',
|
||
};
|
||
|
||
// Subscription pricing (in cents/minor units)
|
||
const SUBSCRIPTION_PRICES = {
|
||
// Starter Plan Prices
|
||
starter_monthly_usd: Number(process.env.DODO_STARTER_MONTHLY_USD_AMOUNT || 750),
|
||
starter_yearly_usd: Number(process.env.DODO_STARTER_YEARLY_USD_AMOUNT || 7500),
|
||
starter_monthly_gbp: Number(process.env.DODO_STARTER_MONTHLY_GBP_AMOUNT || 625),
|
||
starter_yearly_gbp: Number(process.env.DODO_STARTER_YEARLY_GBP_AMOUNT || 6250),
|
||
starter_monthly_eur: Number(process.env.DODO_STARTER_MONTHLY_EUR_AMOUNT || 750),
|
||
starter_yearly_eur: Number(process.env.DODO_STARTER_YEARLY_EUR_AMOUNT || 7500),
|
||
|
||
// Professional Plan Prices
|
||
professional_monthly_usd: Number(process.env.DODO_PROFESSIONAL_MONTHLY_USD_AMOUNT || 2500),
|
||
professional_yearly_usd: Number(process.env.DODO_PROFESSIONAL_YEARLY_USD_AMOUNT || 25000),
|
||
professional_monthly_gbp: Number(process.env.DODO_PROFESSIONAL_MONTHLY_GBP_AMOUNT || 2100),
|
||
professional_yearly_gbp: Number(process.env.DODO_PROFESSIONAL_YEARLY_GBP_AMOUNT || 21000),
|
||
professional_monthly_eur: Number(process.env.DODO_PROFESSIONAL_MONTHLY_EUR_AMOUNT || 2500),
|
||
professional_yearly_eur: Number(process.env.DODO_PROFESSIONAL_YEARLY_EUR_AMOUNT || 25000),
|
||
|
||
// Enterprise Plan Prices
|
||
enterprise_monthly_usd: Number(process.env.DODO_ENTERPRISE_MONTHLY_USD_AMOUNT || 7500),
|
||
enterprise_yearly_usd: Number(process.env.DODO_ENTERPRISE_YEARLY_USD_AMOUNT || 75000),
|
||
enterprise_monthly_gbp: Number(process.env.DODO_ENTERPRISE_MONTHLY_GBP_AMOUNT || 6250),
|
||
enterprise_yearly_gbp: Number(process.env.DODO_ENTERPRISE_YEARLY_GBP_AMOUNT || 62500),
|
||
enterprise_monthly_eur: Number(process.env.DODO_ENTERPRISE_MONTHLY_EUR_AMOUNT || 7500),
|
||
enterprise_yearly_eur: Number(process.env.DODO_ENTERPRISE_YEARLY_EUR_AMOUNT || 75000),
|
||
};
|
||
|
||
// Token top-up pricing (in cents/minor units) - 4 options × 3 currencies
|
||
// Option 1: 100,000 tokens, Option 2: 5,000,000 tokens, Option 3: 20,000,000 tokens, Option 4: 50,000,000 tokens
|
||
const TOPUP_PRICES = {
|
||
// Top-up Option 1 (100,000 tokens)
|
||
topup_1_usd: resolvePositiveInt(process.env.DODO_TOPUP_1_USD_AMOUNT, 750),
|
||
topup_1_gbp: resolvePositiveInt(process.env.DODO_TOPUP_1_GBP_AMOUNT, 500),
|
||
topup_1_eur: resolvePositiveInt(process.env.DODO_TOPUP_1_EUR_AMOUNT, 750),
|
||
|
||
// Top-up Option 2 (5,000,000 tokens)
|
||
topup_2_usd: resolvePositiveInt(process.env.DODO_TOPUP_2_USD_AMOUNT, 2500),
|
||
topup_2_gbp: resolvePositiveInt(process.env.DODO_TOPUP_2_GBP_AMOUNT, 2000),
|
||
topup_2_eur: resolvePositiveInt(process.env.DODO_TOPUP_2_EUR_AMOUNT, 2500),
|
||
|
||
// Top-up Option 3 (20,000,000 tokens)
|
||
topup_3_usd: resolvePositiveInt(process.env.DODO_TOPUP_3_USD_AMOUNT, 7500),
|
||
topup_3_gbp: resolvePositiveInt(process.env.DODO_TOPUP_3_GBP_AMOUNT, 6000),
|
||
topup_3_eur: resolvePositiveInt(process.env.DODO_TOPUP_3_EUR_AMOUNT, 7500),
|
||
|
||
// Top-up Option 4 (50,000,000 tokens)
|
||
topup_4_usd: resolvePositiveInt(process.env.DODO_TOPUP_4_USD_AMOUNT, 12500),
|
||
topup_4_gbp: resolvePositiveInt(process.env.DODO_TOPUP_4_GBP_AMOUNT, 10000),
|
||
topup_4_eur: resolvePositiveInt(process.env.DODO_TOPUP_4_EUR_AMOUNT, 12500),
|
||
};
|
||
|
||
// Supported billing cycles and currencies
|
||
const BILLING_CYCLES = ['monthly', 'yearly'];
|
||
const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur'];
|
||
const MINUTE_MS = 60_000;
|
||
const HOUR_MS = 3_600_000;
|
||
const DAY_MS = 86_400_000;
|
||
const DODO_PRODUCTS_CACHE_TTL_MS = Math.max(30_000, Number(process.env.DODO_PRODUCTS_CACHE_TTL_MS || 5 * MINUTE_MS));
|
||
const FORTY_EIGHT_HOURS_MS = 48 * DAY_MS;
|
||
const AVG_CHARS_PER_TOKEN = 4; // rough heuristic
|
||
const MAX_JSON_BODY_SIZE = Number(process.env.MAX_JSON_BODY_SIZE || 6_000_000); // 6 MB default for JSON payloads (attachments)
|
||
const MAX_ATTACHMENT_SIZE = Number(process.env.MAX_ATTACHMENT_SIZE || 5_000_000); // 5 MB limit per attachment
|
||
const AFFILIATES_FILE = path.join(STATE_DIR, 'affiliates.json');
|
||
const WITHDRAWALS_FILE = path.join(STATE_DIR, 'withdrawals.json');
|
||
const AFFILIATE_COOKIE_NAME = 'affiliate_session';
|
||
const AFFILIATE_REF_COOKIE = 'affiliate_ref';
|
||
const AFFILIATE_SESSION_TTL_MS = Number(process.env.AFFILIATE_SESSION_TTL_MS || 30 * DAY_MS);
|
||
const AFFILIATE_COMMISSION_RATE = 0.075;
|
||
const AFFILIATE_REF_COOKIE_TTL_MS = 30 * DAY_MS;
|
||
const AFFILIATE_REF_COOKIE_TTL_SECONDS = Math.floor(AFFILIATE_REF_COOKIE_TTL_MS / 1000);
|
||
const ADMIN_USER = process.env.ADMIN_USER || process.env.ADMIN_EMAIL || '';
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.ADMIN_PASS || '';
|
||
const ADMIN_SESSION_TTL_MS = Number(process.env.ADMIN_SESSION_TTL_MS || 86_400_000); // default 24h
|
||
const ADMIN_MODELS_FILE = path.join(STATE_DIR, 'admin-models.json');
|
||
const OPENROUTER_SETTINGS_FILE = path.join(STATE_DIR, 'openrouter-settings.json');
|
||
const MISTRAL_SETTINGS_FILE = path.join(STATE_DIR, 'mistral-settings.json');
|
||
const ADMIN_COOKIE_NAME = 'admin_session';
|
||
const TRACKING_FILE = path.join(STATE_DIR, 'tracking.json');
|
||
const TRACKING_PERSIST_INTERVAL_MS = 60000; // Persist every minute
|
||
const ASSETS_DIR = path.join(STATIC_ROOT, 'assets');
|
||
const DEFAULT_MEMORY_LIMIT_BYTES = (() => {
|
||
const parsed = parseMemoryValue(process.env.CONTAINER_MEMORY_LIMIT || process.env.MAX_CONTAINER_MEMORY || '512M');
|
||
return parsed > 0 ? parsed : parseMemoryValue('512M');
|
||
})();
|
||
const DEFAULT_CPU_LIMIT_CORES = (() => {
|
||
const parsed = Number(process.env.CONTAINER_CPU_LIMIT || process.env.MAX_CONTAINER_CPU || 0.5);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0.5;
|
||
})();
|
||
const RESOURCE_LIMITS = detectDockerLimits(DEFAULT_MEMORY_LIMIT_BYTES, DEFAULT_CPU_LIMIT_CORES, REPO_ROOT);
|
||
const RESOURCE_MEMORY_SOFT_RATIO = Number(process.env.RESOURCE_MEMORY_SOFT_RATIO || 0.9);
|
||
const RESOURCE_CPU_SOFT_RATIO = Number(process.env.RESOURCE_CPU_SOFT_RATIO || 0.9);
|
||
const RESOURCE_CHECK_INTERVAL_MS = resolveIntervalMs(process.env.RESOURCE_CHECK_INTERVAL_MS, 750, 250);
|
||
const RESOURCE_MIN_LOAD_FLOOR = Number(process.env.RESOURCE_MIN_LOAD_FLOOR || 0.5);
|
||
const RESOURCE_WAIT_LOG_INTERVAL_MS = resolveIntervalMs(process.env.RESOURCE_WAIT_LOG_INTERVAL_MS, 4000, 500);
|
||
const OPENCODE_MAX_CONCURRENCY = Number(process.env.OPENCODE_MAX_CONCURRENCY || 0);
|
||
|
||
// User authentication configuration
|
||
const USERS_DB_FILE = path.join(STATE_DIR, 'users.json');
|
||
const USER_SESSIONS_FILE = path.join(STATE_DIR, 'user-sessions.json');
|
||
const USER_SESSION_SECRET = process.env.USER_SESSION_SECRET || process.env.SESSION_SECRET || (() => {
|
||
// Generate a secure random session secret for development
|
||
// In production, this should be set via environment variable
|
||
const generatedSecret = randomBytes(32).toString('hex');
|
||
console.warn('⚠️ WARNING: No USER_SESSION_SECRET or SESSION_SECRET found. Generated a random secret for this session.');
|
||
console.warn('⚠️ For production use, set USER_SESSION_SECRET environment variable to a secure random value.');
|
||
console.warn('⚠️ Generate one with: openssl rand -hex 32');
|
||
return generatedSecret;
|
||
})();
|
||
const USER_COOKIE_NAME = 'user_session';
|
||
const USER_SESSION_TTL_MS = Number(process.env.USER_SESSION_TTL_MS || 30 * 24 * 60 * 60 * 1000); // default 30 days
|
||
const USER_SESSION_SHORT_TTL_MS = Number(process.env.USER_SESSION_SHORT_TTL_MS || 3 * 24 * 60 * 60 * 1000); // default 3 days
|
||
const PASSWORD_SALT_ROUNDS = 12; // bcrypt salt rounds
|
||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
|
||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
|
||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || '';
|
||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '';
|
||
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || '';
|
||
const OAUTH_STATE_TTL_MS = Number(process.env.OAUTH_STATE_TTL_MS || 10 * 60 * 1000); // 10 minutes
|
||
const oauthStateStore = new Map();
|
||
const OAUTH_USER_AGENT = process.env.OAUTH_USER_AGENT || 'shopify-ai-app-builder';
|
||
const EMAIL_VERIFICATION_TTL_MS = Number(process.env.EMAIL_VERIFICATION_TTL_MS || 24 * 60 * 60 * 1000); // 24h
|
||
const PASSWORD_RESET_TTL_MS = Number(process.env.PASSWORD_RESET_TTL_MS || 60 * 60 * 1000); // 1h
|
||
const SMTP_HOST = process.env.SMTP_HOST || '';
|
||
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
||
const SMTP_SECURE = process.env.SMTP_SECURE === '1' || String(process.env.SMTP_SECURE || '').toLowerCase() === 'true';
|
||
const SMTP_USER = process.env.SMTP_USER || process.env.SMTP_USERNAME || '';
|
||
const SMTP_PASS = (() => {
|
||
if (process.env.SMTP_PASS_FILE) {
|
||
try {
|
||
return fsSync.readFileSync(process.env.SMTP_PASS_FILE, 'utf8').trim();
|
||
} catch (_) { /* fall back to env */ }
|
||
}
|
||
return process.env.SMTP_PASS || process.env.SMTP_PASSWORD || '';
|
||
})();
|
||
const SMTP_FROM = process.env.SMTP_FROM || process.env.EMAIL_FROM || '';
|
||
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || '';
|
||
const POSTHOG_API_HOST = process.env.POSTHOG_API_HOST || 'https://app.posthog.com';
|
||
const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || process.env.DODO_WEBHOOK_KEY || '';
|
||
const USER_PLANS = ['hobby', 'starter', 'professional', 'enterprise'];
|
||
const DEFAULT_PLAN = 'hobby';
|
||
const DEFAULT_BILLING_STATUS = 'active';
|
||
const PAID_PLANS = new Set(['starter', 'professional', 'enterprise']);
|
||
const MAX_UPLOAD_ZIP_SIZE = Number(process.env.MAX_UPLOAD_ZIP_SIZE || 25_000_000); // 25 MB default for uploaded zip apps
|
||
const MAX_EXPORT_ZIP_SIZE = Number(process.env.MAX_EXPORT_ZIP_SIZE || 100_000_000); // 100 MB default for exported zip apps
|
||
const MAX_EXPORT_FILE_COUNT = Number(process.env.MAX_EXPORT_FILE_COUNT || 10000); // Max 10,000 files per export
|
||
const BASE64_OVERHEAD_MULTIPLIER = 1.34; // ~33% overhead with small buffer
|
||
const ZIP_LOCAL_HEADER_SIG = [0x50, 0x4b, 0x03, 0x04];
|
||
const ZIP_EOCD_EMPTY_SIG = [0x50, 0x4b, 0x05, 0x06];
|
||
const BLOCKED_PATH_PATTERN = /^(?:[a-zA-Z]:|\\\\|\/\/|con:|prn:|aux:|nul:|com[1-9]:|lpt[1-9]:)/i;
|
||
const PLAN_APP_LIMITS = {
|
||
hobby: 3,
|
||
starter: 10,
|
||
professional: 20,
|
||
enterprise: Infinity,
|
||
};
|
||
const PLAN_TOKEN_LIMITS = {
|
||
hobby: 50_000,
|
||
starter: 100_000,
|
||
professional: 5_000_000,
|
||
enterprise: 20_000_000,
|
||
};
|
||
const EXTERNAL_TEST_LIMITS = {
|
||
hobby: 3,
|
||
starter: 50,
|
||
professional: Infinity,
|
||
enterprise: Infinity,
|
||
};
|
||
|
||
// Default token rates (price per 1M tokens in minor units/cents)
|
||
const DEFAULT_TOKEN_RATES = {
|
||
usd: 250,
|
||
gbp: 200,
|
||
eur: 250,
|
||
};
|
||
|
||
// Runtime-editable copy persisted to disk
|
||
let planTokenLimits = JSON.parse(JSON.stringify(PLAN_TOKEN_LIMITS));
|
||
let tokenRates = JSON.parse(JSON.stringify(DEFAULT_TOKEN_RATES));
|
||
|
||
async function loadPlanTokenLimits() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(PLAN_TOKENS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') {
|
||
const clean = {};
|
||
Object.keys(PLAN_TOKEN_LIMITS).forEach((plan) => { clean[plan] = 0; });
|
||
for (const [plan, value] of Object.entries(parsed)) {
|
||
if (!clean.hasOwnProperty(plan)) continue;
|
||
clean[plan] = Math.max(0, Number(value || 0));
|
||
}
|
||
planTokenLimits = clean;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load plan token limits, using defaults', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPlanTokenLimits() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(planTokenLimits, null, 2);
|
||
try {
|
||
await safeWriteFile(PLAN_TOKENS_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist plan token limits', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function loadTokenRates() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(TOKEN_RATES_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') {
|
||
const clean = {};
|
||
Object.keys(DEFAULT_TOKEN_RATES).forEach((currency) => { clean[currency] = 0; });
|
||
for (const [currency, value] of Object.entries(parsed)) {
|
||
if (!clean.hasOwnProperty(currency)) continue;
|
||
clean[currency] = Math.max(0, Number(value || 0));
|
||
}
|
||
tokenRates = clean;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load token rates, using defaults', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistTokenRates() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(tokenRates, null, 2);
|
||
try {
|
||
await safeWriteFile(TOKEN_RATES_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist token rates', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
const PLAN_PRICES = {
|
||
starter: Number(process.env.PRICE_STARTER || 7.5),
|
||
professional: Number(process.env.PRICE_BUSINESS || 25),
|
||
enterprise: Number(process.env.PRICE_ENTERPRISE || 75),
|
||
};
|
||
const AUTO_MODEL_TOKEN = 'auto';
|
||
const DEFAULT_PROVIDER_FALLBACK = 'opencode';
|
||
const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK, 'cohere'];
|
||
const PROVIDER_PERSIST_DEBOUNCE_MS = 200;
|
||
const TOKEN_ESTIMATION_BUFFER = 400;
|
||
const BOOST_PACK_SIZE = 500_000;
|
||
const BOOST_BASE_PRICE = 15;
|
||
const TOKEN_GRACE_RATIO = 0.05;
|
||
const TOKEN_GRACE_MIN = 500;
|
||
const HOBBY_PRIORITY_DELAY_MS = (() => {
|
||
const raw = Number(process.env.HOBBY_PRIORITY_DELAY_MS || process.env.STARTER_PRIORITY_DELAY_MS);
|
||
return Number.isFinite(raw) && raw >= 0 ? raw : 280;
|
||
})();
|
||
const STARTER_PRIORITY_DELAY_MS = (() => {
|
||
const raw = Number(process.env.STARTER_PRIORITY_DELAY_MS);
|
||
return Number.isFinite(raw) && raw >= 0 ? raw : 220;
|
||
})();
|
||
const BUSINESS_PRIORITY_DELAY_MS = (() => {
|
||
const raw = Number(process.env.BUSINESS_PRIORITY_DELAY_MS);
|
||
return Number.isFinite(raw) && raw >= 0 ? raw : 120;
|
||
})();
|
||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
const IMAGE_COMPRESSION_ENABLED = (process.env.IMAGE_COMPRESSION_ENABLED || '1') !== '0';
|
||
const IMAGE_MAX_DIMENSION = Number(process.env.IMAGE_MAX_DIMENSION || 1600);
|
||
const IMAGE_WEBP_QUALITY = Number(process.env.IMAGE_WEBP_QUALITY || 78);
|
||
|
||
function isPaidPlan(plan) {
|
||
const normalized = String(plan || '').trim().toLowerCase();
|
||
return PAID_PLANS.has(normalized);
|
||
}
|
||
|
||
function isImageMime(mimeType) {
|
||
const mt = String(mimeType || '').toLowerCase();
|
||
return mt.startsWith('image/');
|
||
}
|
||
|
||
function getBytesPrefix(buf, length) {
|
||
if (!Buffer.isBuffer(buf)) return Buffer.alloc(0);
|
||
return buf.slice(0, Math.max(0, length || 0));
|
||
}
|
||
|
||
function isLikelyPng(buf) {
|
||
const b = getBytesPrefix(buf, 8);
|
||
return b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a;
|
||
}
|
||
|
||
function isLikelyJpeg(buf) {
|
||
const b = getBytesPrefix(buf, 3);
|
||
return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
||
}
|
||
|
||
function isLikelyGif(buf) {
|
||
const b = getBytesPrefix(buf, 6);
|
||
return b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x39 || b[4] === 0x37) && b[5] === 0x61;
|
||
}
|
||
|
||
function isLikelyWebp(buf) {
|
||
const b = getBytesPrefix(buf, 12);
|
||
return b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50;
|
||
}
|
||
|
||
function isLikelySvg(buf) {
|
||
const head = getBytesPrefix(buf, 200).toString('utf8').trim().toLowerCase();
|
||
return head.startsWith('<svg') || head.startsWith('<?xml') || head.includes('<svg');
|
||
}
|
||
|
||
function validateImageSignature(clientMimeType, buffer) {
|
||
const mt = String(clientMimeType || '').toLowerCase();
|
||
if (!mt.startsWith('image/')) return true;
|
||
if (!Buffer.isBuffer(buffer) || buffer.length < 8) return false;
|
||
if (mt === 'image/png') return isLikelyPng(buffer);
|
||
if (mt === 'image/jpeg') return isLikelyJpeg(buffer);
|
||
if (mt === 'image/gif') return isLikelyGif(buffer);
|
||
if (mt === 'image/webp') return isLikelyWebp(buffer);
|
||
if (mt === 'image/svg+xml') return isLikelySvg(buffer);
|
||
return true;
|
||
}
|
||
|
||
async function compressImageBuffer(buffer, mimeType) {
|
||
if (!IMAGE_COMPRESSION_ENABLED) return { buffer, mimeType, ext: null };
|
||
if (!sharp) return { buffer, mimeType, ext: null };
|
||
const mt = String(mimeType || '').toLowerCase();
|
||
if (!mt.startsWith('image/')) return { buffer, mimeType, ext: null };
|
||
if (mt === 'image/svg+xml' || mt === 'image/gif') return { buffer, mimeType, ext: null };
|
||
try {
|
||
const pipeline = sharp(buffer, { failOn: 'none' }).rotate();
|
||
const resized = pipeline.resize({ width: IMAGE_MAX_DIMENSION, height: IMAGE_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true });
|
||
const out = await resized.webp({ quality: IMAGE_WEBP_QUALITY }).toBuffer();
|
||
if (out && out.length && out.length < buffer.length) {
|
||
return { buffer: out, mimeType: 'image/webp', ext: 'webp' };
|
||
}
|
||
} catch (err) {
|
||
log('image compression failed; storing original', { mimeType: mt, err: String(err) });
|
||
}
|
||
return { buffer, mimeType, ext: null };
|
||
}
|
||
|
||
function safeFileNamePart(raw) {
|
||
return String(raw || 'upload').replace(/[^a-zA-Z0-9\-_\.]/g, '_').slice(0, 140) || 'upload';
|
||
}
|
||
|
||
function extensionForMime(mimeType) {
|
||
const mt = String(mimeType || '').toLowerCase();
|
||
if (mt === 'image/png') return 'png';
|
||
if (mt === 'image/jpeg') return 'jpg';
|
||
if (mt === 'image/gif') return 'gif';
|
||
if (mt === 'image/webp') return 'webp';
|
||
if (mt === 'image/svg+xml') return 'svg';
|
||
if (mt === 'application/pdf') return 'pdf';
|
||
if (mt === 'text/plain') return 'txt';
|
||
if (mt === 'text/css') return 'css';
|
||
if (mt === 'application/javascript') return 'js';
|
||
if (mt === 'text/html') return 'html';
|
||
if (mt === 'text/markdown') return 'md';
|
||
if (mt === 'text/csv') return 'csv';
|
||
if (mt === 'application/json') return 'json';
|
||
if (mt === 'application/xml' || mt === 'text/xml') return 'xml';
|
||
return 'bin';
|
||
}
|
||
|
||
function toDataUrl(mimeType, base64Data) {
|
||
const mt = String(mimeType || 'application/octet-stream');
|
||
return `data:${mt};base64,${base64Data || ''}`;
|
||
}
|
||
|
||
async function buildOpenRouterMessagesForSession(session, currentMessage) {
|
||
const history = Array.isArray(session?.messages) ? session.messages : [];
|
||
const prior = history.filter((m) => m && m.id !== currentMessage?.id && m.status === 'done').slice(-8);
|
||
const mapped = prior.map((m) => {
|
||
if (m.role === 'assistant') return { role: 'assistant', content: String(m.reply || m.partialOutput || '') };
|
||
return { role: 'user', content: String(m.content || '') };
|
||
}).filter((m) => (m.content || '').trim().length);
|
||
|
||
const parts = [];
|
||
const text = String(currentMessage?.content || '').trim();
|
||
if (text) parts.push({ type: 'text', text });
|
||
const images = (Array.isArray(currentMessage?.attachments) ? currentMessage.attachments : []).filter((a) => a && isImageMime(a.type) && a.url);
|
||
for (const img of images.slice(0, 6)) {
|
||
try {
|
||
const filename = String(img.url || '').split('/').pop();
|
||
if (!filename) continue;
|
||
const candidate = path.join(session.uploadsDir, filename);
|
||
const resolved = path.resolve(candidate);
|
||
const uploadsRoot = path.resolve(session.uploadsDir);
|
||
if (!resolved.startsWith(uploadsRoot + path.sep) && resolved !== uploadsRoot) continue;
|
||
const buf = await fs.readFile(resolved);
|
||
parts.push({ type: 'image_url', image_url: { url: toDataUrl(img.type, buf.toString('base64')) } });
|
||
} catch (err) {
|
||
log('failed to embed image for OpenRouter', { err: String(err), url: img.url });
|
||
}
|
||
}
|
||
const userMsg = parts.length ? { role: 'user', content: parts } : { role: 'user', content: text };
|
||
return mapped.concat([userMsg]);
|
||
}
|
||
|
||
const state = { sessions: [] };
|
||
const sessionQueues = new Map();
|
||
const activeStreams = new Map(); // Track active SSE streams per message
|
||
const runningProcesses = new Map(); // Track running opencode processes
|
||
let resourceReservations = 0; // Tracks reserved slots; Node event loop serializes updates.
|
||
const SUPPORTED_CLIS = ['opencode'];
|
||
let server = null;
|
||
let isShuttingDown = false;
|
||
const AUTO_SAVE_INTERVAL_MS = 120000; // Auto-save every 2 minutes (reduced from 30s)
|
||
let autoSaveTimer = null;
|
||
|
||
// ============================================================================
|
||
// Memory Management Constants and State
|
||
// ============================================================================
|
||
const MEMORY_CLEANUP_INTERVAL_MS = 300000; // Run cleanup every 5 minutes (reduced from 60s)
|
||
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||
const MESSAGE_HISTORY_LIMIT = 50; // Max messages to keep per session (reduced from 100)
|
||
const PARTIAL_OUTPUT_MAX_LENGTH = 50000; // Truncate large outputs in completed messages
|
||
const STALE_STREAM_TIMEOUT_MS = 600000; // 10 minutes - clean up stale SSE streams
|
||
const STALE_PROCESS_TIMEOUT_MS = 900000; // 15 minutes - clean up potentially stuck processes
|
||
const MAX_SESSION_QUEUES = 1000; // Limit concurrent session queues
|
||
const MAX_OAUTH_STATES = 10000; // Limit OAuth state entries
|
||
const MAX_LOGIN_ATTEMPTS_ENTRIES = 50000; // Limit login attempt tracking
|
||
let lastMemoryCleanup = 0;
|
||
let memoryCleanupTimer = null;
|
||
const recentlyCleanedSessions = new Set(); // Track recently cleaned sessions to avoid redundant work
|
||
|
||
// Track spawned child processes for proper cleanup
|
||
const childProcesses = new Map(); // processId -> { pid, startTime, sessionId, messageId }
|
||
|
||
// ============================================================================
|
||
// Memory Cleanup Functions
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Triggers garbage collection hint and runs memory cleanup
|
||
* @param {string} reason - Reason for triggering cleanup (for logging)
|
||
*/
|
||
function triggerMemoryCleanup(reason = 'manual') {
|
||
const now = Date.now();
|
||
// Debounce - don't run more than once every 60 seconds (increased from 10s)
|
||
if (now - lastMemoryCleanup < 60000) return;
|
||
lastMemoryCleanup = now;
|
||
|
||
const beforeMem = process.memoryUsage();
|
||
|
||
try {
|
||
// Clean up completed session data (only if needed)
|
||
const activeMessages = state.sessions.reduce((count, s) => count + (s.pending || 0), 0);
|
||
if (activeMessages === 0 || state.sessions.length > 20) {
|
||
cleanupCompletedSessions();
|
||
}
|
||
|
||
// Clean up stale entries from Maps
|
||
cleanupStaleMaps();
|
||
|
||
// Clean up orphaned processes
|
||
cleanupOrphanedProcesses();
|
||
|
||
// Truncate large message outputs (less frequently)
|
||
if (now % 300000 < 60000) { // Every 5 minutes
|
||
truncateLargeOutputs();
|
||
}
|
||
|
||
// Hint to V8 to run GC if available (--expose-gc flag)
|
||
if (global.gc) {
|
||
global.gc();
|
||
}
|
||
|
||
const afterMem = process.memoryUsage();
|
||
const freedMb = ((beforeMem.heapUsed - afterMem.heapUsed) / (1024 * 1024)).toFixed(2);
|
||
|
||
if (freedMb > 1) {
|
||
log('Memory cleanup completed', {
|
||
reason,
|
||
freedMb: `${freedMb}MB`,
|
||
beforeHeap: `${(beforeMem.heapUsed / 1024 / 1024).toFixed(2)}MB`,
|
||
afterHeap: `${(afterMem.heapUsed / 1024 / 1024).toFixed(2)}MB`,
|
||
rss: `${(afterMem.rss / 1024 / 1024).toFixed(2)}MB`
|
||
});
|
||
}
|
||
} catch (err) {
|
||
log('Memory cleanup error', { error: String(err), reason });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleans up old and completed session data
|
||
*/
|
||
function cleanupCompletedSessions() {
|
||
const now = Date.now();
|
||
let cleanedSessions = 0;
|
||
let cleanedMessages = 0;
|
||
|
||
for (const session of state.sessions) {
|
||
// Skip sessions cleaned in the last hour to avoid redundant work
|
||
const lastCleaned = session.lastCleanedAt ? now - new Date(session.lastCleanedAt).getTime() : Infinity;
|
||
if (lastCleaned < 3600000) continue; // Skip if cleaned within last hour
|
||
|
||
let sessionChanged = false;
|
||
|
||
// Clean up old messages within sessions
|
||
if (Array.isArray(session.messages) && session.messages.length > MESSAGE_HISTORY_LIMIT) {
|
||
const excess = session.messages.length - MESSAGE_HISTORY_LIMIT;
|
||
// Remove oldest completed messages first
|
||
const toRemove = session.messages
|
||
.filter(m => m.status === 'done' || m.status === 'error' || m.status === 'cancelled')
|
||
.slice(0, excess);
|
||
|
||
for (const msg of toRemove) {
|
||
const idx = session.messages.indexOf(msg);
|
||
if (idx !== -1) {
|
||
session.messages.splice(idx, 1);
|
||
cleanedMessages++;
|
||
sessionChanged = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear partialOutput from completed messages (keep reply only)
|
||
if (Array.isArray(session.messages)) {
|
||
for (const msg of session.messages) {
|
||
if ((msg.status === 'done' || msg.status === 'error') && msg.partialOutput && msg.reply) {
|
||
// Keep only the final reply, not the streaming partial output
|
||
delete msg.partialOutput;
|
||
cleanedMessages++;
|
||
sessionChanged = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check session age
|
||
const sessionAge = now - new Date(session.createdAt).getTime();
|
||
if (sessionAge > SESSION_MAX_AGE_MS && session.pending === 0) {
|
||
// Mark for cleanup but don't delete - just clean up heavy data
|
||
if (session.messages && session.messages.length > 20) {
|
||
const oldLength = session.messages.length;
|
||
session.messages = session.messages.slice(-20); // Keep only last 20 messages
|
||
if (session.messages.length < oldLength) {
|
||
cleanedSessions++;
|
||
sessionChanged = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark session as cleaned
|
||
if (sessionChanged) {
|
||
session.lastCleanedAt = new Date().toISOString();
|
||
}
|
||
}
|
||
|
||
if (cleanedSessions > 0 || cleanedMessages > 0) {
|
||
log('Cleaned session data', { cleanedSessions, cleanedMessages });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleans up stale entries from various Maps to prevent memory leaks
|
||
*/
|
||
function cleanupStaleMaps() {
|
||
const now = Date.now();
|
||
let cleaned = { streams: 0, queues: 0, oauth: 0, processes: 0, loginAttempts: 0, apiRate: 0 };
|
||
|
||
// Clean up stale active streams (SSE connections that didn't close properly)
|
||
for (const [messageId, streams] of activeStreams.entries()) {
|
||
// If message is no longer running/queued, cleanup its streams
|
||
let messageFound = false;
|
||
for (const session of state.sessions) {
|
||
const msg = session.messages?.find(m => m.id === messageId);
|
||
if (msg) {
|
||
messageFound = true;
|
||
if (msg.status !== 'running' && msg.status !== 'queued') {
|
||
// Message completed - close and remove streams
|
||
if (streams instanceof Set) {
|
||
for (const stream of streams) {
|
||
try { stream.end(); } catch (_) {}
|
||
}
|
||
}
|
||
activeStreams.delete(messageId);
|
||
cleaned.streams++;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (!messageFound) {
|
||
// Message not found at all - cleanup streams
|
||
if (streams instanceof Set) {
|
||
for (const stream of streams) {
|
||
try { stream.end(); } catch (_) {}
|
||
}
|
||
}
|
||
activeStreams.delete(messageId);
|
||
cleaned.streams++;
|
||
}
|
||
}
|
||
|
||
// Clean up old session queues for deleted/inactive sessions
|
||
for (const [sessionId, _queue] of sessionQueues.entries()) {
|
||
const session = state.sessions.find(s => s.id === sessionId);
|
||
if (!session || session.pending === 0) {
|
||
// Check if there are any running messages
|
||
const hasRunning = session?.messages?.some(m => m.status === 'running' || m.status === 'queued');
|
||
if (!hasRunning) {
|
||
sessionQueues.delete(sessionId);
|
||
cleaned.queues++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Limit session queues size
|
||
if (sessionQueues.size > MAX_SESSION_QUEUES) {
|
||
const toDelete = sessionQueues.size - MAX_SESSION_QUEUES;
|
||
let deleted = 0;
|
||
for (const [sessionId] of sessionQueues.entries()) {
|
||
if (deleted >= toDelete) break;
|
||
const session = state.sessions.find(s => s.id === sessionId);
|
||
if (!session || session.pending === 0) {
|
||
sessionQueues.delete(sessionId);
|
||
deleted++;
|
||
cleaned.queues++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up running processes map for processes that finished
|
||
for (const [messageId, processInfo] of runningProcesses.entries()) {
|
||
// Check if message is still running
|
||
let stillRunning = false;
|
||
for (const session of state.sessions) {
|
||
const msg = session.messages?.find(m => m.id === messageId);
|
||
if (msg && (msg.status === 'running' || msg.status === 'queued')) {
|
||
stillRunning = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!stillRunning) {
|
||
runningProcesses.delete(messageId);
|
||
cleaned.processes++;
|
||
}
|
||
|
||
// Also check for stale processes (running too long)
|
||
if (processInfo.started && (now - processInfo.started) > STALE_PROCESS_TIMEOUT_MS) {
|
||
log('Cleaning up stale process entry', { messageId, age: now - processInfo.started });
|
||
runningProcesses.delete(messageId);
|
||
cleaned.processes++;
|
||
}
|
||
}
|
||
|
||
// Clean up expired OAuth state entries (deferred cleanup - handled here for Maps that grow unbounded)
|
||
// Note: oauthStateStore is defined later, so we access it via typeof check
|
||
if (typeof oauthStateStore !== 'undefined' && oauthStateStore instanceof Map) {
|
||
for (const [key, entry] of oauthStateStore.entries()) {
|
||
if (entry.expiresAt && entry.expiresAt < now) {
|
||
oauthStateStore.delete(key);
|
||
cleaned.oauth++;
|
||
}
|
||
}
|
||
// Hard limit on OAuth states
|
||
if (oauthStateStore.size > MAX_OAUTH_STATES) {
|
||
const toDelete = oauthStateStore.size - MAX_OAUTH_STATES;
|
||
let deleted = 0;
|
||
for (const [key] of oauthStateStore.entries()) {
|
||
if (deleted >= toDelete) break;
|
||
oauthStateStore.delete(key);
|
||
deleted++;
|
||
cleaned.oauth++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up old login attempt entries (older than 1 hour)
|
||
const LOGIN_ATTEMPT_MAX_AGE = 60 * 60 * 1000; // 1 hour
|
||
if (typeof loginAttempts !== 'undefined' && loginAttempts instanceof Map) {
|
||
for (const [key, record] of loginAttempts.entries()) {
|
||
const age = now - (record.windowStart || 0);
|
||
if (age > LOGIN_ATTEMPT_MAX_AGE && (!record.lockedUntil || record.lockedUntil < now)) {
|
||
loginAttempts.delete(key);
|
||
cleaned.loginAttempts++;
|
||
}
|
||
}
|
||
// Hard limit
|
||
if (loginAttempts.size > MAX_LOGIN_ATTEMPTS_ENTRIES) {
|
||
const toDelete = loginAttempts.size - MAX_LOGIN_ATTEMPTS_ENTRIES;
|
||
let deleted = 0;
|
||
for (const [key, record] of loginAttempts.entries()) {
|
||
if (deleted >= toDelete) break;
|
||
if (!record.lockedUntil || record.lockedUntil < now) {
|
||
loginAttempts.delete(key);
|
||
deleted++;
|
||
cleaned.loginAttempts++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up old admin login attempt entries
|
||
if (typeof adminLoginAttempts !== 'undefined' && adminLoginAttempts instanceof Map) {
|
||
for (const [key, record] of adminLoginAttempts.entries()) {
|
||
const age = now - (record.windowStart || 0);
|
||
if (age > LOGIN_ATTEMPT_MAX_AGE && (!record.lockedUntil || record.lockedUntil < now)) {
|
||
adminLoginAttempts.delete(key);
|
||
cleaned.loginAttempts++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up old API rate limit entries (older than rate limit window)
|
||
const API_RATE_LIMIT_MAX_AGE = 60 * 60 * 1000; // 1 hour
|
||
if (typeof apiRateLimit !== 'undefined' && apiRateLimit instanceof Map) {
|
||
for (const [key, record] of apiRateLimit.entries()) {
|
||
const age = now - (record.windowStart || 0);
|
||
if (age > API_RATE_LIMIT_MAX_AGE) {
|
||
apiRateLimit.delete(key);
|
||
cleaned.apiRate++;
|
||
}
|
||
}
|
||
}
|
||
|
||
const totalCleaned = Object.values(cleaned).reduce((a, b) => a + b, 0);
|
||
if (totalCleaned > 0) {
|
||
log('Cleaned stale map entries', cleaned);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleans up orphaned child processes
|
||
*/
|
||
function cleanupOrphanedProcesses() {
|
||
const now = Date.now();
|
||
let killed = 0;
|
||
|
||
for (const [processId, info] of childProcesses.entries()) {
|
||
const age = now - info.startTime;
|
||
|
||
// Kill processes running longer than timeout
|
||
if (age > STALE_PROCESS_TIMEOUT_MS) {
|
||
try {
|
||
process.kill(info.pid, 'SIGTERM');
|
||
setTimeout(() => {
|
||
try { process.kill(info.pid, 'SIGKILL'); } catch (_) {}
|
||
}, 5000);
|
||
killed++;
|
||
log('Killed orphaned process', { processId, pid: info.pid, age, sessionId: info.sessionId });
|
||
} catch (err) {
|
||
// Process may already be dead
|
||
if (err.code !== 'ESRCH') {
|
||
log('Failed to kill orphaned process', { processId, error: String(err) });
|
||
}
|
||
}
|
||
childProcesses.delete(processId);
|
||
}
|
||
}
|
||
|
||
if (killed > 0) {
|
||
log('Cleaned up orphaned processes', { killed });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Truncates large outputs in completed messages to save memory
|
||
*/
|
||
function truncateLargeOutputs() {
|
||
let truncated = 0;
|
||
|
||
for (const session of state.sessions) {
|
||
if (!Array.isArray(session.messages)) continue;
|
||
|
||
for (const msg of session.messages) {
|
||
// Only truncate completed messages
|
||
if (msg.status !== 'done' && msg.status !== 'error') continue;
|
||
|
||
// Truncate large reply
|
||
if (msg.reply && msg.reply.length > PARTIAL_OUTPUT_MAX_LENGTH) {
|
||
msg.reply = msg.reply.substring(0, PARTIAL_OUTPUT_MAX_LENGTH) + '\n\n[Output truncated due to length...]';
|
||
truncated++;
|
||
}
|
||
|
||
// Remove partialOutput if we have reply
|
||
if (msg.partialOutput && msg.reply) {
|
||
delete msg.partialOutput;
|
||
truncated++;
|
||
}
|
||
|
||
// Truncate large content
|
||
if (msg.content && msg.content.length > PARTIAL_OUTPUT_MAX_LENGTH) {
|
||
msg.content = msg.content.substring(0, PARTIAL_OUTPUT_MAX_LENGTH) + '\n\n[Content truncated...]';
|
||
truncated++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (truncated > 0) {
|
||
log('Truncated large message fields', { truncated });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Starts periodic memory cleanup
|
||
*/
|
||
function startMemoryCleanup() {
|
||
if (memoryCleanupTimer) return;
|
||
|
||
memoryCleanupTimer = setInterval(() => {
|
||
if (isShuttingDown) return;
|
||
triggerMemoryCleanup('periodic');
|
||
}, MEMORY_CLEANUP_INTERVAL_MS);
|
||
|
||
log('Memory cleanup scheduler started', { intervalMs: MEMORY_CLEANUP_INTERVAL_MS });
|
||
}
|
||
|
||
/**
|
||
* Stops periodic memory cleanup
|
||
*/
|
||
function stopMemoryCleanup() {
|
||
if (memoryCleanupTimer) {
|
||
clearInterval(memoryCleanupTimer);
|
||
memoryCleanupTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Registers a child process for tracking
|
||
* @param {string} processId - Unique process identifier
|
||
* @param {number} pid - Process ID
|
||
* @param {string} sessionId - Associated session ID
|
||
* @param {string} messageId - Associated message ID
|
||
*/
|
||
function registerChildProcess(processId, pid, sessionId, messageId) {
|
||
childProcesses.set(processId, {
|
||
pid,
|
||
startTime: Date.now(),
|
||
sessionId,
|
||
messageId
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Unregisters a child process
|
||
* @param {string} processId - Process identifier
|
||
*/
|
||
function unregisterChildProcess(processId) {
|
||
childProcesses.delete(processId);
|
||
}
|
||
|
||
// ============================================================================
|
||
// OpenCode Process Manager - Singleton for managing a single OpenCode instance
|
||
// ============================================================================
|
||
class OpencodeProcessManager {
|
||
constructor() {
|
||
this.process = null;
|
||
this.isReady = false;
|
||
this.pendingRequests = new Map(); // messageId -> { resolve, reject, timeout }
|
||
this.heartbeatInterval = null;
|
||
this.lastActivity = Date.now();
|
||
this.sessionWorkspaces = new Map(); // sessionId -> workspaceDir
|
||
}
|
||
|
||
async start() {
|
||
if (this.process) {
|
||
log('OpenCode process manager already running');
|
||
return;
|
||
}
|
||
|
||
log('Starting OpenCode process manager singleton...');
|
||
|
||
try {
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
|
||
// Verify CLI exists
|
||
try {
|
||
fsSync.accessSync(cliCommand, fsSync.constants.X_OK);
|
||
} catch (err) {
|
||
throw new Error(`OpenCode CLI not found: ${cliCommand}`);
|
||
}
|
||
|
||
// Start OpenCode in server/daemon mode if supported
|
||
// Otherwise, we'll continue using per-message spawning
|
||
// Check if OpenCode supports a server/daemon mode
|
||
const serverArgs = this.getServerModeArgs();
|
||
|
||
if (serverArgs) {
|
||
log('Starting OpenCode in server mode', { args: serverArgs });
|
||
this.process = spawn(cliCommand, serverArgs, {
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
env: { ...process.env }
|
||
});
|
||
|
||
this.process.stdout.on('data', (data) => this.handleStdout(data));
|
||
this.process.stderr.on('data', (data) => this.handleStderr(data));
|
||
this.process.on('error', (err) => this.handleError(err));
|
||
this.process.on('exit', (code) => this.handleExit(code));
|
||
|
||
// Wait for ready signal
|
||
await this.waitForReady();
|
||
|
||
// Start heartbeat to keep process alive
|
||
this.startHeartbeat();
|
||
|
||
log('OpenCode process manager started successfully');
|
||
} else {
|
||
log('OpenCode does not support server mode, will use per-session approach');
|
||
// We'll track sessions instead of using a single process
|
||
this.isReady = true;
|
||
}
|
||
} catch (err) {
|
||
log('Failed to start OpenCode process manager', { error: String(err) });
|
||
this.process = null;
|
||
this.isReady = false;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
getServerModeArgs() {
|
||
// Check if OpenCode supports server/daemon mode
|
||
// This would need to be customized based on actual OpenCode CLI capabilities
|
||
// For now, we return null to indicate no server mode support
|
||
// If OpenCode adds 'serve' or 'daemon' command, update this
|
||
return null;
|
||
}
|
||
|
||
async waitForReady(timeout = 10000) {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
reject(new Error('OpenCode process failed to become ready'));
|
||
}, timeout);
|
||
|
||
const checkReady = () => {
|
||
if (this.isReady) {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
} else {
|
||
setTimeout(checkReady, 100);
|
||
}
|
||
};
|
||
checkReady();
|
||
});
|
||
}
|
||
|
||
handleStdout(data) {
|
||
const text = data.toString();
|
||
log('OpenCode process stdout', { text: text.slice(0, 200) });
|
||
|
||
// Check for ready signal
|
||
if (text.includes('ready') || text.includes('listening')) {
|
||
this.isReady = true;
|
||
}
|
||
|
||
// Process responses for pending requests
|
||
this.processOutput(text);
|
||
}
|
||
|
||
handleStderr(data) {
|
||
const text = data.toString();
|
||
log('OpenCode process stderr', { text: text.slice(0, 200) });
|
||
}
|
||
|
||
handleError(err) {
|
||
log('OpenCode process error', { error: String(err) });
|
||
this.isReady = false;
|
||
this.rejectAllPending(err);
|
||
}
|
||
|
||
handleExit(code) {
|
||
log('OpenCode process exited', { code });
|
||
this.isReady = false;
|
||
this.process = null;
|
||
this.rejectAllPending(new Error(`OpenCode process exited with code ${code}`));
|
||
|
||
// Auto-restart if not shutting down
|
||
if (!isShuttingDown) {
|
||
log('Auto-restarting OpenCode process...');
|
||
setTimeout(() => this.start().catch(err =>
|
||
log('Failed to restart OpenCode', { error: String(err) })
|
||
), 5000);
|
||
}
|
||
}
|
||
|
||
startHeartbeat() {
|
||
this.heartbeatInterval = setInterval(() => {
|
||
if (!this.process || !this.isReady) return;
|
||
|
||
// Send a lightweight heartbeat command
|
||
// This keeps the process alive and checks responsiveness
|
||
const idleTime = Date.now() - this.lastActivity;
|
||
|
||
// If idle for more than 5 minutes, send a ping
|
||
if (idleTime > 300000) {
|
||
log('Sending heartbeat to OpenCode process', { idleTime });
|
||
// Could send a simple command like 'version' or 'status'
|
||
}
|
||
}, 60000); // Every minute
|
||
}
|
||
|
||
stopHeartbeat() {
|
||
if (this.heartbeatInterval) {
|
||
clearInterval(this.heartbeatInterval);
|
||
this.heartbeatInterval = null;
|
||
}
|
||
}
|
||
|
||
processOutput(text) {
|
||
// Parse output and route to appropriate pending request
|
||
// This would need to be customized based on OpenCode's output format
|
||
this.lastActivity = Date.now();
|
||
}
|
||
|
||
rejectAllPending(error) {
|
||
for (const [messageId, request] of this.pendingRequests.entries()) {
|
||
if (request.timeout) clearTimeout(request.timeout);
|
||
request.reject(error);
|
||
}
|
||
this.pendingRequests.clear();
|
||
}
|
||
|
||
async executeInSession(sessionId, workspaceDir, command, args, options = {}) {
|
||
if (!this.isReady && !this.process) {
|
||
// Fall back to per-message spawning if server mode not available
|
||
return this.executeStandalone(workspaceDir, command, args, options);
|
||
}
|
||
|
||
// Track workspace for this session
|
||
this.sessionWorkspaces.set(sessionId, workspaceDir);
|
||
this.lastActivity = Date.now();
|
||
|
||
// Send command to persistent process
|
||
// This would need protocol implementation based on OpenCode's capabilities
|
||
// For now, fall back to standalone execution
|
||
return this.executeStandalone(workspaceDir, command, args, options);
|
||
}
|
||
|
||
async executeStandalone(workspaceDir, command, args, options) {
|
||
// Execute as a standalone process (current behavior)
|
||
// But track it so we can see when multiple processes are running
|
||
const processId = randomUUID();
|
||
const startTime = Date.now();
|
||
|
||
log('Executing OpenCode command (standalone)', {
|
||
processId,
|
||
command,
|
||
args,
|
||
workspaceDir,
|
||
activeProcesses: runningProcesses.size
|
||
});
|
||
|
||
try {
|
||
const result = await runCommand(command, args, {
|
||
...options,
|
||
cwd: workspaceDir
|
||
});
|
||
|
||
const duration = Date.now() - startTime;
|
||
log('OpenCode command completed', { processId, duration, activeProcesses: runningProcesses.size });
|
||
|
||
return result;
|
||
} catch (err) {
|
||
const duration = Date.now() - startTime;
|
||
log('OpenCode command failed', { processId, duration, error: String(err) });
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async stop() {
|
||
log('Stopping OpenCode process manager...');
|
||
this.stopHeartbeat();
|
||
this.isReady = false;
|
||
|
||
if (this.process) {
|
||
this.process.kill('SIGTERM');
|
||
|
||
// Wait for graceful shutdown
|
||
await new Promise((resolve) => {
|
||
const timeout = setTimeout(() => {
|
||
if (this.process) {
|
||
log('Force killing OpenCode process');
|
||
this.process.kill('SIGKILL');
|
||
}
|
||
resolve();
|
||
}, 5000);
|
||
|
||
if (this.process) {
|
||
this.process.once('exit', () => {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
});
|
||
} else {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
}
|
||
});
|
||
|
||
this.process = null;
|
||
}
|
||
|
||
this.sessionWorkspaces.clear();
|
||
log('OpenCode process manager stopped');
|
||
}
|
||
|
||
getStats() {
|
||
return {
|
||
isRunning: !!this.process,
|
||
isReady: this.isReady,
|
||
pendingRequests: this.pendingRequests.size,
|
||
activeSessions: this.sessionWorkspaces.size,
|
||
lastActivity: this.lastActivity,
|
||
idleTime: Date.now() - this.lastActivity
|
||
};
|
||
}
|
||
}
|
||
|
||
// Global singleton instance
|
||
const opencodeManager = new OpencodeProcessManager();
|
||
|
||
// ============================================================================
|
||
// End OpenCode Process Manager
|
||
// ============================================================================
|
||
|
||
async function safeWriteFile(filePath, data, maxRetries = 3) {
|
||
const tempPath = filePath + '.tmp';
|
||
const parentDir = path.dirname(filePath);
|
||
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
await fs.mkdir(parentDir, { recursive: true });
|
||
await fs.writeFile(tempPath, data, 'utf8');
|
||
await fs.rename(tempPath, filePath);
|
||
return;
|
||
} catch (error) {
|
||
if (error.code === 'ENOENT' && attempt < maxRetries) {
|
||
log(`safeWriteFile retry ${attempt}/${maxRetries} for ${path.basename(filePath)}`, { error: String(error) });
|
||
await delay(50 * attempt);
|
||
continue;
|
||
}
|
||
log(`safeWriteFile failed for ${path.basename(filePath)}`, { error: String(error), attempt });
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function persistAllState() {
|
||
const persistFunctions = [
|
||
persistState,
|
||
persistAdminModels,
|
||
persistOpenRouterSettings,
|
||
persistMistralSettings,
|
||
persistPlanSettings,
|
||
persistPlanTokenLimits,
|
||
persistProviderLimits,
|
||
persistProviderUsage,
|
||
persistTokenUsage,
|
||
persistTopupSessions,
|
||
persistPendingTopups,
|
||
persistPaygSessions,
|
||
persistPendingPayg,
|
||
persistUsersDb,
|
||
persistAffiliatesDb,
|
||
persistFeatureRequestsDb,
|
||
persistContactMessagesDb,
|
||
];
|
||
for (const persistFn of persistFunctions) {
|
||
try {
|
||
await persistFn();
|
||
} catch (error) {
|
||
log(`Failed to persist ${persistFn.name}`, { error: String(error) });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function gracefulShutdown(signal) {
|
||
if (isShuttingDown) return;
|
||
isShuttingDown = true;
|
||
log(`Received ${signal}, starting graceful shutdown with session preservation...`);
|
||
|
||
// Stop periodic tasks first
|
||
stopAutoSave();
|
||
stopMemoryCleanup();
|
||
|
||
// Notify all active SSE connections about the restart
|
||
for (const [messageId, streams] of activeStreams.entries()) {
|
||
try {
|
||
if (streams instanceof Set) {
|
||
for (const stream of streams) {
|
||
try {
|
||
stream.write(`data: ${JSON.stringify({ type: 'server-restart', message: 'Server is restarting, your session will be restored automatically...' })}\n\n`);
|
||
stream.end();
|
||
} catch (_) {}
|
||
}
|
||
} else if (streams && streams.write) {
|
||
streams.write(`data: ${JSON.stringify({ type: 'server-restart', message: 'Server is restarting, your session will be restored automatically...' })}\n\n`);
|
||
}
|
||
} catch (e) {
|
||
// Stream may already be closed, ignore
|
||
}
|
||
}
|
||
activeStreams.clear();
|
||
|
||
// Kill all tracked child processes
|
||
log('Terminating child processes...');
|
||
for (const [processId, info] of childProcesses.entries()) {
|
||
try {
|
||
process.kill(info.pid, 'SIGTERM');
|
||
} catch (_) {
|
||
// Process may already be dead
|
||
}
|
||
}
|
||
// Give them a moment to terminate gracefully
|
||
await delay(1000);
|
||
// Force kill any remaining
|
||
for (const [processId, info] of childProcesses.entries()) {
|
||
try {
|
||
process.kill(info.pid, 'SIGKILL');
|
||
} catch (_) {}
|
||
}
|
||
childProcesses.clear();
|
||
runningProcesses.clear();
|
||
sessionQueues.clear();
|
||
|
||
log('Stopping OpenCode process manager...');
|
||
await opencodeManager.stop();
|
||
|
||
log('Persisting all state with session continuity info...');
|
||
await persistAllState();
|
||
|
||
if (server) {
|
||
log('Closing HTTP server...');
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
|
||
log('Graceful shutdown complete, sessions preserved for restoration');
|
||
process.exit(0);
|
||
}
|
||
|
||
function startAutoSave() {
|
||
autoSaveTimer = setInterval(async () => {
|
||
if (isShuttingDown) return;
|
||
try {
|
||
await persistAllState();
|
||
} catch (error) {
|
||
log('Auto-save failed', { error: String(error) });
|
||
}
|
||
}, AUTO_SAVE_INTERVAL_MS);
|
||
}
|
||
|
||
function stopAutoSave() {
|
||
if (autoSaveTimer) {
|
||
clearInterval(autoSaveTimer);
|
||
autoSaveTimer = null;
|
||
}
|
||
}
|
||
let cachedModels = new Map();
|
||
let cachedModelsAt = new Map();
|
||
// Cache for models.dev provider data
|
||
let cachedModelsDevProviders = new Map();
|
||
let cachedModelsDevProvidersAt = new Map();
|
||
const MODELS_DEV_CACHE_TTL = 3600000; // 1 hour
|
||
const adminSessions = new Map();
|
||
let adminModels = [];
|
||
let adminModelIndex = new Map();
|
||
let openrouterSettings = {
|
||
primaryModel: OPENROUTER_MODEL_PRIMARY,
|
||
backupModel1: OPENROUTER_MODEL_BACKUP_1,
|
||
backupModel2: OPENROUTER_MODEL_BACKUP_2,
|
||
backupModel3: OPENROUTER_MODEL_BACKUP_3,
|
||
};
|
||
let mistralSettings = {
|
||
primaryModel: MISTRAL_MODEL_PRIMARY,
|
||
backupModel1: MISTRAL_MODEL_BACKUP_1,
|
||
backupModel2: MISTRAL_MODEL_BACKUP_2,
|
||
backupModel3: MISTRAL_MODEL_BACKUP_3,
|
||
};
|
||
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'ollama', 'cohere'];
|
||
let planSettings = {
|
||
provider: 'openrouter', // legacy field, retained for backwards compatibility
|
||
freePlanModel: '',
|
||
planningChain: [], // [{ provider, model }]
|
||
};
|
||
let providerLimits = {
|
||
limits: {},
|
||
modelProviders: {},
|
||
opencodeBackupModel: '',
|
||
};
|
||
let pendingProviderPersistTimer = null;
|
||
let providerUsage = {};
|
||
let tokenUsage = {};
|
||
let processedTopups = {};
|
||
let pendingTopups = {};
|
||
let externalTestUsage = {};
|
||
|
||
const externalWpTester = createExternalWpTester({ logger: log });
|
||
let processedPayg = {};
|
||
let pendingPayg = {};
|
||
let processedSubscriptions = {};
|
||
let pendingSubscriptions = {};
|
||
let dodoProductCache = {
|
||
fetchedAt: 0,
|
||
items: [],
|
||
byId: new Map(),
|
||
};
|
||
const userSessions = new Map(); // Track user sessions
|
||
let usersDb = []; // In-memory user database cache
|
||
let invoicesDb = []; // In-memory invoice database cache
|
||
let mailTransport = null;
|
||
|
||
// Security Rate Limiting Data Structures
|
||
const loginAttempts = new Map(); // { email:ip: { count, windowStart, lockedUntil } }
|
||
const adminLoginAttempts = new Map(); // { ip: { count, windowStart, lockedUntil } }
|
||
const apiRateLimit = new Map(); // { userId: { requests, windowStart } }
|
||
const csrfTokens = new Map(); // { token: { userId, expiresAt } }
|
||
const affiliateSessions = new Map();
|
||
let affiliatesDb = [];
|
||
let trackingData = {
|
||
visits: [],
|
||
summary: {
|
||
totalVisits: 0,
|
||
uniqueVisitors: new Set(),
|
||
referrers: {},
|
||
pages: {},
|
||
dailyVisits: {},
|
||
conversions: { signup: 0, paid: 0 },
|
||
financials: { totalRevenue: 0, dailyRevenue: {} },
|
||
referrersToUpgrade: {},
|
||
conversionSources: {
|
||
signup: { home: 0, pricing: 0, other: 0 },
|
||
paid: { home: 0, pricing: 0, other: 0 }
|
||
}
|
||
},
|
||
// Enhanced Analytics Tracking
|
||
userAnalytics: {
|
||
userSessions: {}, // userId: { loginTime, lastActivity, sessionDuration, pageViews, featuresUsed, modelUsage }
|
||
dailyActiveUsers: {}, // date: Set of userIds
|
||
weeklyActiveUsers: {}, // weekKey: Set of userIds
|
||
monthlyActiveUsers: {}, // monthKey: Set of userIds
|
||
sessionDurations: [], // Array of session durations in seconds
|
||
projectData: {}, // sessionId: { createdAt, completedAt, status, featuresUsed }
|
||
featureUsage: {}, // featureName: usage count
|
||
modelUsage: {}, // modelName: usage count
|
||
exportUsage: {}, // exportType: count
|
||
errorRates: {}, // errorType: count
|
||
retentionCohorts: {}, // cohortMonth: { cohortSize, retention: { 1week: %, 1month: %, 3month: % } }
|
||
conversionFunnels: {}, // funnelName: steps data
|
||
resourceUtilization: {}, // timestamp: { cpu, memory, queueTime }
|
||
queueMetrics: {}, // timestamp: { waitTime, processedCount }
|
||
planUpgradePatterns: {}, // fromPlan: toPlan: count
|
||
},
|
||
businessMetrics: {
|
||
mrr: 0, // Monthly Recurring Revenue
|
||
ltv: 0, // Lifetime Value
|
||
churnRate: 0, // Churn rate percentage
|
||
customerAcquisitionCost: 0,
|
||
averageRevenuePerUser: 0,
|
||
trialConversions: {}, // plan: conversion rate
|
||
upgradeDowngradePatterns: {}, // fromPlan: { toPlan: count }
|
||
featureAdoptionByPlan: {}, // feature: { plan: usage count }
|
||
},
|
||
technicalMetrics: {
|
||
aiResponseTimes: [], // Array of response times
|
||
aiErrorRates: {}, // provider: error rate
|
||
modelSelectionTrends: {}, // time period: model usage
|
||
queueWaitTimes: [], // Array of wait times
|
||
resourceUsage: [], // Array of resource usage snapshots
|
||
systemHealth: {
|
||
uptime: 0,
|
||
errors: 0,
|
||
lastRestart: null
|
||
}
|
||
}
|
||
};
|
||
let trackingPersistTimer = null;
|
||
let featureRequestsDb = [];
|
||
let contactMessagesDb = [];
|
||
|
||
// Security Configuration with Sensible Defaults
|
||
const ADMIN_LOGIN_RATE_LIMIT = Number(process.env.ADMIN_LOGIN_RATE_LIMIT || 5); // attempts per minute
|
||
const USER_LOGIN_RATE_LIMIT = Number(process.env.USER_LOGIN_RATE_LIMIT || 10);
|
||
const API_RATE_LIMIT = Number(process.env.API_RATE_LIMIT || 100); // requests per minute
|
||
const MAX_PROMPT_LENGTH = Number(process.env.MAX_PROMPT_LENGTH || 10000);
|
||
const LOGIN_LOCKOUT_MS = Number(process.env.LOGIN_LOCKOUT_MS || 900000); // 15 minutes
|
||
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute window
|
||
|
||
// Admin password hashing
|
||
let adminPasswordHash = null;
|
||
|
||
function log(message, extra) {
|
||
const payload = extra ? `${message} ${JSON.stringify(extra)}` : message;
|
||
console.log(`[${new Date().toISOString()}] ${payload}`);
|
||
}
|
||
|
||
// Lowercase identifiers and collapse unsafe characters into hyphens for safe path segments
|
||
function sanitizeSegment(value, fallback = '') {
|
||
if (!value || typeof value !== 'string') return fallback || '';
|
||
const clean = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||
const safe = clean || fallback || '';
|
||
return safe.slice(0, 120);
|
||
}
|
||
|
||
function sanitizeRedirectPath(rawPath, fallback = '/apps') {
|
||
const value = (rawPath || '').trim();
|
||
if (value && value.startsWith('/') && !value.startsWith('//')) return value;
|
||
return fallback;
|
||
}
|
||
|
||
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||
|
||
function getResourceUsageSnapshot() {
|
||
const mem = process.memoryUsage();
|
||
const load = os.loadavg();
|
||
return {
|
||
rss: mem.rss,
|
||
heapUsed: mem.heapUsed,
|
||
load1: load[0] || 0,
|
||
running: runningProcesses.size,
|
||
};
|
||
}
|
||
|
||
async function waitForResources(reasonId = '') {
|
||
const softMemory = RESOURCE_LIMITS.memoryBytes ? RESOURCE_LIMITS.memoryBytes * RESOURCE_MEMORY_SOFT_RATIO : 0;
|
||
const concurrencyLimit = OPENCODE_MAX_CONCURRENCY > 0
|
||
? OPENCODE_MAX_CONCURRENCY
|
||
: (RESOURCE_LIMITS.cpuCores > 0 ? Math.max(1, Math.ceil(RESOURCE_LIMITS.cpuCores)) : 2);
|
||
const loadLimit = RESOURCE_LIMITS.cpuCores > 0 ? Math.max(RESOURCE_LIMITS.cpuCores * RESOURCE_CPU_SOFT_RATIO, RESOURCE_MIN_LOAD_FLOOR) : Infinity;
|
||
let lastLog = 0;
|
||
let waitMs = RESOURCE_CHECK_INTERVAL_MS;
|
||
const maxWaitMs = RESOURCE_CHECK_INTERVAL_MS * 8;
|
||
const startTime = Date.now();
|
||
|
||
// Messages wait indefinitely for resources - never skip/timeout
|
||
// This ensures no messages are lost due to temporary resource constraints
|
||
while (true) {
|
||
// Attempt memory cleanup if we're under pressure
|
||
const waitTime = Date.now() - startTime;
|
||
if (waitTime > 5000 && waitTime % 10000 < waitMs) {
|
||
// Every ~10 seconds of waiting, try to free memory
|
||
triggerMemoryCleanup('resource_wait');
|
||
}
|
||
|
||
const usage = getResourceUsageSnapshot();
|
||
const memoryOk = !softMemory || usage.rss < softMemory;
|
||
// Combine active processes and pending reservations so we don't schedule beyond the allowed concurrency.
|
||
const currentActive = usage.running + resourceReservations;
|
||
const projectedConcurrency = currentActive + 1;
|
||
const concurrencyOk = projectedConcurrency <= concurrencyLimit;
|
||
const loadOk = loadLimit === Infinity || usage.load1 <= loadLimit;
|
||
|
||
if (memoryOk && concurrencyOk && loadOk) {
|
||
let released = false;
|
||
resourceReservations += 1;
|
||
if (waitTime > 1000) {
|
||
log('resource guard acquired after wait', { reasonId, waitTime, rss: usage.rss });
|
||
}
|
||
// Caller must invoke release; processMessage wraps usage in a finally block to avoid leaks.
|
||
return () => {
|
||
if (released) return;
|
||
released = true;
|
||
resourceReservations = Math.max(0, resourceReservations - 1);
|
||
};
|
||
}
|
||
|
||
const now = Date.now();
|
||
if (now - lastLog > RESOURCE_WAIT_LOG_INTERVAL_MS) {
|
||
lastLog = now;
|
||
const reason = !memoryOk ? 'memory_pressure' : (!concurrencyOk ? 'concurrency_limit' : 'cpu_load');
|
||
log('resource guard waiting (message queued)', {
|
||
reasonId,
|
||
reason,
|
||
rss: usage.rss,
|
||
heapUsed: usage.heapUsed,
|
||
load1: usage.load1,
|
||
running: usage.running,
|
||
memoryLimit: RESOURCE_LIMITS.memoryBytes,
|
||
cpuLimit: RESOURCE_LIMITS.cpuCores,
|
||
concurrencyLimit,
|
||
softMemory,
|
||
reservations: resourceReservations,
|
||
waitTime,
|
||
queuedMessages: getQueuedMessageCount()
|
||
});
|
||
}
|
||
await delay(waitMs);
|
||
// Gradually increase wait time to reduce CPU spinning, but cap it
|
||
waitMs = Math.min(maxWaitMs, Math.max(RESOURCE_CHECK_INTERVAL_MS, waitMs * 1.2));
|
||
}
|
||
}
|
||
|
||
// Helper to count queued messages for logging
|
||
function getQueuedMessageCount() {
|
||
let count = 0;
|
||
for (const session of state.sessions) {
|
||
if (session.messages) {
|
||
count += session.messages.filter(m => m.status === 'queued' || m.status === 'running').length;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function resolveBaseUrl(req) {
|
||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL.replace(/\/+$/, '');
|
||
const hostHeader = (req && req.headers && req.headers.host) ? req.headers.host : `${HOST}:${PORT}`;
|
||
const proto = (req && req.headers && req.headers['x-forwarded-proto'] === 'https') ? 'https' : 'http';
|
||
return `${proto}://${hostHeader}`;
|
||
}
|
||
|
||
function buildRedirectUri(req, provider) {
|
||
const base = resolveBaseUrl(req);
|
||
const safeProvider = (provider || '').toLowerCase();
|
||
return `${base}/auth/${safeProvider}/callback`;
|
||
}
|
||
|
||
function createOAuthState(next, provider, remember = false) {
|
||
// Clean up expired entries opportunistically
|
||
const now = Date.now();
|
||
for (const [key, entry] of oauthStateStore.entries()) {
|
||
if (entry.expiresAt && entry.expiresAt < now) oauthStateStore.delete(key);
|
||
}
|
||
const state = randomUUID();
|
||
oauthStateStore.set(state, {
|
||
provider: (provider || '').toLowerCase(),
|
||
next: sanitizeRedirectPath(next),
|
||
remember: Boolean(remember),
|
||
expiresAt: now + OAUTH_STATE_TTL_MS,
|
||
});
|
||
return state;
|
||
}
|
||
|
||
function consumeOAuthState(state, provider) {
|
||
if (!state) return null;
|
||
const entry = oauthStateStore.get(state);
|
||
oauthStateStore.delete(state);
|
||
if (!entry) return null;
|
||
if (entry.provider !== (provider || '').toLowerCase()) return null;
|
||
if (entry.expiresAt && entry.expiresAt < Date.now()) return null;
|
||
return entry;
|
||
}
|
||
|
||
function decodeJwtPayload(token) {
|
||
try {
|
||
const parts = token.split('.');
|
||
if (parts.length < 2) return {};
|
||
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
|
||
return JSON.parse(payload);
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
.replace(/`/g, '`')
|
||
.replace(/\//g, '/');
|
||
}
|
||
|
||
// Security: Rate Limiting Functions
|
||
function checkLoginRateLimit(key, maxAttempts, attemptsMap) {
|
||
const now = Date.now();
|
||
const record = attemptsMap.get(key) || { count: 0, windowStart: now, lockedUntil: null };
|
||
|
||
// Check if locked out
|
||
if (record.lockedUntil && record.lockedUntil > now) {
|
||
return { blocked: true, locked: true, retryAfter: Math.ceil((record.lockedUntil - now) / 1000) };
|
||
}
|
||
|
||
// Reset window if expired
|
||
if (now - record.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||
record.count = 0;
|
||
record.windowStart = now;
|
||
}
|
||
|
||
record.count++;
|
||
|
||
// Lock out if too many attempts
|
||
if (record.count > maxAttempts) {
|
||
record.lockedUntil = now + LOGIN_LOCKOUT_MS;
|
||
log('account locked due to failed attempts', { key, attempts: record.count });
|
||
}
|
||
|
||
attemptsMap.set(key, record);
|
||
|
||
return {
|
||
blocked: record.count > maxAttempts,
|
||
locked: false,
|
||
attempts: record.count,
|
||
remaining: Math.max(0, maxAttempts - record.count)
|
||
};
|
||
}
|
||
|
||
function checkApiRateLimit(userId, maxRequests = API_RATE_LIMIT) {
|
||
const now = Date.now();
|
||
const record = apiRateLimit.get(userId) || { requests: 0, windowStart: now };
|
||
|
||
if (now - record.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||
record.requests = 0;
|
||
record.windowStart = now;
|
||
}
|
||
|
||
record.requests++;
|
||
apiRateLimit.set(userId, record);
|
||
|
||
const remaining = Math.max(0, maxRequests - record.requests);
|
||
const resetIn = Math.ceil((record.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000);
|
||
|
||
return {
|
||
limited: record.requests > maxRequests,
|
||
remaining,
|
||
resetIn,
|
||
limit: maxRequests
|
||
};
|
||
}
|
||
|
||
function sendRateLimitExceeded(res, retryAfter = 60, limit = API_RATE_LIMIT) {
|
||
res.writeHead(429, {
|
||
'Content-Type': 'application/json',
|
||
'Retry-After': retryAfter,
|
||
'X-RateLimit-Limit': limit,
|
||
'X-RateLimit-Remaining': '0',
|
||
'X-RateLimit-Reset': retryAfter
|
||
});
|
||
res.end(JSON.stringify({
|
||
error: 'Rate limit exceeded',
|
||
message: 'Too many requests. Please try again later.',
|
||
retryAfter,
|
||
limit
|
||
}));
|
||
}
|
||
|
||
// Security: CSRF Token Functions
|
||
function generateCsrfToken(userId) {
|
||
const token = randomUUID();
|
||
csrfTokens.set(token, { userId, expiresAt: Date.now() + 3600000 }); // 1 hour
|
||
return token;
|
||
}
|
||
|
||
function validateCsrfToken(token, userId) {
|
||
const record = csrfTokens.get(token);
|
||
if (!record) return false;
|
||
if (record.expiresAt < Date.now()) return false;
|
||
if (record.userId !== userId) return false;
|
||
return true;
|
||
}
|
||
|
||
// Security: Honeypot Detection
|
||
function checkHoneypot(body) {
|
||
return !!(body.website && body.website.length > 0);
|
||
}
|
||
|
||
// Security: Prompt Injection Protection - Comprehensive
|
||
function sanitizePromptInput(input, options = {}) {
|
||
if (!input || typeof input !== 'string') return '';
|
||
|
||
// Use the comprehensive security module
|
||
const result = security.sanitizeUserInput(input, {
|
||
strictMode: options.strictMode || false,
|
||
maxLength: options.maxLength || MAX_PROMPT_LENGTH,
|
||
allowMarkup: options.allowMarkup || false,
|
||
logViolations: options.logViolations !== false // default true
|
||
});
|
||
|
||
// If blocked, return empty string (will be handled by caller)
|
||
if (result.blocked) {
|
||
return '[BLOCKED]';
|
||
}
|
||
|
||
return result.sanitized;
|
||
}
|
||
|
||
// Security: Check if input should be blocked (for pre-validation)
|
||
function shouldBlockUserInput(input) {
|
||
if (!input || typeof input !== 'string') return { blocked: false };
|
||
return security.shouldBlockInput(input);
|
||
}
|
||
|
||
// Security: Output Validation
|
||
function sanitizeAiOutput(output) {
|
||
if (!output || typeof output !== 'string') return '';
|
||
|
||
const patterns = [
|
||
/api[_-]?key["']?\s*[:=]\s*["']?[a-zA-Z0-9_-]{20,}["']?/gi,
|
||
/password["']?\s*[:=]\s*["']?[^"'\s]{8,}["']?/gi,
|
||
/Bearer\s+[a-zA-Z0-9\-\._~\+\/]+=*/gi,
|
||
/AWS_ACCESS_KEY_ID[^\s]*/gi,
|
||
/AWS_SECRET_ACCESS_KEY[^\s]*/gi,
|
||
];
|
||
|
||
let result = output;
|
||
for (const pattern of patterns) {
|
||
result = result.replace(pattern, '[REDACTED]');
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// Security: Password Validation
|
||
function validatePassword(password) {
|
||
const errors = [];
|
||
if (!password || password.length < 12) errors.push('Minimum 12 characters');
|
||
if (!/[A-Z]/.test(password)) errors.push('Uppercase letter required');
|
||
if (!/[a-z]/.test(password)) errors.push('Lowercase letter required');
|
||
if (!/[0-9]/.test(password)) errors.push('Number required');
|
||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) errors.push('Special character required');
|
||
return { valid: errors.length === 0, errors };
|
||
}
|
||
|
||
// Security: Git Action Validation
|
||
const VALID_GIT_ACTIONS = new Set(['pull', 'push', 'sync', 'status', 'log', 'fetch', 'commit', 'checkout', 'branch', 'init', 'clone', 'add', 'reset', 'restore']);
|
||
|
||
function validateGitAction(action) {
|
||
return VALID_GIT_ACTIONS.has(action?.toLowerCase());
|
||
}
|
||
|
||
// Security: Model Validation
|
||
const ALLOWED_MODELS = new Set();
|
||
|
||
function isModelAllowed(model) {
|
||
if (!model) return false;
|
||
if (ALLOWED_MODELS.has(model)) return true;
|
||
if (getAdminModelByIdOrName(model)) return true;
|
||
return false;
|
||
}
|
||
|
||
// Security: Host Header Validation
|
||
function validateHostHeader(host) {
|
||
if (!host || typeof host !== 'string') return false;
|
||
if (host.length > 256) return false;
|
||
const validPattern = /^([a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d+)?$/;
|
||
return validPattern.test(host);
|
||
}
|
||
|
||
// Security: User Agent Validation
|
||
const SUSPICIOUS_USER_AGENTS = [
|
||
/bot/i, /crawler/i, /spider/i, /curl/i, /wget/i, /python/i,
|
||
/httpclient/i, /java\//i, /libwww/i, /lwp/i, /fetch/i
|
||
];
|
||
|
||
function isSuspiciousUserAgent(ua) {
|
||
if (!ua) return true;
|
||
return SUSPICIOUS_USER_AGENTS.some(pattern => pattern.test(ua));
|
||
}
|
||
|
||
// Security: Git Commit Message Sanitization
|
||
function sanitizeGitMessage(message) {
|
||
if (!message || typeof message !== 'string') return '';
|
||
return message
|
||
.replace(/[\r\n]/g, ' ')
|
||
.replace(/[^a-zA-Z0-9\s\-_.,!?'"()[\]{}@#$%^&*+=\\/]/g, '')
|
||
.slice(0, 500)
|
||
.trim();
|
||
}
|
||
|
||
function summarizeMailConfig() {
|
||
return {
|
||
hostConfigured: !!SMTP_HOST,
|
||
portConfigured: Number.isFinite(SMTP_PORT) && SMTP_PORT > 0,
|
||
secure: SMTP_SECURE,
|
||
hasUser: !!SMTP_USER,
|
||
hasPass: !!SMTP_PASS,
|
||
fromConfigured: !!SMTP_FROM,
|
||
};
|
||
}
|
||
|
||
function readUserIdFromCookie(req) {
|
||
try {
|
||
const cookieHeader = req?.headers?.cookie || '';
|
||
if (!cookieHeader) return '';
|
||
const parts = cookieHeader.split(';').map((p) => p.trim());
|
||
const match = parts.find((p) => p.startsWith('chat_user='));
|
||
if (!match) return '';
|
||
// slice/join preserves cookie values that legitimately contain '=' characters
|
||
const raw = match.split('=').slice(1).join('=') || '';
|
||
return decodeURIComponent(raw);
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function resolveUserId(req, url) {
|
||
// First try the new user session system
|
||
const userSession = getUserSession(req);
|
||
if (userSession && userSession.userId) {
|
||
return userSession.userId;
|
||
}
|
||
|
||
// Fall back to the old system for backwards compatibility
|
||
const cookieUser = readUserIdFromCookie(req);
|
||
const headerUser = (req?.headers?.['x-user-id'] || req?.headers?.['x-user'] || req?.headers?.['x-user-email'] || '').toString();
|
||
const resolved = cookieUser || headerUser;
|
||
const sanitized = sanitizeSegment(resolved, '');
|
||
return sanitized;
|
||
}
|
||
|
||
function requireUserId(req, res, url) {
|
||
const userId = resolveUserId(req, url);
|
||
if (!userId) {
|
||
sendJson(res, 401, { error: 'User identity required' });
|
||
return null;
|
||
}
|
||
return userId;
|
||
}
|
||
|
||
function readAdminSessionToken(req) {
|
||
try {
|
||
const cookieHeader = req?.headers?.cookie || '';
|
||
if (!cookieHeader) return '';
|
||
const parts = cookieHeader.split(';').map((p) => p.trim());
|
||
const match = parts.find((p) => p.startsWith(`${ADMIN_COOKIE_NAME}=`));
|
||
if (!match) return '';
|
||
return decodeURIComponent(match.split('=').slice(1).join('=') || '');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getAdminSession(req) {
|
||
const token = readAdminSessionToken(req);
|
||
if (!token) return null;
|
||
const session = adminSessions.get(token);
|
||
if (!session) return null;
|
||
if (session.expiresAt && session.expiresAt < Date.now()) {
|
||
adminSessions.delete(token);
|
||
return null;
|
||
}
|
||
return { token, expiresAt: session.expiresAt };
|
||
}
|
||
|
||
function startAdminSession(res) {
|
||
const token = randomUUID();
|
||
const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS;
|
||
adminSessions.set(token, { expiresAt });
|
||
const parts = [
|
||
`${ADMIN_COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||
'Path=/',
|
||
'HttpOnly',
|
||
'SameSite=Lax',
|
||
`Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}`,
|
||
];
|
||
if (process.env.COOKIE_SECURE === '0') parts.push('Secure');
|
||
res.setHeader('Set-Cookie', parts.join('; '));
|
||
return token;
|
||
}
|
||
|
||
function clearAdminSession(res) {
|
||
res.setHeader('Set-Cookie', `${ADMIN_COOKIE_NAME}=deleted; Path=/; Max-Age=0; SameSite=Lax`);
|
||
}
|
||
|
||
function requireAdminAuth(req, res) {
|
||
const session = getAdminSession(req);
|
||
if (!session) {
|
||
sendJson(res, 401, { error: 'Admin login required' });
|
||
return null;
|
||
}
|
||
return session;
|
||
}
|
||
|
||
// User authentication functions
|
||
async function loadUsersDb() {
|
||
let shouldPersist = false;
|
||
try {
|
||
await ensureStateFile();
|
||
await fs.access(USERS_DB_FILE);
|
||
const raw = await fs.readFile(USERS_DB_FILE, 'utf8');
|
||
const parsed = JSON.parse(raw || '[]');
|
||
const rawUsers = Array.isArray(parsed) ? parsed : [];
|
||
usersDb = rawUsers.map((user) => {
|
||
const verification = normalizeVerificationState(user);
|
||
if (verification.shouldPersist) shouldPersist = true;
|
||
const normalizedPlan = normalizePlanSelection(user?.plan) || DEFAULT_PLAN;
|
||
return {
|
||
...user,
|
||
providers: Array.isArray(user?.providers) ? user.providers : [],
|
||
emailVerified: verification.verified,
|
||
verificationToken: verification.verificationToken,
|
||
verificationExpiresAt: verification.verificationExpiresAt,
|
||
resetToken: user?.resetToken || '',
|
||
resetExpiresAt: user?.resetExpiresAt || null,
|
||
plan: normalizedPlan,
|
||
billingStatus: user?.billingStatus || DEFAULT_BILLING_STATUS,
|
||
billingEmail: user?.billingEmail || user?.email || '',
|
||
paymentMethodLast4: user?.paymentMethodLast4 || '',
|
||
subscriptionRenewsAt: user?.subscriptionRenewsAt || null,
|
||
referredByAffiliateCode: sanitizeAffiliateCode(user?.referredByAffiliateCode),
|
||
affiliateAttributionAt: user?.affiliateAttributionAt || null,
|
||
affiliatePayouts: Array.isArray(user?.affiliatePayouts)
|
||
? user.affiliatePayouts.map((p) => normalizePlanSelection(p)).filter(Boolean)
|
||
: [],
|
||
};
|
||
});
|
||
if (shouldPersist) {
|
||
await persistUsersDb();
|
||
}
|
||
log('Loaded users database', { count: usersDb.length });
|
||
} catch (error) {
|
||
usersDb = [];
|
||
log('Failed to load users database, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistUsersDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(usersDb, null, 2);
|
||
await safeWriteFile(USERS_DB_FILE, payload);
|
||
}
|
||
|
||
async function loadUserSessions() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(USER_SESSIONS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw || '{}');
|
||
const now = Date.now();
|
||
for (const [token, session] of Object.entries(parsed)) {
|
||
if (session.expiresAt && session.expiresAt > now) {
|
||
userSessions.set(token, session);
|
||
}
|
||
}
|
||
log('Loaded user sessions', { count: userSessions.size });
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load user sessions, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistUserSessions() {
|
||
await ensureStateFile();
|
||
const now = Date.now();
|
||
const sessions = {};
|
||
for (const [token, session] of userSessions.entries()) {
|
||
if (session.expiresAt && session.expiresAt > now) {
|
||
sessions[token] = session;
|
||
}
|
||
}
|
||
const payload = JSON.stringify(sessions, null, 2);
|
||
await safeWriteFile(USER_SESSIONS_FILE, payload);
|
||
}
|
||
|
||
function sanitizeAffiliateCode(code) {
|
||
return (code || '').toString().trim().toLowerCase().replace(/[^a-z0-9\-]/g, '').slice(0, 32);
|
||
}
|
||
|
||
async function loadAffiliatesDb() {
|
||
try {
|
||
await ensureStateFile();
|
||
let raw = '[]';
|
||
try {
|
||
raw = await fs.readFile(AFFILIATES_FILE, 'utf8');
|
||
} catch (_) { /* new file */ }
|
||
const parsed = JSON.parse(raw || '[]');
|
||
affiliatesDb = Array.isArray(parsed) ? parsed.map((a) => ({
|
||
...a,
|
||
codes: Array.isArray(a?.codes) && a.codes.length ? a.codes.map((c) => ({
|
||
code: sanitizeAffiliateCode(c.code || c.id || c.slug || ''),
|
||
label: c.label || 'Tracking link',
|
||
createdAt: c.createdAt || a.createdAt || new Date().toISOString(),
|
||
})).filter((c) => c.code) : [],
|
||
earnings: Array.isArray(a?.earnings) ? a.earnings : [],
|
||
commissionRate: Number.isFinite(a?.commissionRate) ? a.commissionRate : AFFILIATE_COMMISSION_RATE,
|
||
})) : [];
|
||
let changed = false;
|
||
for (const affiliate of affiliatesDb) {
|
||
if (!affiliate.codes || !affiliate.codes.length) {
|
||
const fallbackCode = generateTrackingCode();
|
||
affiliate.codes = [{
|
||
code: fallbackCode,
|
||
label: 'Default link',
|
||
createdAt: affiliate.createdAt || new Date().toISOString(),
|
||
}];
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) await persistAffiliatesDb();
|
||
log('Loaded affiliates database', { count: affiliatesDb.length });
|
||
} catch (error) {
|
||
affiliatesDb = [];
|
||
log('Failed to load affiliates database, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistAffiliatesDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(affiliatesDb, null, 2);
|
||
await safeWriteFile(AFFILIATES_FILE, payload);
|
||
}
|
||
|
||
// Withdrawals database
|
||
let withdrawalsDb = [];
|
||
|
||
async function loadWithdrawalsDb() {
|
||
try {
|
||
await ensureStateFile();
|
||
let raw = '[]';
|
||
try {
|
||
raw = await fs.readFile(WITHDRAWALS_FILE, 'utf8');
|
||
} catch (_) { /* new file */ }
|
||
withdrawalsDb = Array.isArray(JSON.parse(raw || '[]')) ? JSON.parse(raw || '[]') : [];
|
||
log('Loaded withdrawals database', { count: withdrawalsDb.length });
|
||
} catch (error) {
|
||
withdrawalsDb = [];
|
||
log('Failed to load withdrawals database, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistWithdrawalsDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(withdrawalsDb, null, 2);
|
||
await safeWriteFile(WITHDRAWALS_FILE, payload);
|
||
}
|
||
|
||
// Feature Requests functions
|
||
async function loadFeatureRequestsDb() {
|
||
try {
|
||
await ensureStateFile();
|
||
let raw = '[]';
|
||
try {
|
||
raw = await fs.readFile(FEATURE_REQUESTS_FILE, 'utf8');
|
||
} catch (_) { /* new file */ }
|
||
const parsed = JSON.parse(raw || '[]');
|
||
featureRequestsDb = Array.isArray(parsed) ? parsed : [];
|
||
log('Loaded feature requests database', { count: featureRequestsDb.length });
|
||
} catch (error) {
|
||
featureRequestsDb = [];
|
||
log('Failed to load feature requests database, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistFeatureRequestsDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(featureRequestsDb, null, 2);
|
||
await safeWriteFile(FEATURE_REQUESTS_FILE, payload);
|
||
}
|
||
|
||
async function loadContactMessagesDb() {
|
||
let contactMessagesDb = [];
|
||
try {
|
||
await ensureStateFile();
|
||
let raw = '[]';
|
||
try {
|
||
raw = await fs.readFile(CONTACT_MESSAGES_FILE, 'utf8');
|
||
} catch (_) { /* new file */ }
|
||
const parsed = JSON.parse(raw || '[]');
|
||
contactMessagesDb = Array.isArray(parsed) ? parsed : [];
|
||
log('Loaded contact messages database', { count: contactMessagesDb.length });
|
||
} catch (error) {
|
||
contactMessagesDb = [];
|
||
log('Failed to load contact messages database, starting fresh', { error: String(error) });
|
||
}
|
||
return contactMessagesDb;
|
||
}
|
||
|
||
async function persistContactMessagesDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(contactMessagesDb, null, 2);
|
||
await safeWriteFile(CONTACT_MESSAGES_FILE, payload);
|
||
}
|
||
|
||
// Tracking functions
|
||
async function loadTrackingData() {
|
||
try {
|
||
await fs.access(TRACKING_FILE);
|
||
const raw = await fs.readFile(TRACKING_FILE, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
|
||
// Convert dailyVisits uniqueVisitors arrays back to Sets
|
||
const dailyVisits = parsed.summary?.dailyVisits || {};
|
||
for (const dateKey in dailyVisits) {
|
||
if (dailyVisits[dateKey]) {
|
||
// Convert array to Set, or create empty Set if missing
|
||
dailyVisits[dateKey].uniqueVisitors = new Set(dailyVisits[dateKey].uniqueVisitors || []);
|
||
}
|
||
}
|
||
|
||
// Convert user analytics Sets back to Sets
|
||
const userAnalytics = parsed.userAnalytics || {};
|
||
|
||
// Convert DAU/WAU/MAU Sets back to Sets
|
||
for (const dateKey in userAnalytics.dailyActiveUsers || {}) {
|
||
userAnalytics.dailyActiveUsers[dateKey] = new Set(userAnalytics.dailyActiveUsers[dateKey] || []);
|
||
}
|
||
for (const weekKey in userAnalytics.weeklyActiveUsers || {}) {
|
||
userAnalytics.weeklyActiveUsers[weekKey] = new Set(userAnalytics.weeklyActiveUsers[weekKey] || []);
|
||
}
|
||
for (const monthKey in userAnalytics.monthlyActiveUsers || {}) {
|
||
userAnalytics.monthlyActiveUsers[monthKey] = new Set(userAnalytics.monthlyActiveUsers[monthKey] || []);
|
||
}
|
||
|
||
trackingData = {
|
||
visits: Array.isArray(parsed.visits) ? parsed.visits : [],
|
||
summary: {
|
||
totalVisits: parsed.summary?.totalVisits || 0,
|
||
uniqueVisitors: new Set(parsed.summary?.uniqueVisitors || []),
|
||
referrers: parsed.summary?.referrers || {},
|
||
pages: parsed.summary?.pages || {},
|
||
dailyVisits: dailyVisits,
|
||
conversions: parsed.summary?.conversions || { signup: 0, paid: 0 },
|
||
financials: parsed.summary?.financials || { totalRevenue: 0, dailyRevenue: {} },
|
||
referrersToUpgrade: parsed.summary?.referrersToUpgrade || {},
|
||
conversionSources: parsed.summary?.conversionSources || {
|
||
signup: { home: 0, pricing: 0, other: 0 },
|
||
paid: { home: 0, pricing: 0, other: 0 }
|
||
}
|
||
},
|
||
userAnalytics: {
|
||
userSessions: userAnalytics.userSessions || {},
|
||
dailyActiveUsers: userAnalytics.dailyActiveUsers || {},
|
||
weeklyActiveUsers: userAnalytics.weeklyActiveUsers || {},
|
||
monthlyActiveUsers: userAnalytics.monthlyActiveUsers || {},
|
||
sessionDurations: Array.isArray(userAnalytics.sessionDurations) ? userAnalytics.sessionDurations : [],
|
||
projectData: userAnalytics.projectData || {},
|
||
featureUsage: userAnalytics.featureUsage || {},
|
||
modelUsage: userAnalytics.modelUsage || {},
|
||
exportUsage: userAnalytics.exportUsage || {},
|
||
errorRates: userAnalytics.errorRates || {},
|
||
retentionCohorts: userAnalytics.retentionCohorts || {},
|
||
conversionFunnels: userAnalytics.conversionFunnels || {},
|
||
resourceUtilization: userAnalytics.resourceUtilization || {},
|
||
queueMetrics: userAnalytics.queueMetrics || {},
|
||
planUpgradePatterns: userAnalytics.planUpgradePatterns || {},
|
||
},
|
||
businessMetrics: {
|
||
mrr: parsed.businessMetrics?.mrr || 0,
|
||
ltv: parsed.businessMetrics?.ltv || 0,
|
||
churnRate: parsed.businessMetrics?.churnRate || 0,
|
||
customerAcquisitionCost: parsed.businessMetrics?.customerAcquisitionCost || 0,
|
||
averageRevenuePerUser: parsed.businessMetrics?.averageRevenuePerUser || 0,
|
||
trialConversions: parsed.businessMetrics?.trialConversions || {},
|
||
upgradeDowngradePatterns: parsed.businessMetrics?.upgradeDowngradePatterns || {},
|
||
featureAdoptionByPlan: parsed.businessMetrics?.featureAdoptionByPlan || {},
|
||
},
|
||
technicalMetrics: {
|
||
aiResponseTimes: Array.isArray(parsed.technicalMetrics?.aiResponseTimes) ? parsed.technicalMetrics.aiResponseTimes : [],
|
||
aiErrorRates: parsed.technicalMetrics?.aiErrorRates || {},
|
||
modelSelectionTrends: parsed.technicalMetrics?.modelSelectionTrends || {},
|
||
queueWaitTimes: Array.isArray(parsed.technicalMetrics?.queueWaitTimes) ? parsed.technicalMetrics.queueWaitTimes : [],
|
||
resourceUsage: Array.isArray(parsed.technicalMetrics?.resourceUsage) ? parsed.technicalMetrics.resourceUsage : [],
|
||
systemHealth: parsed.technicalMetrics?.systemHealth || {
|
||
uptime: 0,
|
||
errors: 0,
|
||
lastRestart: null
|
||
}
|
||
}
|
||
};
|
||
log('Loaded tracking data', {
|
||
totalVisits: trackingData.summary.totalVisits,
|
||
uniqueVisitors: trackingData.summary.uniqueVisitors.size,
|
||
userAnalytics: Object.keys(trackingData.userAnalytics.userSessions).length
|
||
});
|
||
} catch (error) {
|
||
// Initialize with default structure
|
||
trackingData = {
|
||
visits: [],
|
||
summary: {
|
||
totalVisits: 0,
|
||
uniqueVisitors: new Set(),
|
||
referrers: {},
|
||
pages: {},
|
||
dailyVisits: {},
|
||
conversions: { signup: 0, paid: 0 },
|
||
financials: { totalRevenue: 0, dailyRevenue: {} },
|
||
referrersToUpgrade: {},
|
||
conversionSources: {
|
||
signup: { home: 0, pricing: 0, other: 0 },
|
||
paid: { home: 0, pricing: 0, other: 0 }
|
||
}
|
||
},
|
||
userAnalytics: {
|
||
userSessions: {},
|
||
dailyActiveUsers: {},
|
||
weeklyActiveUsers: {},
|
||
monthlyActiveUsers: {},
|
||
sessionDurations: [],
|
||
projectData: {},
|
||
featureUsage: {},
|
||
modelUsage: {},
|
||
exportUsage: {},
|
||
errorRates: {},
|
||
retentionCohorts: {},
|
||
conversionFunnels: {},
|
||
resourceUtilization: {},
|
||
queueMetrics: {},
|
||
planUpgradePatterns: {},
|
||
},
|
||
businessMetrics: {
|
||
mrr: 0,
|
||
ltv: 0,
|
||
churnRate: 0,
|
||
customerAcquisitionCost: 0,
|
||
averageRevenuePerUser: 0,
|
||
trialConversions: {},
|
||
upgradeDowngradePatterns: {},
|
||
featureAdoptionByPlan: {},
|
||
},
|
||
technicalMetrics: {
|
||
aiResponseTimes: [],
|
||
aiErrorRates: {},
|
||
modelSelectionTrends: {},
|
||
queueWaitTimes: [],
|
||
resourceUsage: [],
|
||
systemHealth: {
|
||
uptime: 0,
|
||
errors: 0,
|
||
lastRestart: null
|
||
}
|
||
}
|
||
};
|
||
log('Failed to load tracking data, starting fresh', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistTrackingData() {
|
||
await ensureStateFile();
|
||
|
||
// Convert dailyVisits Sets to arrays for JSON serialization
|
||
const serializedDailyVisits = {};
|
||
for (const dateKey in trackingData.summary.dailyVisits) {
|
||
const dayData = trackingData.summary.dailyVisits[dateKey];
|
||
serializedDailyVisits[dateKey] = {
|
||
count: dayData.count,
|
||
uniqueVisitors: Array.from(dayData.uniqueVisitors || [])
|
||
};
|
||
}
|
||
|
||
// Convert user analytics Sets to arrays for JSON serialization
|
||
const serializedUserAnalytics = {
|
||
userSessions: trackingData.userAnalytics.userSessions,
|
||
dailyActiveUsers: {},
|
||
weeklyActiveUsers: {},
|
||
monthlyActiveUsers: {},
|
||
sessionDurations: trackingData.userAnalytics.sessionDurations,
|
||
projectData: trackingData.userAnalytics.projectData,
|
||
featureUsage: trackingData.userAnalytics.featureUsage,
|
||
modelUsage: trackingData.userAnalytics.modelUsage,
|
||
exportUsage: trackingData.userAnalytics.exportUsage,
|
||
errorRates: trackingData.userAnalytics.errorRates,
|
||
retentionCohorts: trackingData.userAnalytics.retentionCohorts,
|
||
conversionFunnels: trackingData.userAnalytics.conversionFunnels,
|
||
resourceUtilization: trackingData.userAnalytics.resourceUtilization,
|
||
queueMetrics: trackingData.userAnalytics.queueMetrics,
|
||
planUpgradePatterns: trackingData.userAnalytics.planUpgradePatterns,
|
||
};
|
||
|
||
// Convert DAU/WAU/MAU Sets to arrays
|
||
for (const dateKey in trackingData.userAnalytics.dailyActiveUsers) {
|
||
serializedUserAnalytics.dailyActiveUsers[dateKey] = Array.from(trackingData.userAnalytics.dailyActiveUsers[dateKey] || []);
|
||
}
|
||
for (const weekKey in trackingData.userAnalytics.weeklyActiveUsers) {
|
||
serializedUserAnalytics.weeklyActiveUsers[weekKey] = Array.from(trackingData.userAnalytics.weeklyActiveUsers[weekKey] || []);
|
||
}
|
||
for (const monthKey in trackingData.userAnalytics.monthlyActiveUsers) {
|
||
serializedUserAnalytics.monthlyActiveUsers[monthKey] = Array.from(trackingData.userAnalytics.monthlyActiveUsers[monthKey] || []);
|
||
}
|
||
|
||
const payload = {
|
||
visits: trackingData.visits.slice(-10000), // Keep last 10k visits
|
||
summary: {
|
||
totalVisits: trackingData.summary.totalVisits,
|
||
uniqueVisitors: Array.from(trackingData.summary.uniqueVisitors),
|
||
referrers: trackingData.summary.referrers,
|
||
pages: trackingData.summary.pages,
|
||
dailyVisits: serializedDailyVisits,
|
||
conversions: trackingData.summary.conversions,
|
||
financials: trackingData.summary.financials,
|
||
referrersToUpgrade: trackingData.summary.referrersToUpgrade,
|
||
conversionSources: trackingData.summary.conversionSources
|
||
},
|
||
userAnalytics: serializedUserAnalytics,
|
||
businessMetrics: trackingData.businessMetrics,
|
||
technicalMetrics: trackingData.technicalMetrics
|
||
};
|
||
await safeWriteFile(TRACKING_FILE, JSON.stringify(payload, null, 2));
|
||
}
|
||
|
||
function scheduleTrackingPersist() {
|
||
if (trackingPersistTimer) clearTimeout(trackingPersistTimer);
|
||
trackingPersistTimer = setTimeout(async () => {
|
||
try {
|
||
await persistTrackingData();
|
||
} catch (error) {
|
||
log('Failed to persist tracking data', { error: String(error) });
|
||
}
|
||
}, TRACKING_PERSIST_INTERVAL_MS);
|
||
}
|
||
|
||
function sanitizeUrl(urlString) {
|
||
// Fix malformed URLs with double slashes or other common issues
|
||
if (!urlString || urlString === '') return '/';
|
||
|
||
// Fix URLs starting with '//' which creates ambiguous protocol-relative URLs
|
||
if (urlString.startsWith('//')) {
|
||
return urlString.slice(1);
|
||
}
|
||
|
||
return urlString;
|
||
}
|
||
|
||
function trackVisit(req, res) {
|
||
try {
|
||
let pathname;
|
||
try {
|
||
// Sanitize URL before parsing
|
||
const urlString = sanitizeUrl(req.url);
|
||
pathname = new URL(urlString, 'http://localhost').pathname;
|
||
} catch (urlError) {
|
||
// If URL parsing fails, skip tracking
|
||
log('Tracking skipped - invalid URL', { url: req.url, error: String(urlError) });
|
||
return;
|
||
}
|
||
|
||
// Skip tracking for static assets, API calls, and admin pages
|
||
if (
|
||
pathname.startsWith('/assets/') ||
|
||
pathname.startsWith('/api/') ||
|
||
pathname.startsWith('/admin/') ||
|
||
pathname.match(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|map|txt|pdf|xml|json)$/i)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const referrer = req.headers.referer || req.headers.referrer || 'direct';
|
||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||
req.headers['x-real-ip'] ||
|
||
req.socket.remoteAddress ||
|
||
'unknown';
|
||
|
||
// Check if we should count this as a new session visit
|
||
const visitSession = readCookieValue(req, 'v_session');
|
||
const isNewSession = !visitSession;
|
||
if (isNewSession && res) {
|
||
const sessionToken = randomUUID();
|
||
const parts = [
|
||
`v_session=${sessionToken}`,
|
||
'Path=/',
|
||
'Max-Age=1800',
|
||
'SameSite=Lax'
|
||
];
|
||
if (process.env.COOKIE_SECURE !== '0') parts.push('Secure');
|
||
res.setHeader('Set-Cookie', parts.join('; '));
|
||
|
||
// Store landing info for conversion tracking
|
||
const landingInfo = {
|
||
landingPage: pathname,
|
||
originalReferrer: referrer,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
res.setHeader('Set-Cookie', `v_landing=${encodeURIComponent(JSON.stringify(landingInfo))}; Path=/; Max-Age=86400; SameSite=Lax`);
|
||
}
|
||
|
||
const visit = {
|
||
timestamp: new Date().toISOString(),
|
||
path: pathname,
|
||
referrer: referrer,
|
||
userAgent: userAgent,
|
||
ip: ip
|
||
};
|
||
|
||
// Add to visits array
|
||
trackingData.visits.push(visit);
|
||
|
||
// Update summary
|
||
if (isNewSession) {
|
||
trackingData.summary.totalVisits += 1;
|
||
}
|
||
trackingData.summary.uniqueVisitors.add(ip);
|
||
|
||
// Track referrer
|
||
const referrerDomain = getReferrerDomain(referrer);
|
||
trackingData.summary.referrers[referrerDomain] = (trackingData.summary.referrers[referrerDomain] || 0) + 1;
|
||
|
||
// Track pages
|
||
trackingData.summary.pages[pathname] = (trackingData.summary.pages[pathname] || 0) + 1;
|
||
|
||
// Track referrers to upgrade/pricing page
|
||
if (pathname === '/upgrade.html' || pathname === '/pricing.html' || pathname === '/select-plan.html') {
|
||
trackingData.summary.referrersToUpgrade[referrerDomain] = (trackingData.summary.referrersToUpgrade[referrerDomain] || 0) + 1;
|
||
}
|
||
|
||
// Track daily visits
|
||
const dateKey = new Date().toISOString().split('T')[0];
|
||
if (!trackingData.summary.dailyVisits[dateKey]) {
|
||
trackingData.summary.dailyVisits[dateKey] = {
|
||
count: 0,
|
||
uniqueVisitors: new Set()
|
||
};
|
||
}
|
||
if (isNewSession) {
|
||
trackingData.summary.dailyVisits[dateKey].count += 1;
|
||
}
|
||
trackingData.summary.dailyVisits[dateKey].uniqueVisitors.add(ip);
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
// Silent fail - tracking should not break the app
|
||
log('Tracking error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackConversion(type, req) {
|
||
try {
|
||
if (!trackingData.summary.conversions) {
|
||
trackingData.summary.conversions = { signup: 0, paid: 0 };
|
||
}
|
||
if (!trackingData.summary.conversions[type]) {
|
||
trackingData.summary.conversions[type] = 0;
|
||
}
|
||
trackingData.summary.conversions[type] += 1;
|
||
|
||
// Track source
|
||
const landingInfoRaw = readCookieValue(req, 'v_landing');
|
||
let source = 'other';
|
||
if (landingInfoRaw) {
|
||
try {
|
||
const landingInfo = JSON.parse(decodeURIComponent(landingInfoRaw));
|
||
if (landingInfo.landingPage === '/' || landingInfo.landingPage === '/home.html') source = 'home';
|
||
else if (landingInfo.landingPage === '/pricing.html') source = 'pricing';
|
||
} catch (_) {}
|
||
}
|
||
|
||
if (!trackingData.summary.conversionSources) {
|
||
trackingData.summary.conversionSources = {
|
||
signup: { home: 0, pricing: 0, other: 0 },
|
||
paid: { home: 0, pricing: 0, other: 0 }
|
||
};
|
||
}
|
||
if (!trackingData.summary.conversionSources[type]) {
|
||
trackingData.summary.conversionSources[type] = { home: 0, pricing: 0, other: 0 };
|
||
}
|
||
trackingData.summary.conversionSources[type][source] += 1;
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Conversion tracking error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackFinancial(amount, plan = 'unknown') {
|
||
try {
|
||
if (!trackingData.summary.financials) {
|
||
trackingData.summary.financials = { totalRevenue: 0, dailyRevenue: {} };
|
||
}
|
||
const val = Number(amount) || 0;
|
||
trackingData.summary.financials.totalRevenue += val;
|
||
|
||
const dateKey = new Date().toISOString().split('T')[0];
|
||
if (!trackingData.summary.financials.dailyRevenue[dateKey]) {
|
||
trackingData.summary.financials.dailyRevenue[dateKey] = 0;
|
||
}
|
||
trackingData.summary.financials.dailyRevenue[dateKey] += val;
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Financial tracking error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
// Enhanced Analytics Tracking Functions
|
||
function trackUserSession(userId, action = 'login', data = {}) {
|
||
try {
|
||
if (!trackingData.userAnalytics.userSessions[userId]) {
|
||
trackingData.userAnalytics.userSessions[userId] = {
|
||
loginTime: null,
|
||
lastActivity: null,
|
||
sessionDuration: 0,
|
||
pageViews: [],
|
||
featuresUsed: {},
|
||
modelUsage: {},
|
||
projectCount: 0,
|
||
exports: 0,
|
||
errors: 0
|
||
};
|
||
}
|
||
|
||
const session = trackingData.userAnalytics.userSessions[userId];
|
||
const now = Date.now();
|
||
|
||
switch (action) {
|
||
case 'login':
|
||
session.loginTime = now;
|
||
session.lastActivity = now;
|
||
break;
|
||
case 'activity':
|
||
session.lastActivity = now;
|
||
if (data.page) session.pageViews.push({ path: data.page, timestamp: now });
|
||
if (data.feature) {
|
||
session.featuresUsed[data.feature] = (session.featuresUsed[data.feature] || 0) + 1;
|
||
}
|
||
if (data.model) {
|
||
session.modelUsage[data.model] = (session.modelUsage[data.model] || 0) + 1;
|
||
}
|
||
break;
|
||
case 'logout':
|
||
if (session.loginTime) {
|
||
session.sessionDuration = now - session.loginTime;
|
||
trackingData.userAnalytics.sessionDurations.push(session.sessionDuration / 1000); // Store in seconds
|
||
}
|
||
break;
|
||
case 'project_created':
|
||
session.projectCount += 1;
|
||
if (data.sessionId) {
|
||
trackingData.userAnalytics.projectData[data.sessionId] = {
|
||
createdAt: now,
|
||
completedAt: null,
|
||
status: 'created',
|
||
featuresUsed: session.featuresUsed,
|
||
plan: data.plan || 'unknown'
|
||
};
|
||
}
|
||
break;
|
||
case 'project_completed':
|
||
if (data.sessionId && trackingData.userAnalytics.projectData[data.sessionId]) {
|
||
trackingData.userAnalytics.projectData[data.sessionId].completedAt = now;
|
||
trackingData.userAnalytics.projectData[data.sessionId].status = 'completed';
|
||
}
|
||
break;
|
||
case 'export':
|
||
session.exports += 1;
|
||
const exportType = data.exportType || 'unknown';
|
||
trackingData.userAnalytics.exportUsage[exportType] = (trackingData.userAnalytics.exportUsage[exportType] || 0) + 1;
|
||
break;
|
||
case 'error':
|
||
session.errors += 1;
|
||
const errorType = data.errorType || 'unknown';
|
||
trackingData.userAnalytics.errorRates[errorType] = (trackingData.userAnalytics.errorRates[errorType] || 0) + 1;
|
||
break;
|
||
}
|
||
|
||
// Update DAU/WAU/MAU
|
||
updateActiveUsers(userId);
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('User session tracking error', { userId, action, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function updateActiveUsers(userId) {
|
||
const now = new Date();
|
||
const dateKey = now.toISOString().split('T')[0];
|
||
const weekKey = `${now.getFullYear()}-W${Math.ceil((now.getDate() + now.getDay()) / 7)}`;
|
||
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||
|
||
// Initialize if needed
|
||
if (!trackingData.userAnalytics.dailyActiveUsers[dateKey]) {
|
||
trackingData.userAnalytics.dailyActiveUsers[dateKey] = new Set();
|
||
}
|
||
if (!trackingData.userAnalytics.weeklyActiveUsers[weekKey]) {
|
||
trackingData.userAnalytics.weeklyActiveUsers[weekKey] = new Set();
|
||
}
|
||
if (!trackingData.userAnalytics.monthlyActiveUsers[monthKey]) {
|
||
trackingData.userAnalytics.monthlyActiveUsers[monthKey] = new Set();
|
||
}
|
||
|
||
// Add user to all periods
|
||
trackingData.userAnalytics.dailyActiveUsers[dateKey].add(userId);
|
||
trackingData.userAnalytics.weeklyActiveUsers[weekKey].add(userId);
|
||
trackingData.userAnalytics.monthlyActiveUsers[monthKey].add(userId);
|
||
}
|
||
|
||
function trackFeatureUsage(featureName, userId = null, plan = 'unknown') {
|
||
try {
|
||
trackingData.userAnalytics.featureUsage[featureName] = (trackingData.userAnalytics.featureUsage[featureName] || 0) + 1;
|
||
|
||
// Track feature adoption by plan
|
||
if (!trackingData.businessMetrics.featureAdoptionByPlan[featureName]) {
|
||
trackingData.businessMetrics.featureAdoptionByPlan[featureName] = {};
|
||
}
|
||
trackingData.businessMetrics.featureAdoptionByPlan[featureName][plan] =
|
||
(trackingData.businessMetrics.featureAdoptionByPlan[featureName][plan] || 0) + 1;
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Feature usage tracking error', { featureName, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackModelUsage(modelName, userId = null, plan = 'unknown') {
|
||
try {
|
||
trackingData.userAnalytics.modelUsage[modelName] = (trackingData.userAnalytics.modelUsage[modelName] || 0) + 1;
|
||
|
||
// Track model selection trends over time
|
||
const timeKey = new Date().toISOString().slice(0, 7); // YYYY-MM format
|
||
if (!trackingData.technicalMetrics.modelSelectionTrends[timeKey]) {
|
||
trackingData.technicalMetrics.modelSelectionTrends[timeKey] = {};
|
||
}
|
||
trackingData.technicalMetrics.modelSelectionTrends[timeKey][modelName] =
|
||
(trackingData.technicalMetrics.modelSelectionTrends[timeKey][modelName] || 0) + 1;
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Model usage tracking error', { modelName, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackConversionFunnel(funnelName, step, userId = null, data = {}) {
|
||
try {
|
||
if (!trackingData.userAnalytics.conversionFunnels[funnelName]) {
|
||
trackingData.userAnalytics.conversionFunnels[funnelName] = {};
|
||
}
|
||
|
||
if (!trackingData.userAnalytics.conversionFunnels[funnelName][step]) {
|
||
trackingData.userAnalytics.conversionFunnels[funnelName][step] = {
|
||
count: 0,
|
||
users: new Set(),
|
||
data: {}
|
||
};
|
||
}
|
||
|
||
trackingData.userAnalytics.conversionFunnels[funnelName][step].count += 1;
|
||
if (userId) {
|
||
trackingData.userAnalytics.conversionFunnels[funnelName][step].users.add(userId);
|
||
}
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Conversion funnel tracking error', { funnelName, step, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackResourceUtilization() {
|
||
try {
|
||
const timestamp = Date.now();
|
||
const usage = getResourceUsageSnapshot();
|
||
|
||
trackingData.userAnalytics.resourceUtilization[timestamp] = {
|
||
memory: usage.rss,
|
||
heapUsed: usage.heapUsed,
|
||
cpu: usage.load1,
|
||
activeProcesses: usage.running
|
||
};
|
||
|
||
trackingData.technicalMetrics.resourceUsage.push({
|
||
timestamp,
|
||
memory: usage.rss,
|
||
heapUsed: usage.heapUsed,
|
||
cpu: usage.load1,
|
||
activeProcesses: usage.running
|
||
});
|
||
|
||
// Keep only last 1000 entries
|
||
if (trackingData.technicalMetrics.resourceUsage.length > 1000) {
|
||
trackingData.technicalMetrics.resourceUsage = trackingData.technicalMetrics.resourceUsage.slice(-1000);
|
||
}
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Resource utilization tracking error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackQueueMetrics(waitTime, processedCount = 1) {
|
||
try {
|
||
const timestamp = Date.now();
|
||
|
||
trackingData.userAnalytics.queueMetrics[timestamp] = {
|
||
waitTime,
|
||
processedCount
|
||
};
|
||
|
||
trackingData.technicalMetrics.queueWaitTimes.push(waitTime);
|
||
|
||
// Keep only last 1000 entries
|
||
if (trackingData.technicalMetrics.queueWaitTimes.length > 1000) {
|
||
trackingData.technicalMetrics.queueWaitTimes = trackingData.technicalMetrics.queueWaitTimes.slice(-1000);
|
||
}
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Queue metrics tracking error', { waitTime, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackAIResponseTime(responseTime, provider, success = true, errorType = null) {
|
||
try {
|
||
trackingData.technicalMetrics.aiResponseTimes.push({
|
||
timestamp: Date.now(),
|
||
responseTime,
|
||
provider,
|
||
success
|
||
});
|
||
|
||
// Track error rates by provider
|
||
if (!trackingData.technicalMetrics.aiErrorRates[provider]) {
|
||
trackingData.technicalMetrics.aiErrorRates[provider] = {
|
||
total: 0,
|
||
errors: 0,
|
||
errorRate: 0
|
||
};
|
||
}
|
||
|
||
trackingData.technicalMetrics.aiErrorRates[provider].total += 1;
|
||
if (!success) {
|
||
trackingData.technicalMetrics.aiErrorRates[provider].errors += 1;
|
||
trackingData.userAnalytics.errorRates[errorType || 'ai_error'] =
|
||
(trackingData.userAnalytics.errorRates[errorType || 'ai_error'] || 0) + 1;
|
||
}
|
||
|
||
trackingData.technicalMetrics.aiErrorRates[provider].errorRate =
|
||
(trackingData.technicalMetrics.aiErrorRates[provider].errors /
|
||
trackingData.technicalMetrics.aiErrorRates[provider].total * 100);
|
||
|
||
// Keep only last 1000 entries
|
||
if (trackingData.technicalMetrics.aiResponseTimes.length > 1000) {
|
||
trackingData.technicalMetrics.aiResponseTimes = trackingData.technicalMetrics.aiResponseTimes.slice(-1000);
|
||
}
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('AI response time tracking error', { responseTime, provider, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function trackPlanUpgrade(fromPlan, toPlan, userId = null) {
|
||
try {
|
||
if (!trackingData.userAnalytics.planUpgradePatterns[fromPlan]) {
|
||
trackingData.userAnalytics.planUpgradePatterns[fromPlan] = {};
|
||
}
|
||
trackingData.userAnalytics.planUpgradePatterns[fromPlan][toPlan] =
|
||
(trackingData.userAnalytics.planUpgradePatterns[fromPlan][toPlan] || 0) + 1;
|
||
|
||
if (!trackingData.businessMetrics.upgradeDowngradePatterns[fromPlan]) {
|
||
trackingData.businessMetrics.upgradeDowngradePatterns[fromPlan] = {};
|
||
}
|
||
trackingData.businessMetrics.upgradeDowngradePatterns[fromPlan][toPlan] =
|
||
(trackingData.businessMetrics.upgradeDowngradePatterns[fromPlan][toPlan] || 0) + 1;
|
||
|
||
scheduleTrackingPersist();
|
||
} catch (error) {
|
||
log('Plan upgrade tracking error', { fromPlan, toPlan, error: String(error) });
|
||
}
|
||
}
|
||
|
||
function calculateBusinessMetrics() {
|
||
try {
|
||
const now = Date.now();
|
||
const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000);
|
||
const activeUsers = usersDb.filter(u => {
|
||
const lastActive = u.lastActiveAt ? new Date(u.lastActiveAt).getTime() : 0;
|
||
return lastActive > thirtyDaysAgo;
|
||
});
|
||
|
||
// Calculate MRR (Monthly Recurring Revenue)
|
||
let mrr = 0;
|
||
const planPrices = {
|
||
hobby: 0,
|
||
starter: 7.50,
|
||
business: 25,
|
||
enterprise: 75
|
||
};
|
||
|
||
usersDb.forEach(user => {
|
||
if (PAID_PLANS.has(user.plan)) {
|
||
mrr += planPrices[user.plan] || 0;
|
||
}
|
||
});
|
||
|
||
trackingData.businessMetrics.mrr = mrr;
|
||
|
||
// Calculate LTV (simplified)
|
||
const avgRevenue = mrr / Math.max(activeUsers.length, 1);
|
||
const avgLifespanMonths = 12; // Assume 12 months average
|
||
trackingData.businessMetrics.ltv = avgRevenue * avgLifespanMonths;
|
||
|
||
// Calculate churn rate (users who cancelled in last 30 days)
|
||
const cancelledUsers = usersDb.filter(u =>
|
||
u.plan === 'cancelled' &&
|
||
u.cancelledAt &&
|
||
new Date(u.cancelledAt).getTime() > thirtyDaysAgo
|
||
).length;
|
||
|
||
const totalUsers30DaysAgo = usersDb.filter(u =>
|
||
new Date(u.createdAt).getTime() <= thirtyDaysAgo
|
||
).length;
|
||
|
||
trackingData.businessMetrics.churnRate = totalUsers30DaysAgo > 0
|
||
? (cancelledUsers / totalUsers30DaysAgo * 100)
|
||
: 0;
|
||
|
||
// Calculate ARPU (Average Revenue Per User)
|
||
trackingData.businessMetrics.averageRevenuePerUser =
|
||
activeUsers.length > 0 ? mrr / activeUsers.length : 0;
|
||
|
||
} catch (error) {
|
||
log('Business metrics calculation error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function calculateRetentionCohorts() {
|
||
try {
|
||
const cohorts = {};
|
||
const now = Date.now();
|
||
|
||
usersDb.forEach(user => {
|
||
const cohortMonth = new Date(user.createdAt).toISOString().slice(0, 7); // YYYY-MM
|
||
|
||
if (!cohorts[cohortMonth]) {
|
||
cohorts[cohortMonth] = {
|
||
cohortSize: 0,
|
||
users: [],
|
||
retention: {
|
||
'1week': 0,
|
||
'1month': 0,
|
||
'3month': 0
|
||
}
|
||
};
|
||
}
|
||
|
||
cohorts[cohortMonth].cohortSize += 1;
|
||
cohorts[cohortMonth].users.push(user);
|
||
});
|
||
|
||
// Calculate retention for each cohort
|
||
Object.keys(cohorts).forEach(cohortMonth => {
|
||
const cohort = cohorts[cohortMonth];
|
||
const cohortStart = new Date(cohortMonth + '-01').getTime();
|
||
|
||
// 1 week retention
|
||
const oneWeekAgo = cohortStart + (7 * 24 * 60 * 60 * 1000);
|
||
const oneWeekActive = cohort.users.filter(u => {
|
||
const lastActive = u.lastActiveAt ? new Date(u.lastActiveAt).getTime() : 0;
|
||
return lastActive > oneWeekAgo;
|
||
}).length;
|
||
cohort.retention['1week'] = cohort.cohortSize > 0 ? (oneWeekActive / cohort.cohortSize * 100) : 0;
|
||
|
||
// 1 month retention
|
||
const oneMonthAgo = cohortStart + (30 * 24 * 60 * 60 * 1000);
|
||
const oneMonthActive = cohort.users.filter(u => {
|
||
const lastActive = u.lastActiveAt ? new Date(u.lastActiveAt).getTime() : 0;
|
||
return lastActive > oneMonthAgo;
|
||
}).length;
|
||
cohort.retention['1month'] = cohort.cohortSize > 0 ? (oneMonthActive / cohort.cohortSize * 100) : 0;
|
||
|
||
// 3 month retention
|
||
const threeMonthAgo = cohortStart + (90 * 24 * 60 * 60 * 1000);
|
||
const threeMonthActive = cohort.users.filter(u => {
|
||
const lastActive = u.lastActiveAt ? new Date(u.lastActiveAt).getTime() : 0;
|
||
return lastActive > threeMonthAgo;
|
||
}).length;
|
||
cohort.retention['3month'] = cohort.cohortSize > 0 ? (threeMonthActive / cohort.cohortSize * 100) : 0;
|
||
});
|
||
|
||
trackingData.userAnalytics.retentionCohorts = cohorts;
|
||
} catch (error) {
|
||
log('Retention cohort calculation error', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function getAnalyticsSummary() {
|
||
try {
|
||
// Update business metrics
|
||
calculateBusinessMetrics();
|
||
calculateRetentionCohorts();
|
||
|
||
const now = new Date();
|
||
const today = now.toISOString().split('T')[0];
|
||
const thisWeek = `${now.getFullYear()}-W${Math.ceil((now.getDate() + now.getDay()) / 7)}`;
|
||
const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||
|
||
// Get DAU/WAU/MAU counts
|
||
const dau = trackingData.userAnalytics.dailyActiveUsers[today]?.size || 0;
|
||
const wau = trackingData.userAnalytics.weeklyActiveUsers[thisWeek]?.size || 0;
|
||
const mau = trackingData.userAnalytics.monthlyActiveUsers[thisMonth]?.size || 0;
|
||
|
||
// Calculate average session duration
|
||
const avgSessionDuration = trackingData.userAnalytics.sessionDurations.length > 0
|
||
? trackingData.userAnalytics.sessionDurations.reduce((a, b) => a + b, 0) / trackingData.userAnalytics.sessionDurations.length
|
||
: 0;
|
||
|
||
// Calculate project completion rate
|
||
const totalProjects = Object.keys(trackingData.userAnalytics.projectData).length;
|
||
const completedProjects = Object.values(trackingData.userAnalytics.projectData)
|
||
.filter(p => p.status === 'completed').length;
|
||
const projectCompletionRate = totalProjects > 0 ? (completedProjects / totalProjects * 100) : 0;
|
||
|
||
// Calculate return user rate
|
||
const totalUsers = usersDb.length;
|
||
const returningUsers = Object.keys(trackingData.userAnalytics.userSessions)
|
||
.filter(uid => {
|
||
const session = trackingData.userAnalytics.userSessions[uid];
|
||
return session.loginTime && session.sessionDuration > 0 && session.pageViews.length > 1;
|
||
}).length;
|
||
const returnUserRate = totalUsers > 0 ? (returningUsers / totalUsers * 100) : 0;
|
||
|
||
// Calculate free to paid conversion rate
|
||
const freeUsers = usersDb.filter(u => u.plan === 'hobby').length;
|
||
const convertedUsers = usersDb.filter(u => PAID_PLANS.has(u.plan)).length;
|
||
const freeToPaidConversionRate = freeUsers > 0 ? (convertedUsers / freeUsers * 100) : 0;
|
||
|
||
// Calculate trial to subscription conversion
|
||
const trialUsers = usersDb.filter(u => u.trialStarted).length;
|
||
const trialConversions = usersDb.filter(u => u.trialStarted && PAID_PLANS.has(u.plan)).length;
|
||
const trialToSubscriptionConversion = trialUsers > 0 ? (trialConversions / trialUsers * 100) : 0;
|
||
|
||
return {
|
||
userEngagement: {
|
||
dau,
|
||
wau,
|
||
mau,
|
||
averageSessionDuration: Math.round(avgSessionDuration),
|
||
projectCompletionRate: Math.round(projectCompletionRate),
|
||
returnUserRate: Math.round(returnUserRate),
|
||
freeToPaidConversionRate: Math.round(freeToPaidConversionRate),
|
||
trialToSubscriptionConversion: Math.round(trialToSubscriptionConversion)
|
||
},
|
||
featureUsage: trackingData.userAnalytics.featureUsage,
|
||
modelUsage: trackingData.userAnalytics.modelUsage,
|
||
exportUsage: trackingData.userAnalytics.exportUsage,
|
||
errorRates: trackingData.userAnalytics.errorRates,
|
||
retentionCohorts: trackingData.userAnalytics.retentionCohorts,
|
||
businessMetrics: trackingData.businessMetrics,
|
||
technicalMetrics: {
|
||
aiResponseTimes: trackingData.technicalMetrics.aiResponseTimes.slice(-100), // Last 100
|
||
aiErrorRates: trackingData.technicalMetrics.aiErrorRates,
|
||
modelSelectionTrends: trackingData.technicalMetrics.modelSelectionTrends,
|
||
averageQueueTime: trackingData.technicalMetrics.queueWaitTimes.length > 0
|
||
? Math.round(trackingData.technicalMetrics.queueWaitTimes.reduce((a, b) => a + b, 0) / trackingData.technicalMetrics.queueWaitTimes.length)
|
||
: 0,
|
||
resourceUtilization: trackingData.userAnalytics.resourceUtilization,
|
||
systemHealth: trackingData.technicalMetrics.systemHealth
|
||
},
|
||
planUpgradePatterns: trackingData.userAnalytics.planUpgradePatterns,
|
||
conversionFunnels: trackingData.userAnalytics.conversionFunnels,
|
||
featureAdoptionByPlan: trackingData.businessMetrics.featureAdoptionByPlan
|
||
};
|
||
} catch (error) {
|
||
log('Analytics summary calculation error', { error: String(error) });
|
||
return {
|
||
userEngagement: {
|
||
dau: 0, wau: 0, mau: 0, averageSessionDuration: 0, projectCompletionRate: 0,
|
||
returnUserRate: 0, freeToPaidConversionRate: 0, trialToSubscriptionConversion: 0
|
||
},
|
||
featureUsage: {},
|
||
modelUsage: {},
|
||
exportUsage: {},
|
||
errorRates: {},
|
||
retentionCohours: {},
|
||
businessMetrics: {},
|
||
technicalMetrics: {},
|
||
planUpgradePatterns: {},
|
||
conversionFunnels: {},
|
||
featureAdoptionByPlan: {}
|
||
};
|
||
}
|
||
}
|
||
|
||
async function handleUpgradePopupTracking(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const source = (body && body.source) ? String(body.source).toLowerCase() : 'unknown';
|
||
|
||
// Initialize upgradeSources tracking
|
||
if (!trackingData.summary.upgradeSources) {
|
||
trackingData.summary.upgradeSources = {
|
||
apps_page: 0,
|
||
builder_model: 0,
|
||
usage_limit: 0,
|
||
other: 0
|
||
};
|
||
}
|
||
|
||
// Normalize source to known keys
|
||
const sourceKey = {
|
||
'apps_page': 'apps_page',
|
||
'apps': 'apps_page',
|
||
'builder_model': 'builder_model',
|
||
'model_select': 'builder_model',
|
||
'usage_limit': 'usage_limit',
|
||
'token_limit': 'usage_limit',
|
||
'out_of_tokens': 'usage_limit',
|
||
}[source] || 'other';
|
||
|
||
// Increment counter for this source
|
||
trackingData.summary.upgradeSources[sourceKey] = (trackingData.summary.upgradeSources[sourceKey] || 0) + 1;
|
||
|
||
log('Upgrade popup source tracked', { source, sourceKey });
|
||
scheduleTrackingPersist();
|
||
|
||
sendJson(res, 200, { ok: true });
|
||
} catch (error) {
|
||
log('Upgrade popup tracking error', { error: String(error) });
|
||
sendJson(res, 400, { error: 'Failed to track upgrade popup source' });
|
||
}
|
||
}
|
||
|
||
function calculateRetention() {
|
||
try {
|
||
const now = Date.now();
|
||
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
|
||
|
||
// 1-week retention: Users who signed up 7-14 days ago and were active in the last 7 days
|
||
const cohortStart = now - (14 * 24 * 60 * 60 * 1000);
|
||
const cohortEnd = now - (7 * 24 * 60 * 60 * 1000);
|
||
|
||
const cohort = usersDb.filter(u => {
|
||
const created = new Date(u.createdAt).getTime();
|
||
return created >= cohortStart && created <= cohortEnd;
|
||
});
|
||
|
||
if (cohort.length === 0) return 0;
|
||
|
||
const retained = cohort.filter(u => {
|
||
const lastActive = u.lastActiveAt ? new Date(u.lastActiveAt).getTime() :
|
||
(u.lastLoginAt ? new Date(u.lastLoginAt).getTime() : 0);
|
||
return lastActive > cohortEnd;
|
||
});
|
||
|
||
return parseFloat(((retained.length / cohort.length) * 100).toFixed(1));
|
||
} catch (error) {
|
||
log('Retention calculation error', { error: String(error) });
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function getReferrerDomain(referrer) {
|
||
if (!referrer || referrer === 'direct') return 'direct';
|
||
try {
|
||
const url = new URL(referrer);
|
||
return url.hostname;
|
||
} catch {
|
||
return 'unknown';
|
||
}
|
||
}
|
||
|
||
function findAffiliateByEmail(email) {
|
||
const normalized = (email || '').trim().toLowerCase();
|
||
return affiliatesDb.find((a) => a.email === normalized);
|
||
}
|
||
|
||
function findAffiliateByCode(code) {
|
||
const normalized = sanitizeAffiliateCode(code);
|
||
if (!normalized) return null;
|
||
return affiliatesDb.find((a) => Array.isArray(a.codes) && a.codes.some((c) => c.code === normalized));
|
||
}
|
||
|
||
function generateTrackingCode(seed = '') {
|
||
const base = sanitizeAffiliateCode(seed);
|
||
const randomBit = randomBytes(3).toString('hex');
|
||
let candidate = base ? `${base}-${randomBit}` : randomBytes(5).toString('hex');
|
||
let attempt = 0;
|
||
while (findAffiliateByCode(candidate) && attempt < 5) {
|
||
candidate = base ? `${base}-${randomBytes(4).toString('hex')}` : randomBytes(6).toString('hex');
|
||
attempt += 1;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
async function registerAffiliate({ email, password, name }) {
|
||
const normalized = (email || '').trim().toLowerCase();
|
||
if (!EMAIL_REGEX.test(normalized)) throw new Error('Email is invalid');
|
||
if (findAffiliateByEmail(normalized)) throw new Error('Affiliate already exists with this email');
|
||
if (!password || password.length < 6) throw new Error('Password must be at least 6 characters long');
|
||
const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS);
|
||
const createdAt = new Date().toISOString();
|
||
const code = generateTrackingCode();
|
||
const affiliate = {
|
||
id: randomUUID(),
|
||
email: normalized,
|
||
name: (name || '').trim() || normalized.split('@')[0],
|
||
password: hashedPassword,
|
||
createdAt,
|
||
lastLoginAt: null,
|
||
commissionRate: AFFILIATE_COMMISSION_RATE,
|
||
codes: [{ code, label: 'Default link', createdAt }],
|
||
earnings: [],
|
||
lastPayoutAt: null,
|
||
emailVerified: false,
|
||
verificationToken: '',
|
||
verificationExpiresAt: null,
|
||
};
|
||
assignVerificationToken(affiliate);
|
||
affiliatesDb.push(affiliate);
|
||
await persistAffiliatesDb();
|
||
return affiliate;
|
||
}
|
||
|
||
async function verifyAffiliatePassword(email, password) {
|
||
const affiliate = findAffiliateByEmail(email);
|
||
if (!affiliate || !affiliate.password) return null;
|
||
const valid = await bcrypt.compare(password, affiliate.password);
|
||
if (!valid) return null;
|
||
affiliate.lastLoginAt = new Date().toISOString();
|
||
await persistAffiliatesDb();
|
||
return affiliate;
|
||
}
|
||
|
||
function summarizeAffiliate(affiliate) {
|
||
if (!affiliate) return null;
|
||
const earnings = Array.isArray(affiliate.earnings) ? affiliate.earnings : [];
|
||
const total = earnings.reduce((sum, e) => sum + Number(e.amount || 0), 0);
|
||
return {
|
||
id: affiliate.id,
|
||
email: affiliate.email,
|
||
name: affiliate.name || affiliate.email,
|
||
emailVerified: affiliate.emailVerified || false,
|
||
commissionRate: affiliate.commissionRate || AFFILIATE_COMMISSION_RATE,
|
||
trackingLinks: affiliate.codes || [],
|
||
earnings: {
|
||
total,
|
||
currency: 'USD',
|
||
records: earnings.slice(-20),
|
||
},
|
||
createdAt: affiliate.createdAt,
|
||
lastLoginAt: affiliate.lastLoginAt,
|
||
};
|
||
}
|
||
|
||
function readCookieValue(req, name) {
|
||
try {
|
||
const cookieHeader = req?.headers?.cookie || '';
|
||
if (!cookieHeader) return '';
|
||
const parts = cookieHeader.split(';').map((p) => p.trim());
|
||
const match = parts.find((p) => p.startsWith(`${name}=`));
|
||
if (!match) return '';
|
||
return decodeURIComponent(match.split('=').slice(1).join('=') || '');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function readAffiliateSessionToken(req) {
|
||
return readCookieValue(req, AFFILIATE_COOKIE_NAME);
|
||
}
|
||
|
||
function getAffiliateSession(req) {
|
||
const token = readAffiliateSessionToken(req);
|
||
if (!token) return null;
|
||
const session = affiliateSessions.get(token);
|
||
if (!session) return null;
|
||
if (session.expiresAt && session.expiresAt < Date.now()) {
|
||
affiliateSessions.delete(token);
|
||
return null;
|
||
}
|
||
return { token, affiliateId: session.affiliateId, expiresAt: session.expiresAt };
|
||
}
|
||
|
||
function startAffiliateSession(res, affiliateId) {
|
||
const token = randomUUID();
|
||
const expiresAt = Date.now() + AFFILIATE_SESSION_TTL_MS;
|
||
affiliateSessions.set(token, { affiliateId, expiresAt });
|
||
const parts = [
|
||
`${AFFILIATE_COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||
'Path=/',
|
||
'HttpOnly',
|
||
'SameSite=Lax',
|
||
`Max-Age=${Math.floor(AFFILIATE_SESSION_TTL_MS / 1000)}`,
|
||
];
|
||
if (process.env.COOKIE_SECURE !== '0') parts.push('Secure');
|
||
res.setHeader('Set-Cookie', parts.join('; '));
|
||
return token;
|
||
}
|
||
|
||
function clearAffiliateSession(res) {
|
||
res.setHeader('Set-Cookie', `${AFFILIATE_COOKIE_NAME}=deleted; Path=/; Max-Age=0; SameSite=Lax`);
|
||
}
|
||
|
||
function requireAffiliateAuth(req, res) {
|
||
const session = getAffiliateSession(req);
|
||
if (!session) {
|
||
sendJson(res, 401, { error: 'Affiliate authentication required' });
|
||
return null;
|
||
}
|
||
const affiliate = affiliatesDb.find((a) => a.id === session.affiliateId);
|
||
if (!affiliate) {
|
||
sendJson(res, 401, { error: 'Affiliate session is invalid' });
|
||
return null;
|
||
}
|
||
return { session, affiliate };
|
||
}
|
||
|
||
function setAffiliateReferralCookie(res, code) {
|
||
const parts = [
|
||
`${AFFILIATE_REF_COOKIE}=${encodeURIComponent(code)}`,
|
||
'Path=/',
|
||
'SameSite=Lax',
|
||
`Max-Age=${AFFILIATE_REF_COOKIE_TTL_SECONDS}`,
|
||
];
|
||
if (process.env.COOKIE_SECURE !== '0') parts.push('Secure');
|
||
res.setHeader('Set-Cookie', parts.join('; '));
|
||
}
|
||
|
||
function readAffiliateReferralCode(req) {
|
||
return sanitizeAffiliateCode(readCookieValue(req, AFFILIATE_REF_COOKIE));
|
||
}
|
||
|
||
async function trackAffiliateCommission(user, plan) {
|
||
const normalizedPlan = normalizePlanSelection(plan);
|
||
if (!normalizedPlan || !PAID_PLANS.has(normalizedPlan)) return;
|
||
if (!user || !user.referredByAffiliateCode) return;
|
||
user.affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||
const affiliate = findAffiliateByCode(user.referredByAffiliateCode);
|
||
if (!affiliate) return;
|
||
const price = PLAN_PRICES[normalizedPlan] || 0;
|
||
if (!price) return;
|
||
const amount = price * (affiliate.commissionRate || AFFILIATE_COMMISSION_RATE);
|
||
const record = {
|
||
id: randomUUID(),
|
||
userId: user.id,
|
||
plan: normalizedPlan,
|
||
amount,
|
||
currency: 'USD',
|
||
description: `${normalizedPlan} subscription`,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
affiliate.earnings = Array.isArray(affiliate.earnings) ? affiliate.earnings : [];
|
||
affiliate.earnings.push(record);
|
||
affiliate.lastPayoutAt = record.createdAt;
|
||
user.affiliatePayouts.push(normalizedPlan);
|
||
await persistAffiliatesDb();
|
||
await persistUsersDb();
|
||
}
|
||
|
||
function ensureMailTransport() {
|
||
if (mailTransport) return mailTransport;
|
||
const invalidPort = !Number.isFinite(SMTP_PORT) || SMTP_PORT <= 0;
|
||
if (!SMTP_HOST || invalidPort || !SMTP_FROM || !SMTP_USER || !SMTP_PASS) {
|
||
log('⚠️ SMTP configuration is incomplete. Emails will be logged to CONSOLE only (not sent).', summarizeMailConfig());
|
||
console.log('');
|
||
console.log('┌─────────────────────────────────────────────────────────────┐');
|
||
console.log('│ 📧 EMAIL/SMTP NOT CONFIGURED │');
|
||
console.log('├─────────────────────────────────────────────────────────────┤');
|
||
console.log('│ Password reset and verification emails will NOT be sent. │');
|
||
console.log('│ To enable real emails, configure SMTP in .env file: │');
|
||
console.log('│ │');
|
||
console.log('│ SMTP_HOST=smtp.gmail.com (or your SMTP server) │');
|
||
console.log('│ SMTP_PORT=587 │');
|
||
console.log('│ SMTP_USER=your-email@gmail.com │');
|
||
console.log('│ SMTP_PASS=your-app-password │');
|
||
console.log('│ SMTP_FROM=noreply@yourdomain.com │');
|
||
console.log('│ │');
|
||
console.log('│ 💡 Tip: Emails will be logged below when triggered │');
|
||
console.log('└─────────────────────────────────────────────────────────────┘');
|
||
console.log('');
|
||
// Create a mock transport that logs to console
|
||
mailTransport = {
|
||
sendMail: async (payload) => {
|
||
console.log('');
|
||
console.log('┌─────────────────────────────────────────────────────────────┐');
|
||
console.log('│ 📧 [MOCK EMAIL - Not Sent] │');
|
||
console.log('├─────────────────────────────────────────────────────────────┤');
|
||
console.log(`│ To: ${payload.to}`);
|
||
console.log(`│ Subject: ${payload.subject}`);
|
||
console.log('│ Body preview:');
|
||
const preview = (payload.text || payload.html || '').slice(0, 200).replace(/\n/g, ' ');
|
||
console.log(`│ ${preview}...`);
|
||
console.log('│ │');
|
||
console.log('│ Configure SMTP in .env to send real emails │');
|
||
console.log('└─────────────────────────────────────────────────────────────┘');
|
||
console.log('');
|
||
return { messageId: 'mock-' + Date.now() };
|
||
},
|
||
verify: (cb) => cb(null, true)
|
||
};
|
||
return mailTransport;
|
||
}
|
||
|
||
log('initializing mail transport', summarizeMailConfig());
|
||
mailTransport = nodemailer.createTransport({
|
||
host: SMTP_HOST,
|
||
port: SMTP_PORT,
|
||
secure: SMTP_SECURE,
|
||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||
// Add timeouts to avoid long hangs
|
||
connectionTimeout: 5000,
|
||
greetingTimeout: 5000,
|
||
socketTimeout: 5000,
|
||
});
|
||
|
||
// Verify the connection asynchronously
|
||
mailTransport.verify((error) => {
|
||
if (error) {
|
||
log('❌ SMTP verification failed', { error: String(error) });
|
||
} else {
|
||
log('✅ SMTP transport verified and ready');
|
||
}
|
||
});
|
||
|
||
return mailTransport;
|
||
}
|
||
|
||
async function sendEmail({ to, subject, text, html }) {
|
||
try {
|
||
const transport = ensureMailTransport();
|
||
const plain = text || undefined;
|
||
const payload = {
|
||
from: SMTP_FROM || 'noreply@plugincompass.com',
|
||
to,
|
||
subject,
|
||
};
|
||
if (plain) payload.text = plain;
|
||
if (html) payload.html = html;
|
||
|
||
log('sending email', { to, subject });
|
||
const info = await transport.sendMail(payload);
|
||
log('email sent successfully', { to, messageId: info.messageId });
|
||
return info;
|
||
} catch (err) {
|
||
log('❌ FAILED TO SEND EMAIL', {
|
||
to,
|
||
subject,
|
||
error: String(err),
|
||
hint: 'Check SMTP configuration in .env file'
|
||
});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function assignVerificationToken(user) {
|
||
user.emailVerified = false;
|
||
user.verificationToken = randomBytes(32).toString('hex');
|
||
user.verificationExpiresAt = new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS).toISOString();
|
||
}
|
||
|
||
function assignPasswordResetToken(user) {
|
||
user.resetToken = randomBytes(32).toString('hex');
|
||
user.resetExpiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
|
||
}
|
||
|
||
function normalizeVerificationState(user) {
|
||
const hasExplicitFlag = user?.emailVerified === true || user?.emailVerified === false;
|
||
let verified = user?.emailVerified === true || (!hasExplicitFlag && !!user?.lastLoginAt);
|
||
let verificationToken = user?.verificationToken || '';
|
||
let verificationExpiresAt = user?.verificationExpiresAt || null;
|
||
let shouldPersist = false;
|
||
|
||
if (!verified && !verificationToken) {
|
||
const tmp = { emailVerified: false, verificationToken: '', verificationExpiresAt: null };
|
||
assignVerificationToken(tmp);
|
||
verificationToken = tmp.verificationToken;
|
||
verificationExpiresAt = tmp.verificationExpiresAt;
|
||
shouldPersist = true;
|
||
}
|
||
|
||
if (!hasExplicitFlag) shouldPersist = true;
|
||
|
||
return { verified, verificationToken, verificationExpiresAt, shouldPersist };
|
||
}
|
||
|
||
function renderBrandedEmail({ title, preheader, bodyHtml, buttonText, buttonLink, showHero = false, heroTitle = '', heroSubtitle = '' }) {
|
||
const logoBase = (PUBLIC_BASE_URL || '').replace(/\/+/g, '');
|
||
const logoUrl = (logoBase ? `${logoBase}/assets/Plugin.png` : '/assets/Plugin.png');
|
||
const accent = '#004225';
|
||
const accent2 = '#006B3D';
|
||
const accentLight = '#E8F5EC';
|
||
const bg = '#f9f7f4';
|
||
const cardBg = '#ffffff';
|
||
const textPrimary = '#1a1a1a';
|
||
const textSecondary = '#666666';
|
||
const textMuted = '#999999';
|
||
const borderColor = '#e8e4de';
|
||
const safeTitle = escapeHtml(title || '');
|
||
const safePre = escapeHtml(preheader || '');
|
||
const safeBtnLink = escapeHtml(buttonLink || '');
|
||
|
||
const heroSection = showHero ? `
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, ${accent} 0%, ${accent2} 100%); padding: 32px 40px; text-align: center;">
|
||
<img src="${logoUrl}" alt="Plugin Compass" width="56" height="56" style="border-radius:12px; display: inline-block; margin-bottom: 16px; background: rgba(255,255,255,0.15);">
|
||
<h1 style="color: #ffffff; margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -0.5px;">${escapeHtml(heroTitle || safeTitle)}</h1>
|
||
${heroSubtitle ? `<p style="color: rgba(255,255,255,0.85); margin: 8px 0 0 0; font-size: 15px;">${escapeHtml(heroSubtitle)}</p>` : ''}
|
||
</td>
|
||
</tr>
|
||
` : `
|
||
<tr>
|
||
<td style="padding: 28px 32px 16px 32px; text-align: center; border-bottom: 1px solid ${borderColor};">
|
||
<img src="${logoUrl}" alt="Plugin Compass" width="52" height="52" style="border-radius:10px; display: inline-block; margin-bottom: 8px;">
|
||
<div style="color: ${accent}; font-size: 16px; font-weight: 700; letter-spacing: -0.3px;">${safeTitle}</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${safeTitle}</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||
body { margin: 0; padding: 0; background: ${bg}; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; }
|
||
.wrapper { width: 100%; max-width: 620px; margin: 0 auto; padding: 24px 16px; }
|
||
.card { background: ${cardBg}; border-radius: 16px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.06); border: 1px solid ${borderColor}; }
|
||
.card-inner { background: ${cardBg}; }
|
||
.body-content { padding: 28px 32px 24px 32px; color: ${textPrimary}; font-size: 15px; line-height: 1.7; }
|
||
.body-content p { margin: 0 0 16px 0; }
|
||
.body-content p:last-child { margin-bottom: 0; }
|
||
.details-box { background: ${bg}; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid ${borderColor}; }
|
||
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid ${borderColor}; }
|
||
.detail-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||
.detail-row:first-child { padding-top: 0; }
|
||
.detail-label { color: ${textSecondary}; font-size: 14px; }
|
||
.detail-value { font-weight: 600; color: ${textPrimary}; font-size: 14px; }
|
||
.cta-section { padding: 0 32px 28px 32px; text-align: center; }
|
||
.btn { display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, ${accent} 0%, ${accent2} 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25); }
|
||
.btn:hover { opacity: 0.95; }
|
||
.success-icon { width: 64px; height: 64px; background: ${accentLight}; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px; }
|
||
.success-icon svg { width: 32px; height: 32px; color: ${accent}; }
|
||
.footer { padding: 24px 32px; border-top: 1px solid ${borderColor}; text-align: center; font-size: 13px; color: ${textMuted}; }
|
||
.footer a { color: ${accent}; text-decoration: none; }
|
||
.footer a:hover { text-decoration: underline; }
|
||
.preheader { display: none !important; visibility: hidden; mso-hide: all; font-size: 0; line-height: 0; }
|
||
.divider { height: 1px; background: ${borderColor}; margin: 0; }
|
||
.highlight-box { background: ${accentLight}; border-left: 4px solid ${accent}; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0; }
|
||
@media only screen and (max-width: 640px) {
|
||
.wrapper { padding: 12px; }
|
||
.body-content { padding: 20px 24px 20px 24px; }
|
||
.cta-section { padding: 0 24px 24px 24px; }
|
||
.footer { padding: 20px 24px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<span class="preheader">${safePre}</span>
|
||
<div class="wrapper">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
<tr>
|
||
<td>
|
||
<table class="card" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
${heroSection}
|
||
<tr>
|
||
<td class="body-content">
|
||
${bodyHtml}
|
||
</td>
|
||
</tr>
|
||
${buttonText ? `
|
||
<tr>
|
||
<td class="cta-section">
|
||
<a class="btn" href="${safeBtnLink}">${escapeHtml(buttonText)}</a>
|
||
</td>
|
||
</tr>
|
||
` : ''}
|
||
<tr>
|
||
<td class="divider"></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="footer">
|
||
<p style="margin: 0 0 8px 0;">Need help? Contact us at <a href="mailto:support@plugincompass.com">support@plugincompass.com</a></p>
|
||
<p style="margin: 0 0 4px 0;">© ${new Date().getFullYear()} Plugin Compass. All rights reserved.</p>
|
||
<p style="margin: 0;"><a href="${escapeHtml(PUBLIC_BASE_URL || '')}">${escapeHtml(PUBLIC_BASE_URL || '') || 'Visit our website'}</a> | <a href="${escapeHtml(PUBLIC_BASE_URL || '' || '')}/privacy">Privacy</a> | <a href="${escapeHtml(PUBLIC_BASE_URL || '' || '')}/terms">Terms</a></p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
return html;
|
||
}
|
||
|
||
async function sendVerificationEmail(user, baseUrl) {
|
||
if (!user || !user.email || !user.verificationToken) return;
|
||
const link = `${baseUrl.replace(/\/+$/, '')}/verify-email?token=${encodeURIComponent(user.verificationToken)}`;
|
||
const safeLink = escapeHtml(link);
|
||
|
||
const text = `Welcome!\n\nPlease verify your email address by visiting the link below:\n${link}\n\nThis link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.`;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to Plugin Compass!</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Thanks for signing up! Please verify your email address to get started with AI-powered development.</p>
|
||
|
||
<div style="text-align: center; margin: 28px 0;">
|
||
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Verify Email Address</a>
|
||
</div>
|
||
|
||
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
|
||
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.</strong><br>If you didn't sign up for Plugin Compass, you can safely ignore this email.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Verify your email',
|
||
preheader: 'Confirm your email to get started',
|
||
bodyHtml,
|
||
buttonText: '',
|
||
buttonLink: '',
|
||
});
|
||
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Verify your email', text, html });
|
||
}
|
||
|
||
async function sendAffiliateVerificationEmail(affiliate, baseUrl) {
|
||
if (!affiliate || !affiliate.email || !affiliate.verificationToken) return;
|
||
const link = `${baseUrl.replace(/\/+$/, '')}/affiliate-verify-email?token=${encodeURIComponent(affiliate.verificationToken)}`;
|
||
const safeLink = escapeHtml(link);
|
||
|
||
const text = `Welcome to the Plugin Compass Affiliate Program!\n\nPlease verify your email address by visiting the link below:\n${link}\n\nThis link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.`;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to the Affiliate Program!</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Thanks for joining our Affiliate Program! Please verify your email address to start earning 7.5% recurring commissions.</p>
|
||
|
||
<div style="text-align: center; margin: 28px 0;">
|
||
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Verify Email Address</a>
|
||
</div>
|
||
|
||
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
|
||
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.</strong><br>If you didn't sign up for the Affiliate Program, you can safely ignore this email.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Verify your affiliate email',
|
||
preheader: 'Confirm your email to start earning commissions',
|
||
bodyHtml,
|
||
buttonText: '',
|
||
buttonLink: '',
|
||
});
|
||
|
||
await sendEmail({ to: affiliate.email, subject: 'Plugin Compass — Verify your affiliate email', text, html });
|
||
}
|
||
|
||
async function sendPasswordResetEmail(user, baseUrl) {
|
||
if (!user || !user.email || !user.resetToken) return;
|
||
const link = `${baseUrl.replace(/\/+$/, '')}/reset-password?token=${encodeURIComponent(user.resetToken)}`;
|
||
const safeLink = escapeHtml(link);
|
||
|
||
const text = `You requested a password reset.\n\nReset your password using the link below:\n${link}\n\nThis link expires in ${Math.round(PASSWORD_RESET_TTL_MS / (60 * 1000))} minutes.`;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#E65100" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Reset your password</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We received a request to reset your password. Click the button below to create a new password for your account.</p>
|
||
|
||
<div style="text-align: center; margin: 28px 0;">
|
||
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Reset Password</a>
|
||
</div>
|
||
|
||
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
|
||
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
|
||
|
||
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #E65100; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(PASSWORD_RESET_TTL_MS / (60 * 1000))} minutes.</strong><br>If you didn't request a password reset, you can safely ignore this email.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Reset your password',
|
||
preheader: 'Reset access to your Plugin Compass account',
|
||
bodyHtml,
|
||
buttonText: '',
|
||
buttonLink: '',
|
||
});
|
||
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Reset your password', text, html });
|
||
}
|
||
|
||
async function sendPaymentConfirmationEmail(user, paymentType, details) {
|
||
if (!user || !user.email) return;
|
||
|
||
let title, preheader, bodyHtml, buttonText, buttonLink, subject, showHero, heroTitle, heroSubtitle;
|
||
|
||
if (paymentType === 'topup') {
|
||
const tokens = details.tokens || 0;
|
||
const currency = (details.currency || 'USD').toUpperCase();
|
||
const amount = (details.amount || 0) / 100;
|
||
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
|
||
|
||
subject = 'Plugin Compass — Payment Confirmed';
|
||
title = 'Payment Successful';
|
||
preheader = 'Your token purchase is complete';
|
||
showHero = true;
|
||
heroTitle = 'Payment Confirmed';
|
||
heroSubtitle = 'Your tokens have been added to your account';
|
||
|
||
bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div class="success-icon" style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Thank you for your purchase!</p>
|
||
</div>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Description</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Token Top-up</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Tokens Added</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${tokens.toLocaleString()}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Amount Paid</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Completed</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your tokens are ready to use!</strong> You can now use them for AI-powered development and building.</p>
|
||
</div>
|
||
`;
|
||
|
||
buttonText = 'View Your Account';
|
||
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
|
||
|
||
} else if (paymentType === 'payg') {
|
||
const tokens = details.tokens || 0;
|
||
const currency = (details.currency || 'USD').toUpperCase();
|
||
const amount = (details.amount || 0) / 100;
|
||
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
|
||
|
||
subject = 'Plugin Compass — Payment Confirmed';
|
||
title = 'Pay-as-you-go Billing';
|
||
preheader = 'Your overage billing is complete';
|
||
showHero = true;
|
||
heroTitle = 'Payment Processed';
|
||
heroSubtitle = 'Your usage has been billed';
|
||
|
||
bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div class="success-icon" style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Your pay-as-you-go billing has been processed</p>
|
||
</div>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Type</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Pay-as-you-go Usage</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Tokens Billed</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${tokens.toLocaleString()}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Amount Charged</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Completed</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
buttonText = 'View Usage';
|
||
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
|
||
|
||
} else if (paymentType === 'subscription') {
|
||
const plan = details.plan || '';
|
||
const billingCycle = details.billingCycle || 'monthly';
|
||
const currency = (details.currency || 'USD').toUpperCase();
|
||
const amount = (details.amount || 0) / 100;
|
||
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
|
||
const nextBillingDate = new Date();
|
||
nextBillingDate.setMonth(nextBillingDate.getMonth() + (billingCycle === 'yearly' ? 12 : 1));
|
||
|
||
subject = 'Plugin Compass — Subscription Activated';
|
||
title = 'Welcome to ' + plan.charAt(0).toUpperCase() + plan.slice(1);
|
||
preheader = 'Your subscription is now active';
|
||
showHero = true;
|
||
heroTitle = 'Subscription Active';
|
||
heroSubtitle = 'Welcome to ' + plan.charAt(0).toUpperCase() + plan.slice(1) + ' Plan';
|
||
|
||
bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to the ${plan.charAt(0).toUpperCase() + plan.slice(1)} Plan!</p>
|
||
</div>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription is now active!</strong> You have full access to all ${plan} features and benefits.</p>
|
||
</div>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Plan</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${plan.charAt(0).toUpperCase() + plan.slice(1)}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Billing Cycle</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${billingCycle.charAt(0).toUpperCase() + billingCycle.slice(1)}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Amount</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}/${billingCycle === 'yearly' ? 'year' : 'month'}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Next Billing Date</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${nextBillingDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Active</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
buttonText = 'Explore Your Plan';
|
||
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
|
||
}
|
||
|
||
const html = renderBrandedEmail({
|
||
title,
|
||
preheader,
|
||
bodyHtml,
|
||
buttonText,
|
||
buttonLink,
|
||
showHero,
|
||
heroTitle,
|
||
heroSubtitle,
|
||
});
|
||
|
||
const text = bodyHtml.replace(/<[^>]*>/g, '').replace(/\n\s*\n/g, '\n\n').trim();
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject, text, html });
|
||
} catch (err) {
|
||
log('failed to send payment confirmation email', { userId: user.id, email: user.email, paymentType, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendPaymentCancelledEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Cancelled</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your payment has been cancelled. This could be due to a timeout, customer cancellation, or payment method issue.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>No charges were made.</strong> You can try making the payment again if you wish.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Payment Cancelled',
|
||
preheader: 'Your payment was cancelled',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send payment cancelled email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeAcceptedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Accepted</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A dispute related to your payment has been accepted by the bank. The funds have been returned to the customer.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please contact support if you have any questions.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Accepted',
|
||
preheader: 'Payment dispute accepted by bank',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Accepted', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute accepted email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeCancelledEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Cancelled</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Good news! The dispute related to your payment has been cancelled and your account is in good standing.</p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription remains active.</strong> Thank you for your continued support!</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Cancelled',
|
||
preheader: 'Payment dispute has been cancelled',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute cancelled email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeChallengedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Challenged</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A dispute related to your payment has been challenged. The bank is reviewing the case.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription may be temporarily affected.</strong> We'll notify you once the review is complete.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Challenged',
|
||
preheader: 'Payment dispute is being reviewed',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Challenged', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute challenged email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeExpiredEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Expired</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Good news! The dispute related to your payment has expired. The dispute period has ended and the original charge stands.</p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your account remains in good standing.</strong> Thank you for your continued support!</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Expired',
|
||
preheader: 'Payment dispute has expired',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Expired', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute expired email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeLostEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Lost</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We regret to inform you that the dispute has been decided in favor of the customer. The funds have been refunded.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> If you believe this is an error, please contact our support team.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Lost',
|
||
preheader: 'Payment dispute was not successful',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Lost', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute lost email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendDisputeWonEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Won</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Great news! The dispute has been decided in your favor. The original charge remains valid.</p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your account remains in good standing.</strong> Thank you for your patience during this process.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Dispute Won',
|
||
preheader: 'Payment dispute was successful',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Won', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send dispute won email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendRefundFailedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Refund Failed</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We attempted to process a refund but it was unsuccessful. This could be due to expired card details or payment processor issues.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Please contact support</strong> if you were expecting a refund but haven't received it.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Refund Failed',
|
||
preheader: 'Refund processing failed',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Refund Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send refund failed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendPaymentFailedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Failed</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your payment could not be processed. This could be due to insufficient funds, expired card, or other payment issues.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please update your payment method to reactivate.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Payment Failed',
|
||
preheader: 'Your payment could not be processed',
|
||
bodyHtml,
|
||
buttonText: 'Update Payment',
|
||
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send payment failed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionActiveEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Your subscription is active!</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Great news! Your subscription has been activated successfully. You now have full access to your plan features.</p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Current Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription Active',
|
||
preheader: 'Your subscription is now active',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Active', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription active email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionExpiredEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Expired</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has expired and you have been downgraded to the Hobby plan.</p>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Current Plan</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Hobby</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #D32F2F; font-size: 14px;">Expired</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Resubscribe anytime</strong> to regain access to all premium features and benefits.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription Expired',
|
||
preheader: 'Your subscription has expired',
|
||
bodyHtml,
|
||
buttonText: 'Renew Subscription',
|
||
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Expired', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription expired email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionOnHoldEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription On Hold</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been placed on hold. This could be due to a payment issue or account review.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Please update your payment method</strong> or contact support to reactivate your subscription.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription On Hold',
|
||
preheader: 'Your subscription is temporarily on hold',
|
||
bodyHtml,
|
||
buttonText: 'Update Payment',
|
||
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription On Hold', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription on hold email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionPlanChangedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E3F2FD; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#1976D2" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Plan Changed</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription plan has been updated successfully.</p>
|
||
|
||
<div class="highlight-box" style="background: #E3F2FD; border-left: 4px solid #1976D2; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>New Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Plan Changed',
|
||
preheader: 'Your subscription plan has been updated',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Plan Changed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send plan changed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionRenewedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Renewed!</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been renewed successfully. Thank you for your continued support!</p>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Current Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription Renewed',
|
||
preheader: 'Your subscription has been renewed',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Renewed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription renewed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendPaymentDisputeCreatedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Dispute Created</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A payment dispute has been created against your account. We are reviewing the case.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been temporarily suspended.</strong> We'll notify you once the dispute is resolved.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Payment Dispute Created',
|
||
preheader: 'A dispute has been created',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Dispute Created', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send payment dispute created email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendChargeRefundedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const amount = data.amount || 0;
|
||
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: data.currency || 'USD' }).format(amount / 100);
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Refund Processed</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We have processed a refund for your payment.</p>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Refund Amount</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Refunded</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Refund Processed',
|
||
preheader: 'A refund has been processed',
|
||
bodyHtml,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Refund Processed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send refund processed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionCancelledEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Cancelled</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been cancelled and you have been downgraded to Hobby plan.</p>
|
||
|
||
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Current Plan</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Hobby</span>
|
||
</div>
|
||
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
|
||
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
|
||
<span class="detail-value" style="font-weight: 600; color: #D32F2F; font-size: 14px;">Cancelled</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Resubscribe anytime</strong> to regain access to all premium features and benefits.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription Cancelled',
|
||
preheader: 'Your subscription has been cancelled',
|
||
bodyHtml,
|
||
buttonText: 'Resubscribe',
|
||
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription cancelled email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function sendSubscriptionPaymentFailedEmail(user, data) {
|
||
if (!user || !user.email) return;
|
||
|
||
const bodyHtml = `
|
||
<div style="text-align: center; margin-bottom: 24px;">
|
||
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Payment Failed</p>
|
||
</div>
|
||
|
||
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription payment failed. This could be due to insufficient funds, expired card, or other payment issues.</p>
|
||
|
||
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please update your payment method to reactivate.</p>
|
||
</div>
|
||
`;
|
||
|
||
const html = renderBrandedEmail({
|
||
title: 'Subscription Payment Failed',
|
||
preheader: 'Your subscription payment failed',
|
||
bodyHtml,
|
||
buttonText: 'Update Payment',
|
||
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
|
||
});
|
||
|
||
try {
|
||
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Payment Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
|
||
} catch (err) {
|
||
log('failed to send subscription payment failed email', { userId: user.id, email: user.email, error: String(err) });
|
||
}
|
||
}
|
||
|
||
function findUserByEmail(email) {
|
||
const normalized = (email || '').trim().toLowerCase();
|
||
return usersDb.find(u => u.email === normalized);
|
||
}
|
||
|
||
function findUserByProvider(provider, providerId) {
|
||
if (!provider || !providerId) return null;
|
||
const normalizedProvider = provider.toLowerCase();
|
||
return usersDb.find((u) =>
|
||
Array.isArray(u.providers) &&
|
||
u.providers.some((p) => p.provider === normalizedProvider && p.id === providerId)
|
||
);
|
||
}
|
||
|
||
async function upsertOAuthUser(provider, providerId, email, profile = {}) {
|
||
const normalizedProvider = (provider || '').toLowerCase();
|
||
const normalizedEmail = (email || '').trim().toLowerCase();
|
||
if (!normalizedProvider || !providerId) {
|
||
throw new Error('OAuth provider and id are required');
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
let user = findUserByProvider(normalizedProvider, providerId) || (normalizedEmail ? findUserByEmail(normalizedEmail) : null);
|
||
|
||
if (!user) {
|
||
const placeholderPassword = await bcrypt.hash(randomBytes(32).toString('hex'), PASSWORD_SALT_ROUNDS);
|
||
user = {
|
||
id: randomUUID(),
|
||
email: normalizedEmail || `noreply+${providerId}@example.com`,
|
||
password: placeholderPassword,
|
||
createdAt: now,
|
||
lastLoginAt: now,
|
||
providers: [],
|
||
plan: null,
|
||
billingStatus: DEFAULT_BILLING_STATUS,
|
||
billingEmail: normalizedEmail,
|
||
stripeCustomerId: '',
|
||
stripePaymentMethodId: '',
|
||
paymentMethodBrand: '',
|
||
paymentMethodExpMonth: null,
|
||
paymentMethodExpYear: null,
|
||
paymentMethodLast4: '',
|
||
dodoCustomerId: '',
|
||
dodoSubscriptionId: '',
|
||
billingCycle: null,
|
||
subscriptionCurrency: null,
|
||
subscriptionRenewsAt: null,
|
||
};
|
||
usersDb.push(user);
|
||
}
|
||
|
||
user.providers = Array.isArray(user.providers) ? user.providers : [];
|
||
const existingProvider = user.providers.find((p) => p.provider === normalizedProvider && p.id === providerId);
|
||
const providerPayload = {
|
||
provider: normalizedProvider,
|
||
id: providerId,
|
||
email: normalizedEmail || existingProvider?.email || '',
|
||
name: profile.name || profile.login || existingProvider?.name || '',
|
||
avatar: profile.picture || profile.avatar_url || existingProvider?.avatar || '',
|
||
};
|
||
|
||
if (existingProvider) {
|
||
Object.assign(existingProvider, providerPayload);
|
||
} else {
|
||
user.providers.push(providerPayload);
|
||
}
|
||
|
||
if (normalizedEmail && (!user.email || user.email.endsWith('@example.com'))) {
|
||
user.email = normalizedEmail;
|
||
}
|
||
user.lastLoginAt = now;
|
||
await persistUsersDb();
|
||
log('OAuth user upserted', { provider: normalizedProvider, providerId, userId: user.id, email: user.email });
|
||
return user;
|
||
}
|
||
|
||
function findUserById(userId) {
|
||
return usersDb.find(u => u.id === userId);
|
||
}
|
||
|
||
async function createUser(email, password, options = {}) {
|
||
const normalized = (email || '').trim().toLowerCase();
|
||
if (!normalized || !password) {
|
||
throw new Error('Email and password are required');
|
||
}
|
||
|
||
if (findUserByEmail(normalized)) {
|
||
throw new Error('User already exists with this email');
|
||
}
|
||
|
||
const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS);
|
||
const user = {
|
||
id: randomUUID(),
|
||
email: normalized,
|
||
password: hashedPassword,
|
||
createdAt: new Date().toISOString(),
|
||
lastLoginAt: null,
|
||
providers: [],
|
||
emailVerified: false,
|
||
verificationToken: '',
|
||
verificationExpiresAt: null,
|
||
resetToken: '',
|
||
resetExpiresAt: null,
|
||
plan: null,
|
||
billingStatus: DEFAULT_BILLING_STATUS,
|
||
billingEmail: normalized,
|
||
stripeCustomerId: '',
|
||
stripePaymentMethodId: '',
|
||
paymentMethodBrand: '',
|
||
paymentMethodExpMonth: null,
|
||
paymentMethodExpYear: null,
|
||
paymentMethodLast4: '',
|
||
dodoCustomerId: '',
|
||
dodoSubscriptionId: '',
|
||
billingCycle: null,
|
||
subscriptionCurrency: null,
|
||
subscriptionRenewsAt: null,
|
||
referredByAffiliateCode: sanitizeAffiliateCode(options.referredByAffiliateCode),
|
||
affiliateAttributionAt: options.referredByAffiliateCode ? new Date().toISOString() : null,
|
||
affiliatePayouts: [],
|
||
onboardingCompleted: false,
|
||
};
|
||
|
||
assignVerificationToken(user);
|
||
usersDb.push(user);
|
||
await persistUsersDb();
|
||
log('Created new user', { userId: user.id, email: user.email });
|
||
return user;
|
||
}
|
||
|
||
async function verifyUserPassword(email, password) {
|
||
const user = findUserByEmail(email);
|
||
if (!user || typeof user.password !== 'string' || !user.password.length) return null;
|
||
|
||
const isValid = await bcrypt.compare(password, user.password);
|
||
if (!isValid) {
|
||
// Track failed attempts
|
||
user.failedLogins = (user.failedLogins || 0) + 1;
|
||
|
||
// Lock account after 5 failed attempts
|
||
if (user.failedLogins >= 5) {
|
||
user.lockedUntil = Date.now() + LOGIN_LOCKOUT_MS;
|
||
log('account locked due to failed attempts', { email, failedAttempts: user.failedLogins });
|
||
}
|
||
|
||
await persistUsersDb();
|
||
return null;
|
||
}
|
||
|
||
// Update last login
|
||
user.lastLoginAt = new Date().toISOString();
|
||
user.failedLogins = 0;
|
||
user.lockedUntil = null;
|
||
await persistUsersDb();
|
||
return user;
|
||
}
|
||
|
||
function normalizePlanSelection(plan) {
|
||
const candidate = (plan || '').toString().trim().toLowerCase();
|
||
return USER_PLANS.includes(candidate) ? candidate : null;
|
||
}
|
||
|
||
function resolveUserPlan(userId) {
|
||
const user = findUserById(userId);
|
||
return normalizePlanSelection(user?.plan) || DEFAULT_PLAN;
|
||
}
|
||
|
||
function getPlanAppLimit(plan) {
|
||
const key = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||
const limit = PLAN_APP_LIMITS[key];
|
||
return Number.isFinite(limit) ? limit : Infinity;
|
||
}
|
||
|
||
function isPaidPlan(plan) {
|
||
const normalized = (plan || '').toLowerCase();
|
||
return PAID_PLANS.has(normalized);
|
||
}
|
||
|
||
function planPriority(plan) {
|
||
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||
if (normalized === 'enterprise') return 4;
|
||
if (normalized === 'professional') return 3;
|
||
if (normalized === 'starter') return 2;
|
||
return 1; // hobby
|
||
}
|
||
|
||
async function applyPlanPriorityDelay(plan) {
|
||
const priority = planPriority(plan);
|
||
if (priority === 4) return;
|
||
let delayMs = HOBBY_PRIORITY_DELAY_MS;
|
||
if (priority === 3) delayMs = BUSINESS_PRIORITY_DELAY_MS;
|
||
else if (priority === 2) delayMs = STARTER_PRIORITY_DELAY_MS;
|
||
|
||
await delay(delayMs);
|
||
}
|
||
|
||
function resolveFallbackModel(cli = 'opencode') {
|
||
const configured = getConfiguredModels(cli);
|
||
if (Array.isArray(configured) && configured.length) {
|
||
return configured[0].name || configured[0].id || 'default';
|
||
}
|
||
return 'default';
|
||
}
|
||
|
||
function refreshAdminModelIndex() {
|
||
const next = new Map();
|
||
adminModels.forEach((model) => {
|
||
if (!model) return;
|
||
const idKey = String(model.id || '').trim();
|
||
const nameKey = String(model.name || '').trim();
|
||
if (idKey) next.set(`id:${idKey}`, model);
|
||
if (nameKey) next.set(`name:${nameKey}`, model);
|
||
});
|
||
adminModelIndex = next;
|
||
}
|
||
|
||
function getAdminModelByIdOrName(value) {
|
||
const key = String(value || '').trim();
|
||
if (!key) return null;
|
||
return adminModelIndex.get(`id:${key}`) || adminModelIndex.get(`name:${key}`) || null;
|
||
}
|
||
|
||
function resolvePlanModel(plan, requestedModel) {
|
||
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||
const rawRequested = (requestedModel || '').trim();
|
||
const requestedRecord = rawRequested ? getAdminModelByIdOrName(rawRequested) : null;
|
||
const normalizedRequested = requestedRecord ? requestedRecord.name : '';
|
||
const requested = normalizedRequested || rawRequested;
|
||
const requestedTier = getModelTier(requested);
|
||
|
||
// If user explicitly requests a valid model (not 'auto'), use it
|
||
if (requested && requestedTier && requested !== AUTO_MODEL_TOKEN) {
|
||
return requested;
|
||
}
|
||
|
||
const candidateList = getConfiguredModels();
|
||
|
||
// For hobby/free plan users, use the admin-configured auto model
|
||
if (normalized === 'hobby') {
|
||
const adminDefault = (planSettings.freePlanModel || '').trim();
|
||
if (adminDefault) return adminDefault;
|
||
const firstModel = candidateList[0];
|
||
if (firstModel) return firstModel.name;
|
||
return resolveFallbackModel();
|
||
}
|
||
|
||
// For paid plans: respect user's model choice
|
||
// If they requested a specific model (even without a tier), use it
|
||
// Only fall back to first configured if they didn't request anything or requested 'auto'
|
||
if (requested && requested !== AUTO_MODEL_TOKEN) {
|
||
return requested;
|
||
}
|
||
|
||
// No specific request or 'auto' requested - use first configured model
|
||
if (candidateList.length) {
|
||
return candidateList[0].name;
|
||
}
|
||
|
||
return resolveFallbackModel();
|
||
}
|
||
|
||
function addMonthsSafely(date, months) {
|
||
const base = new Date(date);
|
||
const day = base.getDate();
|
||
base.setDate(1);
|
||
base.setMonth(base.getMonth() + months);
|
||
const lastDay = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate();
|
||
base.setDate(Math.min(day, lastDay));
|
||
return base;
|
||
}
|
||
|
||
function computeRenewalDate(cycle = 'monthly') {
|
||
const now = new Date();
|
||
if (cycle === 'yearly') return addMonthsSafely(now, 12).toISOString();
|
||
return addMonthsSafely(now, 1).toISOString();
|
||
}
|
||
|
||
async function serializeAccount(user) {
|
||
if (!user) return null;
|
||
const normalizedPayouts = Array.isArray(user.affiliatePayouts)
|
||
? user.affiliatePayouts.map((p) => normalizePlanSelection(p)).filter(Boolean)
|
||
: [];
|
||
|
||
let paymentMethod = null;
|
||
if (user.dodoSubscriptionId && DODO_ENABLED) {
|
||
try {
|
||
const subscription = await dodoRequest(`/subscriptions/${user.dodoSubscriptionId}`, {
|
||
method: 'GET',
|
||
});
|
||
|
||
if (subscription && subscription.payment_method) {
|
||
const pm = subscription.payment_method;
|
||
paymentMethod = {
|
||
brand: pm.card?.brand || pm.payment_method || 'Card',
|
||
last4: pm.card?.last4 || pm.card?.last_digits || '',
|
||
expiresAt: pm.card?.expiry || pm.card?.expires_at || '',
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.error('[SerializeAccount] Failed to fetch payment method:', error.message);
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
plan: user.plan || DEFAULT_PLAN,
|
||
billingStatus: user.billingStatus || DEFAULT_BILLING_STATUS,
|
||
billingEmail: user.billingEmail || user.email || '',
|
||
subscriptionRenewsAt: user.subscriptionRenewsAt || null,
|
||
referredByAffiliateCode: sanitizeAffiliateCode(user.referredByAffiliateCode),
|
||
affiliateAttributionAt: user.affiliateAttributionAt || null,
|
||
affiliatePayouts: normalizedPayouts,
|
||
createdAt: user.createdAt || null,
|
||
lastLoginAt: user.lastLoginAt || null,
|
||
unlimitedUsage: Boolean(user.unlimitedUsage),
|
||
balance: Number(user.balance || 0),
|
||
currency: String(user.currency || 'usd').toLowerCase(),
|
||
limits: {
|
||
uploadZipBytes: MAX_UPLOAD_ZIP_SIZE,
|
||
},
|
||
tokenUsage: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN),
|
||
externalTestingUsage: getExternalTestUsageSummary(user.id, user.plan || DEFAULT_PLAN),
|
||
paymentMethod,
|
||
};
|
||
}
|
||
|
||
function readUserSessionToken(req) {
|
||
try {
|
||
const cookieHeader = req?.headers?.cookie || '';
|
||
if (!cookieHeader) return '';
|
||
const parts = cookieHeader.split(';').map((p) => p.trim());
|
||
const match = parts.find((p) => p.startsWith(`${USER_COOKIE_NAME}=`));
|
||
if (!match) return '';
|
||
return decodeURIComponent(match.split('=').slice(1).join('=') || '');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getUserSession(req) {
|
||
const token = readUserSessionToken(req);
|
||
if (!token) return null;
|
||
|
||
const session = userSessions.get(token);
|
||
if (!session) return null;
|
||
|
||
if (session.expiresAt && session.expiresAt < Date.now()) {
|
||
userSessions.delete(token);
|
||
persistUserSessions().catch(() => {});
|
||
return null;
|
||
}
|
||
|
||
// Update last active for the user (throttled to once every 5 mins)
|
||
try {
|
||
const user = findUserById(session.userId);
|
||
if (user) {
|
||
const now = new Date();
|
||
const lastActive = user.lastActiveAt ? new Date(user.lastActiveAt) : new Date(0);
|
||
if (now.getTime() - lastActive.getTime() > 300000) {
|
||
user.lastActiveAt = now.toISOString();
|
||
persistUsersDb().catch(() => {});
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
return { token, userId: session.userId, expiresAt: session.expiresAt };
|
||
}
|
||
|
||
function startUserSession(res, userId, remember = false) {
|
||
const token = randomUUID();
|
||
const ttl = remember ? USER_SESSION_TTL_MS : USER_SESSION_SHORT_TTL_MS;
|
||
const expiresAt = Date.now() + ttl;
|
||
userSessions.set(token, { userId, expiresAt });
|
||
persistUserSessions().catch(() => {});
|
||
|
||
const parts = [
|
||
`${USER_COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||
'Path=/',
|
||
'HttpOnly',
|
||
'SameSite=Lax',
|
||
`Max-Age=${Math.floor(ttl / 1000)}`,
|
||
];
|
||
if (process.env.COOKIE_SECURE === '0') parts.push('Secure');
|
||
res.setHeader('Set-Cookie', parts.join('; '));
|
||
|
||
return token;
|
||
}
|
||
|
||
function clearUserSession(res) {
|
||
res.setHeader('Set-Cookie', `${USER_COOKIE_NAME}=deleted; Path=/; Max-Age=0; SameSite=Lax`);
|
||
}
|
||
|
||
function requireUserAuth(req, res) {
|
||
const session = getUserSession(req);
|
||
if (!session) {
|
||
sendJson(res, 401, { error: 'User authentication required' });
|
||
return null;
|
||
}
|
||
return session;
|
||
}
|
||
|
||
function buildWorkspacePaths(session) {
|
||
const userSegment = sanitizeSegment(session.userId || 'anonymous', 'anonymous');
|
||
const appSegment = sanitizeSegment(session.appId || session.id || 'app', 'app');
|
||
const workspaceDir = path.join(WORKSPACES_ROOT, userSegment, appSegment);
|
||
const uploadsDir = path.join(workspaceDir, 'uploads');
|
||
return { workspaceDir, uploadsDir };
|
||
}
|
||
|
||
async function ensureSessionPaths(session) {
|
||
const paths = buildWorkspacePaths(session);
|
||
session.workspaceDir = paths.workspaceDir;
|
||
session.uploadsDir = paths.uploadsDir;
|
||
session.attachmentKey = session.attachmentKey || randomUUID();
|
||
await fs.mkdir(session.workspaceDir, { recursive: true });
|
||
await fs.mkdir(session.uploadsDir, { recursive: true });
|
||
await ensureOpencodeConfig(session);
|
||
return session;
|
||
}
|
||
|
||
async function ensureOpencodeConfig(session) {
|
||
if (!ENABLE_EXTERNAL_DIR_RESTRICTION) {
|
||
return;
|
||
}
|
||
if (!session?.workspaceDir || !session?.appId) {
|
||
return;
|
||
}
|
||
|
||
const userSegment = sanitizeSegment(session.userId || 'anonymous', 'anonymous');
|
||
const appSegment = sanitizeSegment(session.appId || session.id || 'app', 'app');
|
||
|
||
const providerName = OPENCODE_OLLAMA_PROVIDER || 'openai';
|
||
const modelId = OPENCODE_OLLAMA_MODEL || 'qwen3:0.6b';
|
||
// Respect the base URL exactly as provided by OPENCODE_OLLAMA_BASE_URL (trim trailing slashes)
|
||
const baseUrl = (OPENCODE_OLLAMA_BASE_URL || 'https://ollama.plugincompass.com').replace(/\/+$/, '');
|
||
|
||
const providerCfg = {
|
||
options: {
|
||
baseURL: baseUrl
|
||
},
|
||
models: {
|
||
[modelId]: {
|
||
id: modelId,
|
||
name: modelId,
|
||
tool_call: true,
|
||
temperature: true
|
||
}
|
||
}
|
||
};
|
||
if (OPENCODE_OLLAMA_API_KEY) {
|
||
providerCfg.options.apiKey = OPENCODE_OLLAMA_API_KEY;
|
||
}
|
||
|
||
const providers = {
|
||
[providerName]: providerCfg
|
||
};
|
||
|
||
// Ensure adminModels is loaded
|
||
if (!adminModels || adminModels.length === 0) {
|
||
log('adminModels empty, loading from store', { sessionId: session.id });
|
||
await loadAdminModelStore();
|
||
}
|
||
|
||
// Find which providers are used in adminModels
|
||
const usedProviders = new Set();
|
||
for (const model of adminModels) {
|
||
// First, try to extract provider from model name (e.g., "chutes/model-name" -> "chutes")
|
||
if (model.name && model.name.includes('/')) {
|
||
const providerFromName = model.name.split('/')[0].toLowerCase();
|
||
if (providerFromName && providerFromName !== 'opencode') {
|
||
usedProviders.add(providerFromName);
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(model.providers)) {
|
||
for (const p of model.providers) {
|
||
// Handle both string format ["opencode", "chutes"] and object format [{provider: "opencode"}]
|
||
if (typeof p === 'string') {
|
||
usedProviders.add(p.toLowerCase());
|
||
} else if (p && typeof p === 'object' && p.provider) {
|
||
usedProviders.add(p.provider.toLowerCase());
|
||
}
|
||
}
|
||
}
|
||
if (model.primaryProvider) {
|
||
usedProviders.add(model.primaryProvider.toLowerCase());
|
||
}
|
||
}
|
||
|
||
log('Detected providers from adminModels', {
|
||
usedProviders: Array.from(usedProviders),
|
||
count: usedProviders.size
|
||
});
|
||
|
||
// Provider configurations with their base URLs
|
||
const providerConfigs = {
|
||
chutes: {
|
||
apiKey: CHUTES_API_KEY,
|
||
baseURL: 'https://llm.chutes.ai/v1'
|
||
},
|
||
cerebras: {
|
||
apiKey: process.env.CEREBRAS_API_KEY,
|
||
baseURL: 'https://api.cerebras.ai/v1'
|
||
},
|
||
groq: {
|
||
apiKey: GROQ_API_KEY,
|
||
baseURL: 'https://api.groq.com/openai/v1'
|
||
},
|
||
google: {
|
||
apiKey: GOOGLE_API_KEY,
|
||
baseURL: 'https://generativelanguage.googleapis.com/v1beta'
|
||
},
|
||
nvidia: {
|
||
apiKey: NVIDIA_API_KEY,
|
||
baseURL: 'https://integrate.api.nvidia.com/v1'
|
||
}
|
||
};
|
||
|
||
// Add providers that are both configured and used
|
||
for (const [providerId, config] of Object.entries(providerConfigs)) {
|
||
if (config.apiKey && usedProviders.has(providerId)) {
|
||
// Fetch all models from models.dev for this provider
|
||
let models = {};
|
||
try {
|
||
models = await fetchModelsDevProviderModels(providerId);
|
||
} catch (err) {
|
||
log('Failed to fetch models from models.dev, using empty models list', {
|
||
provider: providerId,
|
||
error: String(err)
|
||
});
|
||
}
|
||
|
||
providers[providerId] = {
|
||
options: {
|
||
apiKey: config.apiKey,
|
||
baseURL: config.baseURL
|
||
},
|
||
models: models
|
||
};
|
||
|
||
log('Configured provider', {
|
||
provider: providerId,
|
||
modelCount: Object.keys(models).length,
|
||
source: Object.keys(models).length > 0 ? 'models.dev' : 'built-in'
|
||
});
|
||
}
|
||
}
|
||
|
||
const config = {
|
||
$schema: 'https://opencode.ai/config.json',
|
||
model: `${providerName}/${modelId}`,
|
||
small_model: `${providerName}/${modelId}`,
|
||
permission: {
|
||
external_directory: {
|
||
'*': 'deny',
|
||
[`*/${appSegment}/*`]: 'allow',
|
||
[`apps/${userSegment}/${appSegment}/*`]: 'allow'
|
||
}
|
||
},
|
||
provider: providers
|
||
};
|
||
|
||
const configPath = path.join(session.workspaceDir, 'opencode.json');
|
||
|
||
try {
|
||
await fs.writeFile(
|
||
configPath,
|
||
JSON.stringify(config, null, 2),
|
||
'utf8'
|
||
);
|
||
log('Created opencode config for session', {
|
||
sessionId: session.id,
|
||
appId: appSegment,
|
||
userId: userSegment,
|
||
providers: Object.keys(providers)
|
||
});
|
||
} catch (err) {
|
||
log('Failed to create opencode config', {
|
||
sessionId: session.id,
|
||
error: String(err)
|
||
});
|
||
}
|
||
}
|
||
|
||
function newOpencodeSessionId() {
|
||
return `ses-${randomUUID()}`;
|
||
}
|
||
|
||
async function ensureOpencodeSession(session, model) {
|
||
if (!session) return null;
|
||
await ensureSessionPaths(session);
|
||
|
||
const targetModel = model || session.model;
|
||
const workspaceDir = session.workspaceDir;
|
||
if (!workspaceDir) throw new Error('Session workspace directory not initialized');
|
||
|
||
// If we have an initial session ID locked, always use it without validation
|
||
// This ensures session continuity across all messages in the builder
|
||
if (session.initialOpencodeSessionId) {
|
||
log('Using locked initial opencode session', {
|
||
sessionId: session.id,
|
||
lockedSessionId: session.initialOpencodeSessionId,
|
||
currentSessionId: session.opencodeSessionId
|
||
});
|
||
|
||
// Ensure session.opencodeSessionId is synced with initial
|
||
if (session.opencodeSessionId !== session.initialOpencodeSessionId) {
|
||
session.opencodeSessionId = session.initialOpencodeSessionId;
|
||
await persistState();
|
||
}
|
||
return session.initialOpencodeSessionId;
|
||
}
|
||
|
||
// If session already has an opencode session ID (but no locked initial), verify it exists in CLI listing.
|
||
// (Previously we only checked that "list" ran, which doesn't validate the id.)
|
||
if (session.opencodeSessionId) {
|
||
try {
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
const listCandidates = [
|
||
['session', 'list', '--format', 'json'],
|
||
['sessions', 'list', '--format', 'json'],
|
||
];
|
||
|
||
let stdout = '';
|
||
for (const args of listCandidates) {
|
||
try {
|
||
const result = await runCommand(cliCommand, args, { timeout: 7000, cwd: workspaceDir });
|
||
stdout = result.stdout || '';
|
||
if (stdout) break;
|
||
} catch (_) {
|
||
// try next candidate
|
||
}
|
||
}
|
||
|
||
if (stdout) {
|
||
try {
|
||
const parsed = JSON.parse(stdout);
|
||
const items = Array.isArray(parsed)
|
||
? parsed
|
||
: (Array.isArray(parsed.sessions) ? parsed.sessions : (Array.isArray(parsed.data) ? parsed.data : []));
|
||
const ids = items.map((it) => it?.id || it?.sessionId || it?.session_id).filter(Boolean);
|
||
if (ids.includes(session.opencodeSessionId)) {
|
||
return session.opencodeSessionId;
|
||
}
|
||
log('stored opencode session id not found in list; using stored id for continuity', { stored: session.opencodeSessionId });
|
||
return session.opencodeSessionId;
|
||
} catch (err) {
|
||
// If JSON parse fails, try to continue with existing id
|
||
log('opencode session list unparseable; attempting to use stored id', { stored: session.opencodeSessionId, err: String(err) });
|
||
return session.opencodeSessionId;
|
||
}
|
||
} else {
|
||
// Cannot list sessions; try to use stored id and let CLI handle it
|
||
log('cannot list sessions; falling back to stored id', { stored: session.opencodeSessionId });
|
||
return session.opencodeSessionId;
|
||
}
|
||
} catch (err) {
|
||
log('existing opencode session validation failed', { sessionId: session.opencodeSessionId, err: String(err) });
|
||
// CRITICAL FIX: If validation fails, don't create a new session - return existing one
|
||
// This prevents creating a new OpenCode session on every message when CLI operations fail
|
||
log('Using existing opencode session ID despite validation failure for continuity', { stored: session.opencodeSessionId });
|
||
return session.opencodeSessionId;
|
||
}
|
||
// If we reach here, the existing session ID may be invalid - continue to create new below
|
||
}
|
||
|
||
// Create a new opencode session using the createOpencodeSession helper
|
||
const freshId = newOpencodeSessionId();
|
||
try {
|
||
const sessionId = await createOpencodeSession(freshId, targetModel, workspaceDir);
|
||
if (sessionId) {
|
||
session.opencodeSessionId = sessionId;
|
||
// CRITICAL FIX: Only set initialOpencodeSessionId if not already set
|
||
// This prevents overwriting the initial session ID when creating subsequent sessions
|
||
if (!session.initialOpencodeSessionId) {
|
||
session.initialOpencodeSessionId = sessionId; // Lock this as the initial session
|
||
log('Created and locked new opencode session', { sessionId, model: targetModel });
|
||
} else {
|
||
log('Updated opencode session ID (preserving initial)', {
|
||
sessionId,
|
||
initialSessionId: session.initialOpencodeSessionId,
|
||
model: targetModel
|
||
});
|
||
}
|
||
await persistState();
|
||
return sessionId;
|
||
}
|
||
} catch (err) {
|
||
log('Failed to create opencode session', { err: String(err), desiredId: freshId, model: targetModel });
|
||
}
|
||
|
||
// If createOpencodeSession failed, try a simple direct approach
|
||
// This handles cases where CLI doesn't support expected session commands
|
||
try {
|
||
// We cannot just generate a random ID because CLI will crash if session file doesn't exist.
|
||
// Instead, we return null to let the run command create a new session implicitly.
|
||
log('Cannot create explicit session, will rely on auto-creation during run', { model: targetModel });
|
||
return null;
|
||
} catch (error) {
|
||
log('Failed to setup opencode session', { error: String(error), model: targetModel });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeCli(cli) {
|
||
const name = (cli || '').toLowerCase();
|
||
return SUPPORTED_CLIS.includes(name) ? name : 'opencode';
|
||
}
|
||
|
||
function resolveCliCommand(cli) {
|
||
const normalized = normalizeCli(cli);
|
||
const binDir = process.env.OPENCODE_BIN_DIR || '/root/.opencode/bin';
|
||
const candidates = [];
|
||
if (OPENCODE_REPO_CLI) candidates.push(OPENCODE_REPO_CLI);
|
||
if (binDir) candidates.push(path.join(binDir, normalized));
|
||
candidates.push(`/usr/local/bin/${normalized}`);
|
||
candidates.push(normalized);
|
||
for (const candidate of candidates) {
|
||
try {
|
||
fsSync.accessSync(candidate, fsSync.constants.X_OK);
|
||
return candidate;
|
||
} catch (_) { }
|
||
}
|
||
log('CLI command not found on PATH; falling back to raw name', { cli: normalized, candidates });
|
||
return normalized;
|
||
}
|
||
|
||
const opencodeVerificationCache = {
|
||
checked: false,
|
||
ok: false,
|
||
error: null,
|
||
cliPath: null,
|
||
};
|
||
|
||
function verifyOpencodePrompts() {
|
||
const promptFiles = [
|
||
'codex_header.txt',
|
||
'beast.txt',
|
||
'gemini.txt',
|
||
'anthropic.txt',
|
||
'qwen.txt',
|
||
'trinity.txt',
|
||
'wordpress-plugin.txt',
|
||
'wordpress-plugin-subsequent.txt'
|
||
];
|
||
const missing = [];
|
||
const nonWordPress = [];
|
||
|
||
for (const file of promptFiles) {
|
||
const fullPath = path.join(OPENCODE_PROMPT_DIR, file);
|
||
if (!fsSync.existsSync(fullPath)) {
|
||
missing.push(fullPath);
|
||
continue;
|
||
}
|
||
const content = fsSync.readFileSync(fullPath, 'utf8');
|
||
if (!/wordpress/i.test(content)) {
|
||
nonWordPress.push(fullPath);
|
||
}
|
||
}
|
||
|
||
if (missing.length) {
|
||
throw new Error(`OpenCode prompt verification failed: missing prompt files: ${missing.join(', ')}`);
|
||
}
|
||
if (nonWordPress.length) {
|
||
throw new Error(`OpenCode prompt verification failed: prompts are not WordPress-specific: ${nonWordPress.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
function verifyOpencodeCli(cliCommand) {
|
||
if (!OPENCODE_REQUIRE_REPO) return;
|
||
const repoRootResolved = fsSync.existsSync(OPENCODE_REPO_ROOT)
|
||
? fsSync.realpathSync(OPENCODE_REPO_ROOT)
|
||
: null;
|
||
if (!repoRootResolved) {
|
||
throw new Error('OpenCode repo root not found; cannot verify CLI build source.');
|
||
}
|
||
let cliResolved = null;
|
||
try {
|
||
cliResolved = fsSync.realpathSync(cliCommand);
|
||
} catch (_) {
|
||
cliResolved = null;
|
||
}
|
||
if (!cliResolved || !cliResolved.startsWith(repoRootResolved)) {
|
||
throw new Error(`OpenCode CLI is not using the repo build. Expected CLI under ${repoRootResolved} but got ${cliCommand}.`);
|
||
}
|
||
}
|
||
|
||
function verifyOpencodeSetup(cliCommand) {
|
||
if (!OPENCODE_REQUIRE_REPO) {
|
||
log('OpenCode verification skipped (OPENCODE_REQUIRE_REPO=false)');
|
||
return;
|
||
}
|
||
if (opencodeVerificationCache.checked) {
|
||
if (opencodeVerificationCache.error) throw opencodeVerificationCache.error;
|
||
return;
|
||
}
|
||
try {
|
||
verifyOpencodeCli(cliCommand);
|
||
verifyOpencodePrompts();
|
||
opencodeVerificationCache.checked = true;
|
||
opencodeVerificationCache.ok = true;
|
||
opencodeVerificationCache.cliPath = cliCommand;
|
||
} catch (err) {
|
||
opencodeVerificationCache.checked = true;
|
||
opencodeVerificationCache.ok = false;
|
||
opencodeVerificationCache.error = err;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function ensureStateFile() {
|
||
try {
|
||
await fs.mkdir(STATE_DIR, { recursive: true });
|
||
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||
await fs.mkdir(WORKSPACES_ROOT, { recursive: true });
|
||
} catch (error) {
|
||
log('Error creating state directories', { error: String(error), STATE_DIR, UPLOADS_DIR, WORKSPACES_ROOT });
|
||
}
|
||
try { await fs.access(STATE_FILE); } catch (_) {
|
||
try {
|
||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
||
} catch (writeError) {
|
||
log('Error initializing state file', { error: String(writeError), STATE_FILE });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadState() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(STATE_FILE, 'utf8');
|
||
const parsed = JSON.parse(raw || '{}');
|
||
const savedSessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
|
||
state.sessions = [];
|
||
for (const saved of savedSessions) {
|
||
const session = {
|
||
...saved,
|
||
cli: normalizeCli(saved.cli),
|
||
userId: sanitizeSegment(saved.userId || 'anonymous', 'anonymous'),
|
||
appId: sanitizeSegment(saved.appId || saved.id || 'app', 'app'),
|
||
entryMode: saved.entryMode === 'opencode' ? 'opencode' : 'plan',
|
||
source: saved.source || 'builder',
|
||
planApproved: !!saved.planApproved,
|
||
opencodeSessionId: saved.opencodeSessionId || saved.opencodeSession,
|
||
initialOpencodeSessionId: saved.initialOpencodeSessionId || saved.opencodeSessionId,
|
||
};
|
||
if (session.opencodeSession) delete session.opencodeSession;
|
||
try {
|
||
await ensureSessionPaths(session);
|
||
} catch (err) {
|
||
log('Failed to ensure session paths while loading state', { err: String(err), sessionId: session.id });
|
||
}
|
||
|
||
// Restore running/queued messages after server restart to allow continuation
|
||
if (Array.isArray(session.messages)) {
|
||
let sessionModified = false;
|
||
session.messages.forEach(msg => {
|
||
if (msg.status === 'running' || msg.status === 'queued') {
|
||
log('Restoring interrupted message after restart', { sessionId: session.id, messageId: msg.id, prevStatus: msg.status });
|
||
// Reset to queued state so it can be retried automatically
|
||
msg.status = 'queued';
|
||
msg.retryAfterRestart = true;
|
||
msg.restartedAt = new Date().toISOString();
|
||
sessionModified = true;
|
||
}
|
||
});
|
||
if (sessionModified) {
|
||
// Keep pending count to trigger automatic retry
|
||
session.updatedAt = new Date().toISOString();
|
||
session.restoredAfterRestart = true;
|
||
}
|
||
}
|
||
|
||
state.sessions.push(session);
|
||
}
|
||
log(`Loaded ${state.sessions.length} sessions from ${STATE_FILE}`);
|
||
// If any sessions were modified during cleanup, persist the changes
|
||
await persistState();
|
||
} catch (error) {
|
||
log('Failed to load state, starting fresh', { error: String(error) });
|
||
state.sessions = [];
|
||
}
|
||
}
|
||
|
||
async function persistState() {
|
||
try {
|
||
// Prepare state with additional runtime information for graceful restart
|
||
const stateToSave = {
|
||
sessions: state.sessions.map(session => ({
|
||
...session,
|
||
lastSavedAt: new Date().toISOString(),
|
||
activeQueues: sessionQueues.has(session.id),
|
||
hasActiveStreams: activeStreams.has(session.id)
|
||
})),
|
||
serverVersion: '2.0',
|
||
savedAt: new Date().toISOString()
|
||
};
|
||
const safe = JSON.stringify(stateToSave, null, 2);
|
||
await safeWriteFile(STATE_FILE, safe);
|
||
} catch (error) {
|
||
log('persistState failed', { error: String(error), STATE_FILE });
|
||
}
|
||
}
|
||
|
||
async function restoreInterruptedSessions() {
|
||
try {
|
||
let restoredCount = 0;
|
||
let messageCount = 0;
|
||
|
||
for (const session of state.sessions) {
|
||
if (!session.restoredAfterRestart) continue;
|
||
|
||
let hasQueuedMessages = false;
|
||
const messagesToProcess = [];
|
||
if (Array.isArray(session.messages)) {
|
||
for (const msg of session.messages) {
|
||
if (msg.status === 'queued' && msg.retryAfterRestart) {
|
||
hasQueuedMessages = true;
|
||
messageCount++;
|
||
messagesToProcess.push(msg);
|
||
log('Restoring message after restart', {
|
||
sessionId: session.id,
|
||
messageId: msg.id,
|
||
role: msg.role
|
||
});
|
||
}
|
||
}
|
||
|
||
if (messagesToProcess.length > 0) {
|
||
let prev = Promise.resolve();
|
||
for (const msg of messagesToProcess) {
|
||
prev = prev.then(async () => {
|
||
delete msg.retryAfterRestart;
|
||
await processMessage(session.id, msg);
|
||
});
|
||
}
|
||
await prev;
|
||
}
|
||
}
|
||
|
||
if (hasQueuedMessages) {
|
||
restoredCount++;
|
||
// Clear the restart flag
|
||
delete session.restoredAfterRestart;
|
||
}
|
||
}
|
||
|
||
if (restoredCount > 0) {
|
||
log(`Restored ${restoredCount} sessions with ${messageCount} queued messages after restart`);
|
||
await persistState();
|
||
} else {
|
||
log('No interrupted sessions to restore');
|
||
}
|
||
} catch (error) {
|
||
log('Failed to restore interrupted sessions', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function cleanupOrphanedWorkspaces() {
|
||
try {
|
||
const workspaceRoot = path.resolve(WORKSPACES_ROOT);
|
||
|
||
// Check if workspaces root exists
|
||
try {
|
||
await fs.access(workspaceRoot);
|
||
} catch {
|
||
log('Workspaces root does not exist, skipping orphaned workspace cleanup');
|
||
return;
|
||
}
|
||
|
||
// Create a set of all active workspace directories from current sessions
|
||
const activeWorkspaceDirs = new Set();
|
||
for (const session of state.sessions) {
|
||
if (session.workspaceDir) {
|
||
const normalizedPath = path.resolve(session.workspaceDir);
|
||
if (normalizedPath.startsWith(workspaceRoot)) {
|
||
activeWorkspaceDirs.add(normalizedPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
let cleanedCount = 0;
|
||
let errorCount = 0;
|
||
|
||
// Recursively walk through workspaces directory
|
||
async function scanAndClean(dirPath) {
|
||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dirPath, entry.name);
|
||
const normalizedPath = path.resolve(fullPath);
|
||
|
||
if (!normalizedPath.startsWith(workspaceRoot)) {
|
||
continue; // Skip paths outside workspace root
|
||
}
|
||
|
||
if (entry.isDirectory()) {
|
||
// Check if this directory is an active workspace
|
||
if (activeWorkspaceDirs.has(normalizedPath)) {
|
||
// This is an active workspace, skip it but don't recurse into it
|
||
continue;
|
||
}
|
||
|
||
// Recursively scan subdirectories
|
||
await scanAndClean(fullPath);
|
||
|
||
// After scanning children, if this directory is empty (we cleaned all children) and not active, delete it
|
||
try {
|
||
const remainingEntries = await fs.readdir(fullPath);
|
||
if (remainingEntries.length === 0) {
|
||
await fs.rm(fullPath, { recursive: true, force: true });
|
||
cleanedCount++;
|
||
log('Cleaned orphaned workspace directory', { path: fullPath });
|
||
}
|
||
} catch (err) {
|
||
errorCount++;
|
||
log('Failed to check/clean orphaned workspace', { path: fullPath, error: String(err) });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await scanAndClean(workspaceRoot);
|
||
|
||
if (cleanedCount > 0) {
|
||
log(`Orphaned workspace cleanup completed: ${cleanedCount} directories cleaned${errorCount > 0 ? `, ${errorCount} errors` : ''}`);
|
||
} else {
|
||
log('Orphaned workspace cleanup: no orphaned directories found');
|
||
}
|
||
} catch (error) {
|
||
log('Failed to clean orphaned workspaces', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
function sanitizeMessage(message) { if (!message) return ''; return message.toString().trim(); }
|
||
|
||
async function ensureAssetsDir() { await fs.mkdir(ASSETS_DIR, { recursive: true }); }
|
||
|
||
async function loadAdminModelStore() {
|
||
try {
|
||
await ensureStateFile();
|
||
await ensureAssetsDir();
|
||
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]');
|
||
const parsed = JSON.parse(raw || '[]');
|
||
if (Array.isArray(parsed)) adminModels = parsed;
|
||
else if (Array.isArray(parsed.models)) adminModels = parsed.models;
|
||
else adminModels = [];
|
||
adminModels = adminModels.map((m) => {
|
||
const providersRaw = Array.isArray(m.providers) && m.providers.length
|
||
? m.providers
|
||
: [{ provider: 'opencode', model: m.name, primary: true }];
|
||
const providers = providersRaw.map((p, idx) => ({
|
||
provider: normalizeProviderName(p.provider || p.name || 'opencode'),
|
||
model: (p.model || p.name || m.name || '').trim() || m.name,
|
||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
||
})).filter((p) => !!p.model);
|
||
const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0]?.provider || 'opencode';
|
||
return {
|
||
id: m.id || randomUUID(),
|
||
name: m.name,
|
||
label: m.label || m.name,
|
||
icon: m.icon || '',
|
||
cli: normalizeCli(m.cli || 'opencode'),
|
||
providers,
|
||
primaryProvider,
|
||
tier: normalizeTier(m.tier),
|
||
supportsMedia: m.supportsMedia ?? false,
|
||
};
|
||
}).filter((m) => !!m.name);
|
||
refreshAdminModelIndex();
|
||
} catch (error) {
|
||
log('Failed to load admin models, starting empty', { error: String(error) });
|
||
adminModels = [];
|
||
refreshAdminModelIndex();
|
||
}
|
||
}
|
||
|
||
async function persistAdminModels() {
|
||
await ensureStateFile();
|
||
await ensureAssetsDir();
|
||
const payload = JSON.stringify(adminModels, null, 2);
|
||
await safeWriteFile(ADMIN_MODELS_FILE, payload);
|
||
refreshAdminModelIndex();
|
||
}
|
||
|
||
async function loadOpenRouterSettings() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(OPENROUTER_SETTINGS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (typeof parsed.primaryModel === 'string') openrouterSettings.primaryModel = parsed.primaryModel;
|
||
if (typeof parsed.backupModel1 === 'string') openrouterSettings.backupModel1 = parsed.backupModel1;
|
||
if (typeof parsed.backupModel2 === 'string') openrouterSettings.backupModel2 = parsed.backupModel2;
|
||
if (typeof parsed.backupModel3 === 'string') openrouterSettings.backupModel3 = parsed.backupModel3;
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load OpenRouter settings, using defaults', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistOpenRouterSettings() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(openrouterSettings, null, 2);
|
||
await safeWriteFile(OPENROUTER_SETTINGS_FILE, payload);
|
||
}
|
||
|
||
async function loadMistralSettings() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(MISTRAL_SETTINGS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (typeof parsed.primaryModel === 'string') mistralSettings.primaryModel = parsed.primaryModel;
|
||
if (typeof parsed.backupModel1 === 'string') mistralSettings.backupModel1 = parsed.backupModel1;
|
||
if (typeof parsed.backupModel2 === 'string') mistralSettings.backupModel2 = parsed.backupModel2;
|
||
if (typeof parsed.backupModel3 === 'string') mistralSettings.backupModel3 = parsed.backupModel3;
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load Mistral settings, using defaults', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistMistralSettings() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(mistralSettings, null, 2);
|
||
await safeWriteFile(MISTRAL_SETTINGS_FILE, payload);
|
||
}
|
||
|
||
async function loadPlanSettings() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(path.join(STATE_DIR, 'plan-settings.json'), 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (typeof parsed.provider === 'string' && PLANNING_PROVIDERS.includes(normalizeProviderName(parsed.provider))) {
|
||
planSettings.provider = normalizeProviderName(parsed.provider);
|
||
}
|
||
if (typeof parsed.freePlanModel === 'string') {
|
||
planSettings.freePlanModel = parsed.freePlanModel.trim();
|
||
}
|
||
if (Array.isArray(parsed.planningChain)) {
|
||
planSettings.planningChain = normalizePlanningChain(parsed.planningChain);
|
||
}
|
||
}
|
||
if (!planSettings.planningChain.length) {
|
||
planSettings.planningChain = defaultPlanningChainFromSettings(planSettings.provider);
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load plan settings, using defaults', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPlanSettings() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(planSettings, null, 2);
|
||
await safeWriteFile(path.join(STATE_DIR, 'plan-settings.json'), payload);
|
||
}
|
||
|
||
function normalizePlanningChain(chain) {
|
||
if (!Array.isArray(chain)) return [];
|
||
const seen = new Set();
|
||
const normalized = [];
|
||
for (const entry of chain) {
|
||
// Start with provider from the entry or fallback to planSettings
|
||
let provider = normalizeProviderName(entry?.provider || planSettings.provider || 'openrouter');
|
||
if (!PLANNING_PROVIDERS.includes(provider)) continue;
|
||
|
||
// Preserve the original user input (raw) while still normalizing a model
|
||
// for runtime usage. `raw` lets the admin UI show exactly what was entered
|
||
// (e.g., "groq/compound-mini") even if the normalized model value omits
|
||
// the provider prefix for API calls.
|
||
const rawModel = (typeof entry?.raw === 'string' && entry.raw.trim())
|
||
? entry.raw.trim()
|
||
: (typeof entry?.model === 'string' ? entry.model.trim() : '');
|
||
|
||
const parsed = parseModelString(rawModel);
|
||
if (parsed.provider) {
|
||
provider = parsed.provider;
|
||
}
|
||
const model = parsed.model || rawModel;
|
||
|
||
const key = `${provider}::${model || '__any__'}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
// Store both a normalized `model` (used at runtime) and the original `raw`
|
||
// input so the admin UI can display what the user actually typed.
|
||
normalized.push({ provider, model, raw: rawModel || undefined });
|
||
if (normalized.length >= 25) break;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function defaultPlanningChainFromSettings(preferredProvider) {
|
||
const provider = PLANNING_PROVIDERS.includes(normalizeProviderName(preferredProvider))
|
||
? normalizeProviderName(preferredProvider)
|
||
: 'openrouter';
|
||
|
||
let primaryChain = [];
|
||
if (provider === 'mistral') {
|
||
primaryChain = buildMistralPlanChain();
|
||
} else if (provider === 'groq') {
|
||
primaryChain = buildGroqPlanChain();
|
||
} else if (provider === 'google') {
|
||
primaryChain = buildGooglePlanChain();
|
||
} else if (provider === 'nvidia') {
|
||
primaryChain = buildNvidiaPlanChain();
|
||
} else {
|
||
primaryChain = buildOpenRouterPlanChain();
|
||
}
|
||
|
||
const base = primaryChain.map((model) => ({ provider, model }));
|
||
|
||
// Add OpenRouter as fallback for non-OpenRouter providers
|
||
if (provider !== 'openrouter') {
|
||
buildOpenRouterPlanChain().forEach((model) => base.push({ provider: 'openrouter', model }));
|
||
}
|
||
|
||
return normalizePlanningChain(base);
|
||
}
|
||
|
||
function normalizeProviderName(name) {
|
||
return (name || '').toString().trim().toLowerCase() || 'opencode';
|
||
}
|
||
|
||
const KNOWN_USAGE_PROVIDERS = new Set(['openrouter', 'mistral', 'opencode', 'google', 'groq', 'nvidia', 'cohere']);
|
||
|
||
// Treat unknown "provider" labels that are really OpenRouter model families (e.g. openai/anthropic)
|
||
// as OpenRouter for usage + rate-limits, so admin charts roll up correctly.
|
||
function normalizeUsageProvider(provider, model = '') {
|
||
const key = normalizeProviderName(provider);
|
||
if (KNOWN_USAGE_PROVIDERS.has(key)) return key;
|
||
const modelStr = (model || '').toString();
|
||
if (modelStr.includes('/')) return 'openrouter';
|
||
return key;
|
||
}
|
||
|
||
function sanitizeLimitNumber(value) {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) && num > 0 ? num : 0;
|
||
}
|
||
|
||
function defaultProviderLimit(provider) {
|
||
return {
|
||
provider: normalizeProviderName(provider),
|
||
scope: 'provider',
|
||
tokensPerMinute: 0,
|
||
tokensPerHour: 0,
|
||
tokensPerDay: 0,
|
||
requestsPerMinute: 0,
|
||
requestsPerHour: 0,
|
||
requestsPerDay: 0,
|
||
perModel: {},
|
||
};
|
||
}
|
||
|
||
function extractProviderName(p) {
|
||
return normalizeProviderName((p && (p.provider || p.name)) || DEFAULT_PROVIDER_FALLBACK);
|
||
}
|
||
|
||
function collectProviderSeeds() {
|
||
const seeds = new Set(DEFAULT_PROVIDER_SEEDS);
|
||
const validProviders = new Set(DEFAULT_PROVIDER_SEEDS);
|
||
// Only include providers from limits/usage that are valid configured providers
|
||
// This prevents "moonshot" or "z-ai" from being treated as providers
|
||
Object.keys(providerLimits.limits || {}).forEach((p) => {
|
||
const normalized = normalizeProviderName(p);
|
||
if (validProviders.has(normalized)) seeds.add(normalized);
|
||
});
|
||
Object.keys(providerUsage || {}).forEach((p) => {
|
||
const normalized = normalizeProviderName(p);
|
||
if (validProviders.has(normalized)) seeds.add(normalized);
|
||
});
|
||
adminModels.forEach((m) => {
|
||
(m.providers || []).forEach((p) => {
|
||
const providerName = extractProviderName(p);
|
||
if (validProviders.has(providerName)) seeds.add(providerName);
|
||
});
|
||
});
|
||
(planSettings.planningChain || []).forEach((entry) => {
|
||
const normalized = normalizeProviderName(entry.provider);
|
||
if (validProviders.has(normalized)) seeds.add(normalized);
|
||
});
|
||
return Array.from(seeds);
|
||
}
|
||
|
||
function ensureProviderLimitDefaults(provider) {
|
||
const key = normalizeProviderName(provider);
|
||
if (!providerLimits.limits[key]) providerLimits.limits[key] = defaultProviderLimit(key);
|
||
const cfg = providerLimits.limits[key];
|
||
cfg.scope = cfg.scope === 'model' ? 'model' : 'provider';
|
||
cfg.tokensPerMinute = sanitizeLimitNumber(cfg.tokensPerMinute);
|
||
cfg.tokensPerHour = sanitizeLimitNumber(cfg.tokensPerHour);
|
||
cfg.tokensPerDay = sanitizeLimitNumber(cfg.tokensPerDay);
|
||
cfg.requestsPerMinute = sanitizeLimitNumber(cfg.requestsPerMinute);
|
||
cfg.requestsPerHour = sanitizeLimitNumber(cfg.requestsPerHour);
|
||
cfg.requestsPerDay = sanitizeLimitNumber(cfg.requestsPerDay);
|
||
cfg.perModel = cfg.perModel && typeof cfg.perModel === 'object' ? cfg.perModel : {};
|
||
Object.keys(cfg.perModel).forEach((model) => {
|
||
const entry = cfg.perModel[model] || {};
|
||
cfg.perModel[model] = {
|
||
tokensPerMinute: sanitizeLimitNumber(entry.tokensPerMinute),
|
||
tokensPerHour: sanitizeLimitNumber(entry.tokensPerHour),
|
||
tokensPerDay: sanitizeLimitNumber(entry.tokensPerDay),
|
||
requestsPerMinute: sanitizeLimitNumber(entry.requestsPerMinute),
|
||
requestsPerHour: sanitizeLimitNumber(entry.requestsPerHour),
|
||
requestsPerDay: sanitizeLimitNumber(entry.requestsPerDay),
|
||
};
|
||
});
|
||
return cfg;
|
||
}
|
||
|
||
async function discoverProviderModels() {
|
||
const map = new Map();
|
||
const add = (provider, model) => {
|
||
const key = normalizeProviderName(provider || DEFAULT_PROVIDER_FALLBACK);
|
||
if (!map.has(key)) map.set(key, new Set());
|
||
if (model) map.get(key).add(model);
|
||
};
|
||
|
||
collectProviderSeeds().forEach((p) => add(p));
|
||
|
||
adminModels.forEach((m) => {
|
||
(m.providers || []).forEach((p) => add(extractProviderName(p), p.model || m.name));
|
||
});
|
||
|
||
(planSettings.planningChain || []).forEach((entry) => {
|
||
add(entry.provider, entry.model);
|
||
});
|
||
|
||
try {
|
||
const models = await listModels(DEFAULT_PROVIDER_FALLBACK);
|
||
// Build a set of valid configured providers to avoid treating model name prefixes as providers
|
||
const validProviders = new Set(DEFAULT_PROVIDER_SEEDS);
|
||
models.forEach((m) => {
|
||
// listModels may return strings or objects; handle both.
|
||
let name = m.name || m.id || m;
|
||
if (!name || typeof name !== 'string') return;
|
||
|
||
// Handle OpenRouter model name formats:
|
||
// 1. "openrouter/z-ai/glm-4.7" -> provider: openrouter, model: openrouter/z-ai/glm-4.7
|
||
// 2. "z-ai/glm-4.7 (OpenRouter)" -> provider: openrouter, model: z-ai/glm-4.7
|
||
// 3. "z-ai/glm-4.7" without prefix/label -> provider: openrouter (detected from slash), model: z-ai/glm-4.7
|
||
|
||
const openRouterLabelMatch = name.match(/\s*\(openrouter\)\s*$/i);
|
||
if (openRouterLabelMatch) {
|
||
// Format: "z-ai/glm-4.7 (OpenRouter)" - strip the label and treat as openrouter
|
||
const cleanName = name.slice(0, openRouterLabelMatch.index).trim();
|
||
add('openrouter', cleanName);
|
||
return;
|
||
}
|
||
|
||
const parts = name.split('/');
|
||
if (parts.length > 1 && parts[0] && validProviders.has(parts[0].toLowerCase())) {
|
||
// The first part is a known configured provider (e.g., "openrouter/z-ai/glm-4.7")
|
||
add(parts[0], name);
|
||
} else if (parts.length > 1) {
|
||
// Model has a slash but first part is not a known provider (e.g., "z-ai/glm-4.7")
|
||
// This is likely an OpenRouter model without the prefix
|
||
add('openrouter', name);
|
||
} else {
|
||
// No slash in name, use default provider
|
||
add(DEFAULT_PROVIDER_FALLBACK, name);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
log('provider discovery failed', { error: String(err) });
|
||
}
|
||
|
||
const providers = Array.from(map.keys()).sort();
|
||
providers.forEach((p) => ensureProviderLimitDefaults(p));
|
||
|
||
const providerModels = {};
|
||
providers.forEach((p) => {
|
||
providerModels[p] = Array.from(map.get(p) || []).sort();
|
||
});
|
||
|
||
return { providers, providerModels };
|
||
}
|
||
|
||
async function loadProviderLimits() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(PROVIDER_LIMITS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') providerLimits = { ...providerLimits, ...parsed };
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load provider limits, using defaults', { error: String(error) });
|
||
}
|
||
|
||
// Clean up any invalid providers that may have been saved previously
|
||
// (e.g., "moonshot", "z-ai" that were incorrectly extracted from model names)
|
||
const validProviders = new Set(DEFAULT_PROVIDER_SEEDS);
|
||
if (providerLimits.limits) {
|
||
Object.keys(providerLimits.limits).forEach((key) => {
|
||
if (!validProviders.has(normalizeProviderName(key))) {
|
||
delete providerLimits.limits[key];
|
||
log('Removed invalid provider from limits', { provider: key });
|
||
}
|
||
});
|
||
}
|
||
|
||
collectProviderSeeds().forEach((p) => ensureProviderLimitDefaults(p));
|
||
if (!providerLimits.opencodeBackupModel) providerLimits.opencodeBackupModel = '';
|
||
}
|
||
|
||
async function persistProviderLimits() {
|
||
await ensureStateFile();
|
||
collectProviderSeeds().forEach((p) => ensureProviderLimitDefaults(p));
|
||
const payload = JSON.stringify(providerLimits, null, 2);
|
||
await safeWriteFile(PROVIDER_LIMITS_FILE, payload);
|
||
}
|
||
|
||
function ensureProviderUsageBucket(provider) {
|
||
const key = normalizeProviderName(provider);
|
||
if (!providerUsage[key]) providerUsage[key] = [];
|
||
return providerUsage[key];
|
||
}
|
||
|
||
function mergeLegacyProviderUsageIntoOpenRouter() {
|
||
// Historical/config-driven provider keys like "openai" or "anthropic" should be rolled up
|
||
// into OpenRouter usage when the model name looks like an OpenRouter model.
|
||
const openrouterBucket = ensureProviderUsageBucket('openrouter');
|
||
Object.keys(providerUsage || {}).forEach((providerKey) => {
|
||
const normalized = normalizeProviderName(providerKey);
|
||
if (KNOWN_USAGE_PROVIDERS.has(normalized)) return;
|
||
const entries = Array.isArray(providerUsage[providerKey]) ? providerUsage[providerKey] : [];
|
||
if (!entries.length) return;
|
||
const keep = [];
|
||
entries.forEach((entry) => {
|
||
const model = (entry && entry.model) ? String(entry.model) : '';
|
||
if (model.includes('/')) openrouterBucket.push(entry);
|
||
else keep.push(entry);
|
||
});
|
||
if (keep.length) providerUsage[providerKey] = keep;
|
||
else delete providerUsage[providerKey];
|
||
});
|
||
pruneProviderUsage();
|
||
}
|
||
|
||
function pruneProviderUsage(now = Date.now()) {
|
||
const cutoff = now - FORTY_EIGHT_HOURS_MS; // keep last 48h for reporting
|
||
Object.keys(providerUsage).forEach((provider) => {
|
||
providerUsage[provider] = (providerUsage[provider] || []).filter((entry) => entry && typeof entry.ts === 'number' && entry.ts >= cutoff);
|
||
});
|
||
}
|
||
|
||
async function loadProviderUsage() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(PROVIDER_USAGE_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') providerUsage = parsed;
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load provider usage, starting empty', { error: String(error) });
|
||
providerUsage = {};
|
||
}
|
||
pruneProviderUsage();
|
||
mergeLegacyProviderUsageIntoOpenRouter();
|
||
collectProviderSeeds().forEach((p) => ensureProviderUsageBucket(p));
|
||
}
|
||
|
||
async function persistProviderUsage() {
|
||
pruneProviderUsage();
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(providerUsage, null, 2);
|
||
try {
|
||
await safeWriteFile(PROVIDER_USAGE_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist provider usage', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
function currentMonthKey(date = new Date()) {
|
||
const yr = date.getFullYear();
|
||
const mo = String(date.getMonth() + 1).padStart(2, '0');
|
||
return `${yr}-${mo}`;
|
||
}
|
||
|
||
function getModelTier(modelName) {
|
||
if (!modelName) return null;
|
||
const target = getAdminModelByIdOrName(modelName);
|
||
return target ? target.tier : null;
|
||
}
|
||
|
||
function ensureTokenUsageBucket(userId) {
|
||
const key = String(userId || '');
|
||
if (!key) return null;
|
||
const month = currentMonthKey();
|
||
if (!tokenUsage[key] || tokenUsage[key].month !== month) {
|
||
tokenUsage[key] = {
|
||
month,
|
||
usage: 0,
|
||
addOns: 0,
|
||
paygBilled: 0,
|
||
};
|
||
} else {
|
||
const entry = tokenUsage[key];
|
||
entry.usage = typeof entry.usage === 'number' ? entry.usage : 0;
|
||
entry.addOns = typeof entry.addOns === 'number' ? entry.addOns : 0;
|
||
entry.paygBilled = typeof entry.paygBilled === 'number' ? entry.paygBilled : 0;
|
||
}
|
||
return tokenUsage[key];
|
||
}
|
||
|
||
function ensureExternalTestUsageBucket(userId) {
|
||
const key = String(userId || '');
|
||
if (!key) return null;
|
||
const month = currentMonthKey();
|
||
if (!externalTestUsage[key] || externalTestUsage[key].month !== month) {
|
||
externalTestUsage[key] = { month, count: 0 };
|
||
} else {
|
||
const entry = externalTestUsage[key];
|
||
entry.count = typeof entry.count === 'number' ? entry.count : 0;
|
||
}
|
||
return externalTestUsage[key];
|
||
}
|
||
|
||
function getExternalTestUsageSummary(userId, plan) {
|
||
const bucket = ensureExternalTestUsageBucket(userId) || { count: 0, month: currentMonthKey() };
|
||
const normalizedPlan = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||
const limit = EXTERNAL_TEST_LIMITS[normalizedPlan] ?? EXTERNAL_TEST_LIMITS[DEFAULT_PLAN];
|
||
const used = Math.max(0, Number(bucket.count || 0));
|
||
const remaining = Number.isFinite(limit) ? Math.max(0, limit - used) : 0;
|
||
const percent = Number.isFinite(limit) && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||
return {
|
||
month: bucket.month,
|
||
plan: normalizedPlan,
|
||
used,
|
||
limit,
|
||
remaining,
|
||
percent,
|
||
};
|
||
}
|
||
|
||
function canUseExternalTesting(userId, plan, unlimited = false) {
|
||
if (unlimited) return { allowed: true, summary: getExternalTestUsageSummary(userId, plan) };
|
||
const summary = getExternalTestUsageSummary(userId, plan);
|
||
if (!Number.isFinite(summary.limit)) return { allowed: true, summary };
|
||
if (summary.used >= summary.limit) {
|
||
return { allowed: false, summary };
|
||
}
|
||
return { allowed: true, summary };
|
||
}
|
||
|
||
function normalizeTier(tier) {
|
||
const normalized = (tier || '').toLowerCase();
|
||
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
||
}
|
||
|
||
function getTierMultiplier(tier) {
|
||
const normalizedTier = normalizeTier(tier);
|
||
return normalizedTier === 'pro' ? 3 : (normalizedTier === 'plus' ? 2 : 1);
|
||
}
|
||
|
||
async function loadTokenUsage() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(TOKEN_USAGE_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') tokenUsage = parsed;
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load token usage, starting empty', { error: String(error) });
|
||
tokenUsage = {};
|
||
}
|
||
}
|
||
|
||
async function loadExternalTestUsage() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(EXTERNAL_TEST_USAGE_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') externalTestUsage = parsed;
|
||
}
|
||
} catch (error) {
|
||
log('Failed to load external test usage, starting empty', { error: String(error) });
|
||
externalTestUsage = {};
|
||
}
|
||
}
|
||
|
||
async function persistTokenUsage() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(tokenUsage, null, 2);
|
||
try {
|
||
await safeWriteFile(TOKEN_USAGE_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist token usage', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function persistExternalTestUsage() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(externalTestUsage, null, 2);
|
||
try {
|
||
await safeWriteFile(EXTERNAL_TEST_USAGE_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist external test usage', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function loadTopupSessions() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(TOPUP_SESSIONS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') processedTopups = parsed;
|
||
}
|
||
} catch (error) {
|
||
processedTopups = {};
|
||
log('Failed to load top-up sessions, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistTopupSessions() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(processedTopups, null, 2);
|
||
try {
|
||
await safeWriteFile(TOPUP_SESSIONS_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist top-up sessions', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function loadPendingTopups() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(TOPUP_PENDING_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') pendingTopups = parsed;
|
||
}
|
||
} catch (error) {
|
||
pendingTopups = {};
|
||
log('Failed to load pending top-ups, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPendingTopups() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(pendingTopups, null, 2);
|
||
try {
|
||
await safeWriteFile(TOPUP_PENDING_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist pending top-ups', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function loadInvoicesDb() {
|
||
try {
|
||
await ensureStateFile();
|
||
await fs.mkdir(INVOICES_DIR, { recursive: true });
|
||
const raw = await fs.readFile(INVOICES_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && Array.isArray(parsed)) invoicesDb = parsed;
|
||
}
|
||
} catch (error) {
|
||
invoicesDb = [];
|
||
log('Failed to load invoices, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistInvoicesDb() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(invoicesDb, null, 2);
|
||
try {
|
||
await safeWriteFile(INVOICES_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist invoices', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
function generateInvoiceNumber() {
|
||
const date = new Date();
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const count = invoicesDb.filter(inv => inv.invoiceNumber?.startsWith(`INV-${year}${month}`)).length + 1;
|
||
return `INV-${year}${month}-${String(count).padStart(4, '0')}`;
|
||
}
|
||
|
||
function normalizeInvoiceSource(source) {
|
||
if (!source || typeof source !== 'object') return null;
|
||
const provider = String(source.provider || 'dodo').toLowerCase();
|
||
const normalized = { provider };
|
||
for (const key of ['orderId', 'checkoutId', 'paymentId', 'eventId', 'subscriptionId']) {
|
||
if (source[key]) normalized[key] = String(source[key]);
|
||
}
|
||
const hasIdentifier = Object.keys(normalized).some((key) => key !== 'provider');
|
||
return hasIdentifier ? normalized : null;
|
||
}
|
||
|
||
function findInvoiceBySource(userId, source) {
|
||
const normalized = normalizeInvoiceSource(source);
|
||
if (!normalized) return null;
|
||
const matchOrder = ['paymentId', 'eventId', 'orderId', 'checkoutId'];
|
||
|
||
return invoicesDb.find((invoice) => {
|
||
if (!invoice || invoice.userId !== userId) return false;
|
||
const invSource = invoice.details?.source;
|
||
if (!invSource || typeof invSource !== 'object') return false;
|
||
if (String(invSource.provider || 'dodo').toLowerCase() !== normalized.provider) return false;
|
||
|
||
return matchOrder.some((key) => normalized[key] && invSource[key] && String(invSource[key]) === normalized[key]);
|
||
}) || null;
|
||
}
|
||
|
||
async function createInvoiceIfMissing(user, type, details) {
|
||
const existing = findInvoiceBySource(user.id, details?.source);
|
||
if (existing) return existing;
|
||
return createInvoice(user, type, details);
|
||
}
|
||
|
||
async function createInvoice(user, type, details) {
|
||
const invoiceNumber = generateInvoiceNumber();
|
||
const amountRaw = Number(details?.amount);
|
||
const amount = Number.isFinite(amountRaw) ? Math.max(0, Math.round(amountRaw)) : 0;
|
||
const currency = String(details?.currency || 'usd').toUpperCase();
|
||
const invoice = {
|
||
id: randomUUID(),
|
||
invoiceNumber,
|
||
userId: user.id,
|
||
email: user.email,
|
||
type,
|
||
status: 'paid',
|
||
amount,
|
||
currency,
|
||
createdAt: new Date().toISOString(),
|
||
dueDate: new Date().toISOString(),
|
||
paidAt: new Date().toISOString(),
|
||
details: {}
|
||
};
|
||
|
||
const source = normalizeInvoiceSource(details?.source);
|
||
if (source) invoice.details.source = source;
|
||
|
||
if (type === 'topup') {
|
||
const tokenCount = Number(details?.tokens);
|
||
const tokenLabel = Number.isFinite(tokenCount) ? tokenCount.toLocaleString() : '0';
|
||
invoice.description = `Top-up - ${tokenLabel} tokens`;
|
||
invoice.details.tokens = Number.isFinite(tokenCount) ? tokenCount : 0;
|
||
if (details?.tier) invoice.details.tier = details.tier;
|
||
} else if (type === 'subscription') {
|
||
invoice.description = `Subscription - ${details?.plan || 'plan'} (${details?.billingCycle || 'monthly'})`;
|
||
invoice.details.plan = details?.plan;
|
||
invoice.details.billingCycle = details?.billingCycle;
|
||
} else if (type === 'payg') {
|
||
const tokenCount = Number(details?.tokens);
|
||
const tokenLabel = Number.isFinite(tokenCount) ? tokenCount.toLocaleString() : '0';
|
||
invoice.description = `Pay-as-you-go - ${tokenLabel} tokens`;
|
||
invoice.details.tokens = Number.isFinite(tokenCount) ? tokenCount : 0;
|
||
}
|
||
|
||
invoicesDb.push(invoice);
|
||
await persistInvoicesDb();
|
||
|
||
const pdfPath = path.join(INVOICES_DIR, `${invoice.id}.pdf`);
|
||
await generateInvoicePdf(invoice, user, pdfPath);
|
||
|
||
return invoice;
|
||
}
|
||
|
||
async function generateInvoicePdf(invoice, user, outputPath) {
|
||
return new Promise((resolve, reject) => {
|
||
const doc = new PDFDocument({
|
||
size: 'A4',
|
||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
||
compress: true,
|
||
info: {
|
||
Title: `Invoice ${invoice.invoiceNumber}`,
|
||
Author: 'Plugin Compass',
|
||
Subject: invoice.description,
|
||
Creator: 'Plugin Compass'
|
||
}
|
||
});
|
||
|
||
const stream = require('fs').createWriteStream(outputPath);
|
||
doc.pipe(stream);
|
||
|
||
const green = '#008060';
|
||
const darkGreen = '#004c3f';
|
||
const gray = '#6b7280';
|
||
const lightGray = '#f3f4f6';
|
||
|
||
doc.fontSize(24).fillColor(green).font('Helvetica-Bold').text('Plugin Compass', 50, 50);
|
||
doc.moveDown(0.5);
|
||
|
||
doc.fontSize(10).fillColor(gray).font('Helvetica').text('Invoice', 50, null);
|
||
doc.fontSize(28).fillColor('#1f2937').font('Helvetica-Bold').text(invoice.invoiceNumber, 50, null);
|
||
doc.moveDown(0.5);
|
||
|
||
doc.fontSize(10).fillColor(gray).font('Helvetica').text('Date:', 50, null);
|
||
doc.fontSize(12).fillColor('#1f2937').font('Helvetica').text(new Date(invoice.createdAt).toLocaleDateString(), 50, null);
|
||
doc.moveDown(0.3);
|
||
|
||
doc.fontSize(10).fillColor(gray).font('Helvetica').text('Due Date:', 50, null);
|
||
doc.fontSize(12).fillColor('#1f2937').font('Helvetica').text(new Date(invoice.dueDate).toLocaleDateString(), 50, null);
|
||
|
||
const y = 50;
|
||
const rightX = 400;
|
||
doc.fontSize(10).fillColor(gray).font('Helvetica').text('Bill To:', rightX, y);
|
||
doc.fontSize(12).fillColor('#1f2937').font('Helvetica-Bold').text(user.email || 'N/A', rightX, null);
|
||
|
||
doc.moveDown(1.5);
|
||
|
||
doc.rect(50, doc.y, 495, 0).fill(lightGray);
|
||
doc.fill(green).fontSize(10).font('Helvetica-Bold').text('Description', 60, doc.y - 24, { width: 250 });
|
||
doc.text('Amount', 430, doc.y, { width: 100 });
|
||
doc.rect(50, doc.y - 24, 495, 24).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||
|
||
doc.moveDown(0.5);
|
||
|
||
doc.fontSize(10).fillColor('#374151').font('Helvetica').text(invoice.description, 60, null, { width: 250 });
|
||
const amountText = `${invoice.currency} ${(invoice.amount / 100).toFixed(2)}`;
|
||
doc.text(amountText, 430, doc.y - 14, { width: 100, align: 'right' });
|
||
|
||
doc.moveDown(0.5);
|
||
doc.rect(50, doc.y - 12, 495, 0).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||
|
||
const totalY = doc.y + 20;
|
||
doc.fontSize(10).fillColor(gray).font('Helvetica').text('Total:', 430, totalY);
|
||
doc.fontSize(16).fillColor(green).font('Helvetica-Bold').text(amountText, 430, totalY + 15, { width: 100, align: 'right' });
|
||
|
||
doc.moveDown(3);
|
||
|
||
doc.fontSize(9).fillColor(gray).font('Helvetica').text('Payment Status: PAID', 50, null);
|
||
|
||
doc.moveDown(2);
|
||
doc.fontSize(9).fillColor(gray).font('Helvetica').text('Thank you for your payment!', 50, null);
|
||
|
||
doc.moveDown(1);
|
||
doc.fontSize(8).fillColor('#9ca3af').font('Helvetica').text('Plugin Compass © ' + new Date().getFullYear() + '. All rights reserved.', 50, null);
|
||
|
||
doc.end();
|
||
|
||
stream.on('finish', resolve);
|
||
stream.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function getInvoicesByUserId(userId) {
|
||
return invoicesDb
|
||
.filter(inv => inv.userId === userId)
|
||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
}
|
||
|
||
async function loadPaygSessions() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(PAYG_SESSIONS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') processedPayg = parsed;
|
||
}
|
||
} catch (error) {
|
||
processedPayg = {};
|
||
log('Failed to load pay-as-you-go sessions, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPaygSessions() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(processedPayg, null, 2);
|
||
try {
|
||
await safeWriteFile(PAYG_SESSIONS_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist pay-as-you-go sessions', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function loadPendingPayg() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(PAYG_PENDING_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') pendingPayg = parsed;
|
||
}
|
||
} catch (error) {
|
||
pendingPayg = {};
|
||
log('Failed to load pending pay-as-you-go sessions, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPendingPayg() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(pendingPayg, null, 2);
|
||
try {
|
||
await safeWriteFile(PAYG_PENDING_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist pending pay-as-you-go sessions', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
// Subscription persistence functions
|
||
async function loadSubscriptionSessions() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(SUBSCRIPTION_SESSIONS_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') processedSubscriptions = parsed;
|
||
}
|
||
} catch (error) {
|
||
processedSubscriptions = {};
|
||
log('Failed to load subscription sessions, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function loadPendingSubscriptions() {
|
||
try {
|
||
await ensureStateFile();
|
||
const raw = await fs.readFile(SUBSCRIPTION_PENDING_FILE, 'utf8').catch(() => null);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') pendingSubscriptions = parsed;
|
||
}
|
||
} catch (error) {
|
||
pendingSubscriptions = {};
|
||
log('Failed to load pending subscriptions, starting empty', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
async function persistPendingSubscriptions() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(pendingSubscriptions, null, 2);
|
||
try {
|
||
await safeWriteFile(SUBSCRIPTION_PENDING_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist pending subscriptions', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function persistProcessedSubscriptions() {
|
||
await ensureStateFile();
|
||
const payload = JSON.stringify(processedSubscriptions, null, 2);
|
||
try {
|
||
await safeWriteFile(SUBSCRIPTION_SESSIONS_FILE, payload);
|
||
} catch (err) {
|
||
log('Failed to persist processed subscriptions', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
async function dodoRequest(pathname, { method = 'GET', body, query } = {}) {
|
||
if (!DODO_ENABLED) throw new Error('Dodo Payments is not configured');
|
||
const url = new URL(`${DODO_BASE_URL}${pathname}`);
|
||
if (query && typeof query === 'object') {
|
||
Object.entries(query).forEach(([key, value]) => {
|
||
if (value === undefined || value === null || value === '') return;
|
||
url.searchParams.set(key, String(value));
|
||
});
|
||
}
|
||
const headers = {
|
||
Authorization: `Bearer ${DODO_PAYMENTS_API_KEY}`,
|
||
};
|
||
if (body) headers['Content-Type'] = 'application/json';
|
||
const res = await fetch(url, {
|
||
method,
|
||
headers,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const payload = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const detail = payload?.error || payload?.message || res.statusText || 'Dodo request failed';
|
||
const error = new Error(detail);
|
||
error.status = res.status;
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
// Attempt immediate cancellation first (DELETE), then fall back to end-of-period cancellation (PATCH).
|
||
const DODO_SUBSCRIPTION_CANCEL_ATTEMPTS = [
|
||
{ method: 'DELETE', body: undefined, label: 'immediate' },
|
||
{ method: 'PATCH', body: { cancel_at_next_billing_date: true }, label: 'period_end' },
|
||
];
|
||
|
||
/**
|
||
* Cancel a Dodo subscription with a best-effort fallback strategy.
|
||
* Tries immediate cancellation first, then schedules cancellation at period end.
|
||
* @param {object} user - User record containing dodoSubscriptionId.
|
||
* @param {string} reason - Cancellation reason for logging.
|
||
* @param {object} options - Behavior options.
|
||
* @param {boolean} options.clearOnFailure - Whether to clear the subscription ID on failure.
|
||
* @returns {Promise<boolean>} True if a cancellation request succeeded.
|
||
*/
|
||
async function cancelDodoSubscription(user, reason = 'manual', { clearOnFailure = false } = {}) {
|
||
if (!user?.dodoSubscriptionId) return false;
|
||
const subscriptionId = user.dodoSubscriptionId;
|
||
|
||
for (const attempt of DODO_SUBSCRIPTION_CANCEL_ATTEMPTS) {
|
||
try {
|
||
await dodoRequest(`/subscriptions/${subscriptionId}`, attempt);
|
||
log('Dodo subscription cancelled', { userId: user.id, subscriptionId, reason, method: attempt.method, mode: attempt.label });
|
||
user.dodoSubscriptionId = '';
|
||
await persistUsersDb();
|
||
return true;
|
||
} catch (error) {
|
||
log('Failed to cancel Dodo subscription', {
|
||
userId: user.id,
|
||
subscriptionId,
|
||
reason,
|
||
method: attempt.method,
|
||
mode: attempt.label,
|
||
error: String(error)
|
||
});
|
||
}
|
||
}
|
||
if (clearOnFailure) {
|
||
user.dodoSubscriptionId = '';
|
||
await persistUsersDb();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Change an existing Dodo subscription to a different plan using the Change Plan API.
|
||
* This properly handles upgrades and downgrades with proration.
|
||
* @param {object} user - User record containing dodoSubscriptionId.
|
||
* @param {string} newPlan - The new plan to change to (starter, professional, enterprise).
|
||
* @param {string} billingCycle - The billing cycle (monthly, yearly).
|
||
* @param {string} currency - The currency (usd, gbp, eur).
|
||
* @returns {Promise<object>} The change plan response from Dodo.
|
||
*/
|
||
async function changeDodoSubscriptionPlan(user, newPlan, billingCycle, currency) {
|
||
if (!user?.dodoSubscriptionId) {
|
||
throw new Error('No active subscription to change');
|
||
}
|
||
|
||
const subscriptionId = user.dodoSubscriptionId;
|
||
const product = resolveSubscriptionProduct(newPlan, billingCycle, currency);
|
||
|
||
if (!product) {
|
||
throw new Error('Invalid plan, billing cycle, or currency combination');
|
||
}
|
||
|
||
try {
|
||
// Use Dodo's Change Plan API with difference_immediately proration
|
||
// This will charge the difference for upgrades and apply credits for downgrades
|
||
const result = await dodoRequest(`/subscriptions/${subscriptionId}/change-plan`, {
|
||
method: 'POST',
|
||
body: {
|
||
product_id: product.productId,
|
||
quantity: 1,
|
||
proration_billing_mode: 'difference_immediately',
|
||
},
|
||
});
|
||
|
||
log('Dodo subscription plan changed', {
|
||
userId: user.id,
|
||
subscriptionId,
|
||
oldPlan: user.plan,
|
||
newPlan,
|
||
billingCycle,
|
||
currency,
|
||
productId: product.productId,
|
||
});
|
||
|
||
return result;
|
||
} catch (error) {
|
||
log('Failed to change Dodo subscription plan', {
|
||
userId: user.id,
|
||
subscriptionId,
|
||
oldPlan: user.plan,
|
||
newPlan,
|
||
error: String(error),
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function ensureDodoCustomer(user) {
|
||
if (!user) return '';
|
||
if (user.dodoCustomerId) return user.dodoCustomerId;
|
||
const email = user.billingEmail || user.email || '';
|
||
if (!email) return '';
|
||
const customers = await dodoRequest('/customers', {
|
||
method: 'POST',
|
||
body: {
|
||
email,
|
||
name: email.split('@')[0] || email,
|
||
metadata: { userId: String(user.id) },
|
||
},
|
||
});
|
||
const customerId = customers?.customer_id || customers?.id || '';
|
||
if (customerId) {
|
||
user.dodoCustomerId = customerId;
|
||
await persistUsersDb();
|
||
}
|
||
return customerId;
|
||
}
|
||
|
||
async function ingestUsageEvent({ user, tokens, amountCents, currency, ratePerMillion, plan, month }) {
|
||
if (!DODO_ENABLED || !DODO_USAGE_EVENTS_ENABLED) return false;
|
||
if (!user || !Number.isFinite(tokens) || tokens <= 0) return false;
|
||
if (!Number.isFinite(amountCents) || amountCents <= 0) return false;
|
||
const customerId = await ensureDodoCustomer(user);
|
||
if (!customerId) return false;
|
||
|
||
const metadata = {
|
||
[DODO_USAGE_EVENT_TOKENS_FIELD]: Math.max(0, Math.round(tokens)),
|
||
[DODO_USAGE_EVENT_COST_FIELD]: Math.max(0, Math.round(amountCents)),
|
||
currency: String(currency || 'usd').toLowerCase(),
|
||
ratePerMillion: Math.max(0, Number(ratePerMillion || 0)),
|
||
plan: String(plan || '').toLowerCase(),
|
||
month: String(month || ''),
|
||
userId: String(user.id),
|
||
};
|
||
|
||
const event = {
|
||
event_id: `usage_${randomUUID()}`,
|
||
customer_id: customerId,
|
||
event_name: DODO_USAGE_EVENT_NAME,
|
||
timestamp: new Date().toISOString(),
|
||
metadata,
|
||
};
|
||
|
||
await dodoRequest('/events/ingest', {
|
||
method: 'POST',
|
||
body: { events: [event] },
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function refreshDodoProductsCache() {
|
||
const now = Date.now();
|
||
if (dodoProductCache.items.length && (now - dodoProductCache.fetchedAt) < DODO_PRODUCTS_CACHE_TTL_MS) {
|
||
return dodoProductCache;
|
||
}
|
||
const items = [];
|
||
const pageSize = 100;
|
||
for (let page = 0; page < 10; page += 1) {
|
||
const data = await dodoRequest('/products', {
|
||
method: 'GET',
|
||
query: { page_size: pageSize, page_number: page },
|
||
});
|
||
const list = Array.isArray(data?.items) ? data.items : [];
|
||
items.push(...list);
|
||
if (list.length < pageSize) break;
|
||
}
|
||
const byId = new Map();
|
||
items.forEach((item) => {
|
||
if (item?.product_id) byId.set(String(item.product_id), item);
|
||
});
|
||
dodoProductCache = { fetchedAt: now, items, byId };
|
||
return dodoProductCache;
|
||
}
|
||
|
||
async function getDodoProductById(productId) {
|
||
if (!productId) return null;
|
||
const cache = await refreshDodoProductsCache();
|
||
if (cache.byId.has(productId)) return cache.byId.get(productId);
|
||
return null;
|
||
}
|
||
|
||
// Subscription product resolution
|
||
function resolveSubscriptionProduct(plan, billingCycle, currency) {
|
||
const normalizedPlan = String(plan || '').toLowerCase();
|
||
const normalizedBilling = String(billingCycle || '').toLowerCase();
|
||
const normalizedCurrency = String(currency || '').toLowerCase();
|
||
|
||
// Only support paid plans for subscriptions
|
||
if (!PAID_PLANS.has(normalizedPlan)) return null;
|
||
if (!BILLING_CYCLES.includes(normalizedBilling)) return null;
|
||
if (!SUPPORTED_CURRENCIES.includes(normalizedCurrency)) return null;
|
||
|
||
const productKey = `${normalizedPlan}_${normalizedBilling}_${normalizedCurrency}`;
|
||
const productId = SUBSCRIPTION_PRODUCT_IDS[productKey];
|
||
const price = SUBSCRIPTION_PRICES[productKey];
|
||
|
||
if (!productId || !price) return null;
|
||
|
||
return {
|
||
plan: normalizedPlan,
|
||
billingCycle: normalizedBilling,
|
||
currency: normalizedCurrency,
|
||
productId,
|
||
price,
|
||
productKey,
|
||
};
|
||
}
|
||
|
||
// Get all available subscription products for a plan
|
||
function getAvailableSubscriptionProducts(plan) {
|
||
const normalizedPlan = String(plan || '').toLowerCase();
|
||
if (!PAID_PLANS.has(normalizedPlan)) return [];
|
||
|
||
const products = [];
|
||
for (const billing of BILLING_CYCLES) {
|
||
for (const currency of SUPPORTED_CURRENCIES) {
|
||
const product = resolveSubscriptionProduct(normalizedPlan, billing, currency);
|
||
if (product) products.push(product);
|
||
}
|
||
}
|
||
return products;
|
||
}
|
||
|
||
// Validate subscription selection
|
||
function validateSubscriptionSelection(plan, billingCycle, currency) {
|
||
const product = resolveSubscriptionProduct(plan, billingCycle, currency);
|
||
return product !== null;
|
||
}
|
||
|
||
function getPlanTokenLimits(plan, userId) {
|
||
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||
const base = (planTokenLimits && typeof planTokenLimits[normalized] === 'number') ? planTokenLimits[normalized] : (PLAN_TOKEN_LIMITS[DEFAULT_PLAN] || 0);
|
||
const bucket = ensureTokenUsageBucket(userId) || { addOns: 0 };
|
||
return Math.max(0, Number(base || 0) + Number(bucket.addOns || 0));
|
||
}
|
||
|
||
function getTokenUsageSummary(userId, plan) {
|
||
const bucket = ensureTokenUsageBucket(userId) || { usage: 0, addOns: 0 };
|
||
const limit = getPlanTokenLimits(plan, userId);
|
||
const used = Math.max(0, Number(bucket.usage || 0));
|
||
const remaining = limit > 0 ? Math.max(0, limit - used) : 0;
|
||
return {
|
||
month: bucket.month || currentMonthKey(),
|
||
used,
|
||
limit,
|
||
remaining,
|
||
percent: limit > 0 ? Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))) : 0,
|
||
addOn: Math.max(0, Number(bucket.addOns || 0)),
|
||
plan: normalizePlanSelection(plan) || DEFAULT_PLAN,
|
||
};
|
||
}
|
||
|
||
async function recordExternalTestUsage(userId) {
|
||
const bucket = ensureExternalTestUsageBucket(userId);
|
||
if (!bucket) return null;
|
||
bucket.count += 1;
|
||
await persistExternalTestUsage();
|
||
return bucket;
|
||
}
|
||
|
||
function resolveUserCurrency(user) {
|
||
const currency = String(user?.subscriptionCurrency || user?.billingCurrency || '').toLowerCase();
|
||
return SUPPORTED_CURRENCIES.includes(currency) ? currency : 'usd';
|
||
}
|
||
|
||
function getPaygPrice(currency = 'usd') {
|
||
const normalized = String(currency || 'usd').toLowerCase();
|
||
const fallback = PAYG_PRICES.usd || MIN_PAYMENT_AMOUNT;
|
||
return Math.max(MIN_PAYMENT_AMOUNT, PAYG_PRICES[normalized] || fallback);
|
||
}
|
||
|
||
function getPendingPaygTokens(userId, monthKey = currentMonthKey()) {
|
||
const targetMonth = monthKey || currentMonthKey();
|
||
let total = 0;
|
||
Object.values(pendingPayg || {}).forEach((entry) => {
|
||
if (!entry) return;
|
||
if (entry.userId !== userId) return;
|
||
if (entry.month && entry.month !== targetMonth) return;
|
||
total += Math.max(0, Number(entry.tokens || 0));
|
||
});
|
||
return total;
|
||
}
|
||
|
||
function computePaygSummary(userId, plan, { projectedUsage } = {}) {
|
||
const user = findUserById(userId);
|
||
const currency = resolveUserCurrency(user);
|
||
const pricePerUnit = getPaygPrice(currency);
|
||
const bucket = ensureTokenUsageBucket(userId) || { usage: 0, addOns: 0, paygBilled: 0 };
|
||
const limit = getPlanTokenLimits(plan, userId);
|
||
const used = Math.max(0, Number(projectedUsage !== undefined ? projectedUsage : bucket.usage || 0));
|
||
const overageTokens = Math.max(0, used - limit);
|
||
const billedTokens = Math.max(0, Number(bucket.paygBilled || 0));
|
||
const pendingTokens = getPendingPaygTokens(userId, bucket.month);
|
||
const billableTokens = Math.max(0, overageTokens - billedTokens - pendingTokens);
|
||
const amount = PAYG_ENABLED ? Math.max(0, Math.ceil((billableTokens * pricePerUnit) / PAYG_UNIT_TOKENS)) : 0;
|
||
const projectedAmount = PAYG_ENABLED ? Math.max(0, Math.ceil((Math.max(0, overageTokens - billedTokens - pendingTokens) * pricePerUnit) / PAYG_UNIT_TOKENS)) : 0;
|
||
return {
|
||
currency,
|
||
productConfigured: !!PAYG_PRODUCT_IDS[currency],
|
||
pricePerUnit,
|
||
unitTokens: PAYG_UNIT_TOKENS,
|
||
overageTokens,
|
||
billedTokens,
|
||
pendingTokens,
|
||
billableTokens,
|
||
amount,
|
||
projectedAmount,
|
||
month: bucket.month,
|
||
limit,
|
||
used,
|
||
};
|
||
}
|
||
|
||
function computeTopupDiscount(plan) {
|
||
const normalized = normalizePlanSelection(plan);
|
||
if (normalized === 'enterprise') return ENTERPRISE_TOPUP_DISCOUNT;
|
||
if (normalized === 'professional') return BUSINESS_TOPUP_DISCOUNT;
|
||
return 0;
|
||
}
|
||
|
||
function applyTopupDiscount(baseAmount, discount) {
|
||
// Respect gateway minimum charge; if discount pushes below minimum, cap at MIN_PAYMENT_AMOUNT
|
||
return Math.max(MIN_PAYMENT_AMOUNT, Math.round(baseAmount * (1 - discount)));
|
||
}
|
||
|
||
function resolveTopupPack(tier, currency = 'usd') {
|
||
const normalizedTier = (tier || '').toString().toLowerCase();
|
||
const normalizedCurrency = (currency || 'usd').toLowerCase();
|
||
const priceKey = `${normalizedTier}_${normalizedCurrency}`;
|
||
|
||
// Ensure config objects exist
|
||
const productIds = TOPUP_PRODUCT_IDS || {};
|
||
const tokens = TOPUP_TOKENS || {};
|
||
|
||
// Try new format with currency support
|
||
if (productIds[priceKey]) {
|
||
return {
|
||
tier: normalizedTier,
|
||
currency: normalizedCurrency,
|
||
tokens: tokens[normalizedTier] || 0,
|
||
productId: productIds[priceKey] || '',
|
||
};
|
||
}
|
||
|
||
// Fallback to legacy mapping for backward compatibility (assumes USD)
|
||
let legacy = 'topup_1';
|
||
if (normalizedTier === 'free') legacy = 'topup_1';
|
||
else if (normalizedTier === 'plus') legacy = 'topup_2';
|
||
else if (normalizedTier === 'pro') legacy = 'topup_3';
|
||
|
||
return {
|
||
tier: legacy,
|
||
currency: normalizedCurrency,
|
||
tokens: tokens[legacy] || 0,
|
||
productId: productIds[`${legacy}_usd`] || '',
|
||
};
|
||
}
|
||
|
||
function getTopupPrice(tier, currency = 'usd') {
|
||
const normalizedTier = (tier || '').toLowerCase();
|
||
const normalizedCurrency = (currency || 'usd').toLowerCase();
|
||
const priceKey = `${normalizedTier}_${normalizedCurrency}`;
|
||
const prices = TOPUP_PRICES || {};
|
||
return prices[priceKey] || 0;
|
||
}
|
||
|
||
async function recordUserTokens(userId, tokens = 0) {
|
||
if (!userId) {
|
||
console.error('[USAGE] recordUserTokens: userId is missing, cannot record tokens');
|
||
return;
|
||
}
|
||
const roundedTokens = Math.ceil(tokens || 0); // Always round up
|
||
if (roundedTokens <= 0) {
|
||
console.error(`[USAGE] recordUserTokens: token count is 0 or negative, skipping. Raw: ${tokens}, Rounded: ${roundedTokens}`);
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
const bucket = ensureTokenUsageBucket(userId);
|
||
const previousTotal = Number(bucket.usage) || 0;
|
||
bucket.usage = previousTotal + roundedTokens;
|
||
|
||
// Usage-based billing for unlimited usage (charge only over plan limit)
|
||
if (user?.unlimitedUsage) {
|
||
const plan = user?.plan || DEFAULT_PLAN;
|
||
const limit = getPlanTokenLimits(plan, userId);
|
||
const previousOverage = Math.max(0, previousTotal - limit);
|
||
const currentOverage = Math.max(0, bucket.usage - limit);
|
||
const overageDelta = Math.max(0, currentOverage - previousOverage);
|
||
|
||
if (overageDelta > 0) {
|
||
const currency = resolveUserCurrency(user);
|
||
const rate = tokenRates[currency] || DEFAULT_TOKEN_RATES[currency] || 250;
|
||
const chargeAmount = Math.max(0, Math.round((overageDelta * rate) / 1_000_000));
|
||
|
||
if (chargeAmount > 0) {
|
||
try {
|
||
await ingestUsageEvent({
|
||
user,
|
||
tokens: overageDelta,
|
||
amountCents: chargeAmount,
|
||
currency,
|
||
ratePerMillion: rate,
|
||
plan,
|
||
month: bucket.month || currentMonthKey(),
|
||
});
|
||
console.log(`[BILLING] 📈 Usage event: ${overageDelta} tokens over limit, ${currency.toUpperCase()}${(chargeAmount / 100).toFixed(2)} billed for user ${userId}.`);
|
||
} catch (err) {
|
||
console.error(`[BILLING] ❌ Failed to ingest usage event for ${userId}:`, err?.message || err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Force immediate write
|
||
try {
|
||
await persistTokenUsage();
|
||
console.log(`[USAGE] ✅ Recorded ${roundedTokens} tokens for ${userId}. Previous: ${previousTotal}, New total: ${bucket.usage}`);
|
||
} catch (err) {
|
||
console.error(`[USAGE] ❌ Failed to persist token usage for ${userId}:`, err);
|
||
}
|
||
}
|
||
|
||
function canConsumeTokens(userId, plan, requestedTokens = 0) {
|
||
const user = findUserById(userId);
|
||
const summary = getTokenUsageSummary(userId, plan);
|
||
const requested = Math.max(0, Math.round(requestedTokens || 0));
|
||
const grace = Math.max(TOKEN_GRACE_MIN, Math.round(summary.limit * TOKEN_GRACE_RATIO));
|
||
const projected = (summary.used || 0) + requested;
|
||
const unlimitedEnabled = Boolean(user?.unlimitedUsage);
|
||
const paid = isPaidPlan(plan);
|
||
const allowOverage = paid && PAYG_ENABLED && !unlimitedEnabled;
|
||
const payg = allowOverage ? computePaygSummary(userId, plan, { projectedUsage: projected }) : null;
|
||
|
||
if (unlimitedEnabled) {
|
||
return {
|
||
allowed: true,
|
||
limit: summary.limit,
|
||
used: summary.used,
|
||
remaining: summary.remaining,
|
||
projected,
|
||
requestedTokens: requested,
|
||
payg: null,
|
||
unlimited: true,
|
||
};
|
||
}
|
||
|
||
if (!allowOverage && summary.limit > 0 && projected > summary.limit + grace) {
|
||
return {
|
||
allowed: false,
|
||
reason: 'You have exceeded your token allowance. Upgrade or add a boost.',
|
||
limit: summary.limit,
|
||
used: summary.used,
|
||
remaining: summary.remaining,
|
||
};
|
||
}
|
||
return {
|
||
allowed: true,
|
||
limit: summary.limit,
|
||
used: summary.used,
|
||
remaining: summary.remaining,
|
||
projected,
|
||
requestedTokens: requested,
|
||
payg,
|
||
};
|
||
}
|
||
|
||
function summarizeProviderUsage(provider, model) {
|
||
const key = normalizeUsageProvider(provider, model);
|
||
const now = Date.now();
|
||
const minuteAgo = now - MINUTE_MS;
|
||
const hourAgo = now - HOUR_MS;
|
||
const dayAgo = now - DAY_MS;
|
||
const entries = ensureProviderUsageBucket(key);
|
||
const filterByModel = !!(model && providerLimits.limits[key] && providerLimits.limits[key].scope === 'model');
|
||
const result = {
|
||
tokensLastMinute: 0,
|
||
tokensLastHour: 0,
|
||
tokensLastDay: 0,
|
||
requestsLastMinute: 0,
|
||
requestsLastHour: 0,
|
||
requestsLastDay: 0,
|
||
perModel: {},
|
||
};
|
||
|
||
entries.forEach((entry) => {
|
||
if (!entry || typeof entry.ts !== 'number') return;
|
||
const matchesModel = !filterByModel || (entry.model && model && entry.model === model);
|
||
const targetKey = entry.model || 'unknown';
|
||
const isMinute = entry.ts >= minuteAgo;
|
||
const isHour = entry.ts >= hourAgo;
|
||
const isDay = entry.ts >= dayAgo;
|
||
if (isMinute && matchesModel) {
|
||
result.tokensLastMinute += Number(entry.tokens || 0);
|
||
result.requestsLastMinute += Number(entry.requests || 0);
|
||
}
|
||
if (isHour && matchesModel) {
|
||
result.tokensLastHour += Number(entry.tokens || 0);
|
||
result.requestsLastHour += Number(entry.requests || 0);
|
||
}
|
||
if (isDay && matchesModel) {
|
||
result.tokensLastDay += Number(entry.tokens || 0);
|
||
result.requestsLastDay += Number(entry.requests || 0);
|
||
}
|
||
if (!result.perModel[targetKey]) {
|
||
result.perModel[targetKey] = { tokensLastMinute: 0, tokensLastHour: 0, tokensLastDay: 0, requestsLastMinute: 0, requestsLastHour: 0, requestsLastDay: 0 };
|
||
}
|
||
if (isMinute) {
|
||
result.perModel[targetKey].tokensLastMinute += Number(entry.tokens || 0);
|
||
result.perModel[targetKey].requestsLastMinute += Number(entry.requests || 0);
|
||
}
|
||
if (isHour) {
|
||
result.perModel[targetKey].tokensLastHour += Number(entry.tokens || 0);
|
||
result.perModel[targetKey].requestsLastHour += Number(entry.requests || 0);
|
||
}
|
||
if (isDay) {
|
||
result.perModel[targetKey].tokensLastDay += Number(entry.tokens || 0);
|
||
result.perModel[targetKey].requestsLastDay += Number(entry.requests || 0);
|
||
}
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
function isProviderLimited(provider, model) {
|
||
const key = normalizeUsageProvider(provider, model);
|
||
const cfg = ensureProviderLimitDefaults(key);
|
||
const usage = summarizeProviderUsage(key, model);
|
||
const modelCfg = (cfg.scope === 'model' && model && cfg.perModel[model]) ? cfg.perModel[model] : cfg;
|
||
const checks = [
|
||
['tokensPerMinute', usage.tokensLastMinute, 'minute tokens'],
|
||
['tokensPerHour', usage.tokensLastHour, 'hourly tokens'],
|
||
['tokensPerDay', usage.tokensLastDay, 'daily tokens'],
|
||
['requestsPerMinute', usage.requestsLastMinute, 'minute requests'],
|
||
['requestsPerHour', usage.requestsLastHour, 'hourly requests'],
|
||
['requestsPerDay', usage.requestsLastDay, 'daily requests'],
|
||
];
|
||
for (const [field, used, label] of checks) {
|
||
const limit = sanitizeLimitNumber(modelCfg[field]);
|
||
if (limit > 0 && used >= limit) {
|
||
return { limited: true, reason: `${label} limit reached`, field, used, limit, usage, scope: cfg.scope };
|
||
}
|
||
}
|
||
return { limited: false, usage, scope: cfg.scope };
|
||
}
|
||
|
||
function recordProviderUsage(provider, model, tokens = 0, requests = 1) {
|
||
const key = normalizeUsageProvider(provider, model);
|
||
const isNew = !providerLimits.limits[key];
|
||
ensureProviderLimitDefaults(key);
|
||
if (isNew) {
|
||
if (pendingProviderPersistTimer) clearTimeout(pendingProviderPersistTimer);
|
||
const timer = setTimeout(() => {
|
||
if (pendingProviderPersistTimer !== timer) return;
|
||
pendingProviderPersistTimer = null;
|
||
persistProviderLimits().catch((err) => log('Failed to persist new provider limit defaults', { provider: key, error: String(err) }));
|
||
}, PROVIDER_PERSIST_DEBOUNCE_MS);
|
||
pendingProviderPersistTimer = timer;
|
||
}
|
||
const bucket = ensureProviderUsageBucket(key);
|
||
bucket.push({
|
||
ts: Date.now(),
|
||
tokens: Math.max(0, Math.round(tokens || 0)),
|
||
requests: Math.max(0, Math.round(requests || 0)),
|
||
model: model || '',
|
||
});
|
||
pruneProviderUsage();
|
||
persistProviderUsage().catch((err) => log('Failed to persist provider usage (async)', { error: String(err) }));
|
||
}
|
||
|
||
/**
|
||
* Validates that a token count is reasonable
|
||
* @param {number} tokens - The token count to validate
|
||
* @param {object} context - Context for validation (contentLength, source, etc.)
|
||
* @returns {{valid: boolean, reason?: string, adjustedTokens?: number}}
|
||
*/
|
||
function validateTokenCount(tokens, context = {}) {
|
||
const { contentLength = 0, source = 'unknown' } = context;
|
||
|
||
// Must be a positive number
|
||
if (typeof tokens !== 'number' || !isFinite(tokens)) {
|
||
return { valid: false, reason: `tokens is not a finite number (type: ${typeof tokens}, value: ${tokens})` };
|
||
}
|
||
|
||
if (tokens <= 0) {
|
||
return { valid: false, reason: `tokens is not positive (value: ${tokens})` };
|
||
}
|
||
|
||
// Reasonable maximum: 1M tokens (most models have much lower limits)
|
||
const MAX_REASONABLE_TOKENS = 1000000;
|
||
if (tokens > MAX_REASONABLE_TOKENS) {
|
||
log('⚠️ validateTokenCount: Token count exceeds reasonable maximum', { tokens, max: MAX_REASONABLE_TOKENS, source, contentLength });
|
||
return { valid: false, reason: `tokens exceeds reasonable maximum of ${MAX_REASONABLE_TOKENS}` };
|
||
}
|
||
|
||
// If we have content length, verify tokens are roughly consistent
|
||
// Minimum chars per token is typically 1 (for very dense text), max is typically 10
|
||
if (contentLength > 0) {
|
||
const charsPerToken = contentLength / tokens;
|
||
const MIN_CHARS_PER_TOKEN = 0.5; // Very conservative lower bound
|
||
const MAX_CHARS_PER_TOKEN = 15; // Very conservative upper bound
|
||
|
||
if (charsPerToken < MIN_CHARS_PER_TOKEN) {
|
||
log('⚠️ validateTokenCount: Chars per token too low (suspicious)', {
|
||
tokens,
|
||
contentLength,
|
||
charsPerToken,
|
||
min: MIN_CHARS_PER_TOKEN,
|
||
source
|
||
});
|
||
return {
|
||
valid: false,
|
||
reason: `chars per token (${charsPerToken.toFixed(2)}) is suspiciously low (< ${MIN_CHARS_PER_TOKEN})`
|
||
};
|
||
}
|
||
|
||
if (charsPerToken > MAX_CHARS_PER_TOKEN) {
|
||
log('⚠️ validateTokenCount: Chars per token too high (suspicious)', {
|
||
tokens,
|
||
contentLength,
|
||
charsPerToken,
|
||
max: MAX_CHARS_PER_TOKEN,
|
||
source
|
||
});
|
||
return {
|
||
valid: false,
|
||
reason: `chars per token (${charsPerToken.toFixed(2)}) is suspiciously high (> ${MAX_CHARS_PER_TOKEN})`
|
||
};
|
||
}
|
||
}
|
||
|
||
return { valid: true };
|
||
}
|
||
|
||
function normalizeTokenNumber(value) {
|
||
if (typeof value === 'number' && isFinite(value)) return value;
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const n = Number(value);
|
||
if (isFinite(n)) return n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractTokenUsage(parsed) {
|
||
if (!parsed || typeof parsed !== 'object') return null;
|
||
|
||
const directCandidates = [
|
||
['tokensUsed', parsed.tokensUsed],
|
||
['tokens', parsed.tokens],
|
||
['totalTokens', parsed.totalTokens],
|
||
['total_tokens', parsed.total_tokens],
|
||
['totalTokenCount', parsed.totalTokenCount],
|
||
['tokenCount', parsed.tokenCount],
|
||
['token_count', parsed.token_count],
|
||
['usage.total_tokens', parsed.usage?.total_tokens],
|
||
['usage.totalTokens', parsed.usage?.totalTokens],
|
||
['usage.totalTokenCount', parsed.usage?.totalTokenCount],
|
||
['usage.total', parsed.usage?.total],
|
||
['usage.totalTokensUsed', parsed.usage?.totalTokensUsed],
|
||
['usageMetadata.totalTokenCount', parsed.usageMetadata?.totalTokenCount],
|
||
['tokenUsage.total_tokens', parsed.tokenUsage?.total_tokens],
|
||
['tokenUsage.totalTokens', parsed.tokenUsage?.totalTokens],
|
||
['tokenUsage.totalTokenCount', parsed.tokenUsage?.totalTokenCount],
|
||
];
|
||
|
||
for (const [source, value] of directCandidates) {
|
||
const n = normalizeTokenNumber(value);
|
||
if (n && n > 0) return { tokens: n, source };
|
||
}
|
||
|
||
const usage = parsed.usage || parsed.tokenUsage || parsed.usageMetadata || parsed.usage_metadata || null;
|
||
if (usage) {
|
||
const prompt = normalizeTokenNumber(usage.prompt_tokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.inputTokens ?? usage.inputTokenCount);
|
||
const completion = normalizeTokenNumber(usage.completion_tokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.outputTokens ?? usage.outputTokenCount);
|
||
if ((prompt || 0) + (completion || 0) > 0) {
|
||
return { tokens: (prompt || 0) + (completion || 0), source: 'usage.sum' };
|
||
}
|
||
}
|
||
|
||
const inputTokens = normalizeTokenNumber(parsed.inputTokens || parsed.input_tokens || parsed.prompt_tokens || parsed.promptTokens);
|
||
const outputTokens = normalizeTokenNumber(parsed.outputTokens || parsed.output_tokens || parsed.completion_tokens || parsed.completionTokens);
|
||
if ((inputTokens || 0) + (outputTokens || 0) > 0) {
|
||
return { tokens: (inputTokens || 0) + (outputTokens || 0), source: 'input+output' };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function estimateTokensFromText(text) {
|
||
if (!text) return 0;
|
||
const str = typeof text === 'string' ? text : JSON.stringify(text);
|
||
return Math.max(1, Math.ceil(str.length / AVG_CHARS_PER_TOKEN));
|
||
}
|
||
|
||
function estimateTokensFromMessages(messages = [], reply = '') {
|
||
const combined = []
|
||
.concat((messages || []).map((m) => {
|
||
if (typeof m === 'string') return m;
|
||
if (m && typeof m.content === 'string') return m.content;
|
||
if (m && Array.isArray(m.content)) {
|
||
return m.content.map(part => (part && part.text) || '').join(' ');
|
||
}
|
||
return '';
|
||
}))
|
||
.concat(reply || '')
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const tokens = estimateTokensFromText(combined);
|
||
console.log(`[USAGE] Estimated tokens: ${tokens} from combined length: ${combined.length}`);
|
||
return tokens;
|
||
}
|
||
|
||
function extractTokenUsageFromResult(result, messages, options = {}) {
|
||
const { allowEstimate = true } = options || {};
|
||
console.log('[USAGE] extractTokenUsageFromResult called with:', {
|
||
resultType: typeof result,
|
||
hasRaw: !!(result && result.raw),
|
||
hasUsage: !!(result && (result.usage || (result.raw && result.raw.usage))),
|
||
hasReply: !!(result && result.reply),
|
||
hasMessages: !!messages && messages.length,
|
||
allowEstimate
|
||
});
|
||
|
||
// 1. Try OpenAI/Standard format
|
||
if (result && (result.usage || (result.raw && result.raw.usage))) {
|
||
const usage = result.usage || result.raw.usage;
|
||
const total = usage.total_tokens || usage.total || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0);
|
||
if (total) {
|
||
console.log('[USAGE] ✅ Extracted tokens from OpenAI format:', total);
|
||
return total;
|
||
}
|
||
}
|
||
|
||
// 2. Try Google/Gemini format
|
||
if (result && (result.usageMetadata || (result.raw && result.raw.usageMetadata))) {
|
||
const meta = result.usageMetadata || result.raw.usageMetadata;
|
||
const total = meta.totalTokenCount || (meta.promptTokenCount || 0) + (meta.candidatesTokenCount || 0);
|
||
if (total) {
|
||
console.log('[USAGE] ✅ Extracted tokens from Google format:', total);
|
||
return total;
|
||
}
|
||
}
|
||
|
||
// 3. Fallback to estimation - handle different result structures (OpenCode returns { reply, model, attempts, provider })
|
||
if (!allowEstimate) {
|
||
console.log('[USAGE] ⚠️ Token usage missing and estimation disabled.');
|
||
return 0;
|
||
}
|
||
const replyText = (typeof result === 'string' ? result : (result && typeof result.reply === 'string' ? result.reply : ''));
|
||
const estimated = estimateTokensFromMessages(messages || [], replyText);
|
||
console.log('[USAGE] ⚠️ Using estimation fallback:', { replyLength: replyText.length, estimatedTokens: estimated });
|
||
return estimated;
|
||
}
|
||
|
||
function getProviderUsageSnapshot(providerList = null) {
|
||
const providers = (providerList && providerList.length)
|
||
? providerList.map((p) => normalizeProviderName(p))
|
||
: Object.keys(providerLimits.limits || {});
|
||
return providers.map((provider) => {
|
||
const cfg = ensureProviderLimitDefaults(provider);
|
||
const usage = summarizeProviderUsage(provider);
|
||
return {
|
||
provider,
|
||
scope: cfg.scope,
|
||
limits: {
|
||
tokensPerMinute: cfg.tokensPerMinute,
|
||
tokensPerDay: cfg.tokensPerDay,
|
||
requestsPerMinute: cfg.requestsPerMinute,
|
||
requestsPerDay: cfg.requestsPerDay,
|
||
},
|
||
perModelLimits: cfg.perModel || {},
|
||
usage,
|
||
};
|
||
});
|
||
}
|
||
|
||
function serializeSession(session) {
|
||
// Accuracy fix: detect if 'running' messages have actually stopped
|
||
if (Array.isArray(session.messages)) {
|
||
const now = Date.now();
|
||
session.messages.forEach(msg => {
|
||
if (msg.status === 'running') {
|
||
const isActuallyRunning = runningProcesses.has(msg.id);
|
||
const startedAt = msg.startedAt ? new Date(msg.startedAt).getTime() : 0;
|
||
const runningDuration = now - startedAt;
|
||
|
||
// Only mark as error if there's evidence of an actual error from the opencode server
|
||
// (non-zero exit code or existing error message). Don't mark as error just because
|
||
// it's been running for a while - the process may still be working.
|
||
const hasActualError = (msg.opencodeExitCode && msg.opencodeExitCode !== 0) ||
|
||
(msg.error && msg.error.length > 0);
|
||
if (!isActuallyRunning && startedAt > 0 && runningDuration > 30000 && hasActualError) {
|
||
log('Detecting failed running message in serializeSession', {
|
||
sessionId: session.id,
|
||
messageId: msg.id,
|
||
duration: runningDuration,
|
||
exitCode: msg.opencodeExitCode,
|
||
error: msg.error
|
||
});
|
||
msg.status = 'error';
|
||
if (!msg.error || !msg.error.length) {
|
||
msg.error = 'Message processing seems to have stalled or was interrupted.';
|
||
}
|
||
msg.finishedAt = new Date().toISOString();
|
||
session.updatedAt = msg.finishedAt;
|
||
// Note: we don't call persistState() here to keep this fast,
|
||
// but the next state change will save it.
|
||
}
|
||
}
|
||
});
|
||
}
|
||
return {
|
||
id: session.id,
|
||
title: session.title,
|
||
model: session.model,
|
||
cli: session.cli || 'opencode',
|
||
opencodeSessionId: session.opencodeSessionId,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
messages: session.messages,
|
||
pending: session.pending || 0,
|
||
userId: session.userId,
|
||
appId: session.appId,
|
||
entryMode: session.entryMode || 'plan',
|
||
source: session.source || 'builder',
|
||
planSummary: session.planSummary,
|
||
planUserRequest: session.planUserRequest,
|
||
planApproved: !!session.planApproved,
|
||
pluginVersion: session.pluginVersion,
|
||
pluginVersionHistory: session.pluginVersionHistory || [],
|
||
lastVersionBumpType: session.lastVersionBumpType,
|
||
};
|
||
}
|
||
|
||
async function pathExists(targetPath) {
|
||
try {
|
||
await fs.access(targetPath);
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function safeMkdir(targetPath) {
|
||
await fs.mkdir(targetPath, { recursive: true });
|
||
}
|
||
|
||
async function normalizeIconPath(iconPath) {
|
||
if (!iconPath) return '';
|
||
await ensureAssetsDir();
|
||
const trimmed = iconPath.trim().replace(/^https?:\/\/[^/]+/, '');
|
||
const withoutLeadingSlash = trimmed.replace(/^\/+/, '');
|
||
const relative = withoutLeadingSlash.startsWith('assets/') ? withoutLeadingSlash : `assets/${withoutLeadingSlash}`;
|
||
const resolved = path.resolve(STATIC_ROOT, relative);
|
||
const assetsRoot = path.resolve(ASSETS_DIR);
|
||
if (!resolved.startsWith(assetsRoot)) return '';
|
||
if (!(await pathExists(resolved))) throw new Error('Icon not found in assets folder');
|
||
return `/${relative.replace(/\\/g, '/')}`;
|
||
}
|
||
|
||
async function listAdminIcons() {
|
||
await ensureAssetsDir();
|
||
try {
|
||
const entries = await fs.readdir(ASSETS_DIR, { withFileTypes: true });
|
||
return entries
|
||
.filter((e) => e.isFile())
|
||
.map((e) => e.name)
|
||
.filter((name) => name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i))
|
||
.map((name) => `/assets/${name}`);
|
||
} catch (error) {
|
||
log('Failed to list admin icons', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
async function mergeCopyPath(srcPath, destPath, tag) {
|
||
const srcStat = await fs.stat(srcPath);
|
||
if (srcStat.isDirectory()) {
|
||
await safeMkdir(destPath);
|
||
const entries = await fs.readdir(srcPath);
|
||
for (const entry of entries) {
|
||
await mergeCopyPath(path.join(srcPath, entry), path.join(destPath, entry), tag);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// File: only copy if destination doesn't exist; otherwise preserve both by writing a suffixed copy.
|
||
if (!(await pathExists(destPath))) {
|
||
await safeMkdir(path.dirname(destPath));
|
||
await fs.copyFile(srcPath, destPath);
|
||
return;
|
||
}
|
||
|
||
const ext = path.extname(destPath);
|
||
const base = destPath.slice(0, destPath.length - ext.length);
|
||
const suffixed = `${base}.migrated-${tag}${ext || ''}`;
|
||
await safeMkdir(path.dirname(suffixed));
|
||
await fs.copyFile(srcPath, suffixed);
|
||
}
|
||
|
||
async function migrateUserSessions(fromUserId, toUserId) {
|
||
const from = sanitizeSegment(fromUserId || '', '');
|
||
const to = sanitizeSegment(toUserId || '', '');
|
||
if (!from || !to || from === to) return { moved: 0, skipped: 0 };
|
||
|
||
const tag = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
const sessionsToMove = state.sessions.filter((s) => s.userId === from);
|
||
let moved = 0;
|
||
let skipped = 0;
|
||
|
||
for (const session of sessionsToMove) {
|
||
const oldWorkspaceDir = session.workspaceDir;
|
||
const oldUploadsDir = session.uploadsDir;
|
||
|
||
const targetPaths = buildWorkspacePaths({ userId: to, appId: session.appId || session.id, id: session.id });
|
||
const newWorkspaceDir = targetPaths.workspaceDir;
|
||
const newUploadsDir = targetPaths.uploadsDir;
|
||
|
||
try {
|
||
await safeMkdir(newWorkspaceDir);
|
||
await safeMkdir(newUploadsDir);
|
||
|
||
// Merge-copy old workspace into new workspace, without deleting old.
|
||
if (oldWorkspaceDir && oldWorkspaceDir !== newWorkspaceDir && (await pathExists(oldWorkspaceDir))) {
|
||
await mergeCopyPath(oldWorkspaceDir, newWorkspaceDir, tag);
|
||
}
|
||
if (oldUploadsDir && oldUploadsDir !== newUploadsDir && (await pathExists(oldUploadsDir))) {
|
||
await mergeCopyPath(oldUploadsDir, newUploadsDir, tag);
|
||
}
|
||
|
||
// Only after the disk merge succeeded do we flip ownership.
|
||
session.userId = to;
|
||
session.workspaceDir = newWorkspaceDir;
|
||
session.uploadsDir = newUploadsDir;
|
||
session.attachmentKey = session.attachmentKey || randomUUID();
|
||
|
||
moved += 1;
|
||
} catch (err) {
|
||
skipped += 1;
|
||
log('migration copy failed', { sessionId: session.id, err: String(err), oldWorkspaceDir, newWorkspaceDir });
|
||
}
|
||
}
|
||
|
||
await persistState();
|
||
return { moved, skipped };
|
||
}
|
||
|
||
function getSession(sessionId, userId) {
|
||
const session = state.sessions.find((s) => s.id === sessionId);
|
||
if (!session) return null;
|
||
if (userId && session.userId && session.userId !== userId) return null;
|
||
return session;
|
||
}
|
||
async function createSession(payload = {}, userId, appId) {
|
||
const ownerPlan = resolveUserPlan(userId);
|
||
const model = resolvePlanModel(ownerPlan, payload.model || 'default');
|
||
const cli = normalizeCli(payload.cli);
|
||
const entryMode = payload.entryMode === 'opencode' ? 'opencode' : 'plan';
|
||
const source = payload.source || 'builder';
|
||
const planApproved = payload.planApproved === true;
|
||
const now = new Date().toISOString();
|
||
const sessionId = randomUUID();
|
||
const rawAppId = appId || payload.appId || payload.app;
|
||
const reuseAppId = payload.reuseAppId === true || payload.reuseApp === true;
|
||
const sanitizedAppId = sanitizeSegment(rawAppId || '', '');
|
||
const ownerId = sanitizeSegment(userId || 'anonymous', 'anonymous');
|
||
// Default to the unique session id when no app identifier is provided
|
||
let resolvedAppId = sanitizedAppId || sessionId;
|
||
|
||
// When reusing an existing appId, look up the existing session to preserve its title
|
||
let existingSession = null;
|
||
let sessionTitle = payload.title?.trim() || 'New Chat';
|
||
|
||
if (sanitizedAppId && reuseAppId) {
|
||
existingSession = state.sessions.find((s) => s.userId === ownerId && s.appId === resolvedAppId);
|
||
if (existingSession && existingSession.title) {
|
||
// Preserve the existing app's title
|
||
sessionTitle = existingSession.title;
|
||
}
|
||
}
|
||
|
||
if (sanitizedAppId) {
|
||
const collision = state.sessions.some((s) => s.userId === ownerId && s.appId === resolvedAppId);
|
||
if (collision && !reuseAppId) resolvedAppId = `${resolvedAppId}-${sessionId.slice(0, 8)}`;
|
||
}
|
||
const appLimit = getPlanAppLimit(ownerPlan);
|
||
const existingAppIds = new Set(state.sessions.filter((s) => s.userId === ownerId).map((s) => s.appId));
|
||
const isNewApp = !existingAppIds.has(resolvedAppId);
|
||
const currentAppCount = existingAppIds.size;
|
||
if (isNewApp && Number.isFinite(appLimit) && currentAppCount >= appLimit) {
|
||
const err = new Error(`You have reached the app limit (${appLimit} apps, currently ${currentAppCount}) for your plan. Upgrade to create more apps.`);
|
||
err.statusCode = 403;
|
||
throw err;
|
||
}
|
||
const session = {
|
||
id: sessionId,
|
||
title: sessionTitle,
|
||
model,
|
||
cli,
|
||
userId: ownerId,
|
||
appId: resolvedAppId,
|
||
attachmentKey: randomUUID(),
|
||
opencodeSessionId: null, // Will be initialized when first message is sent
|
||
initialOpencodeSessionId: null, // Will be locked to the first session created for continuity
|
||
entryMode,
|
||
source,
|
||
planApproved,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
messages: [],
|
||
pending: 0,
|
||
// Version tracking for WordPress plugins
|
||
pluginVersion: null, // Will be detected from plugin file on first export
|
||
pluginVersionHistory: [], // Track version changes over time
|
||
lastVersionBumpType: null // 'major', 'minor', 'patch', or 'keep'
|
||
};
|
||
|
||
// WordPress identifies plugins by their folder + main file (plugin basename).
|
||
// If we generate a new slug per session/export, WP treats it as a different plugin and won't upgrade cleanly.
|
||
// Keep the slug stable per appId (and independent of title), so repeated exports upgrade the same plugin.
|
||
const baseSlug = sanitizeSegment(resolvedAppId || payload.title || 'plugin', 'plugin');
|
||
session.pluginSlug = baseSlug.startsWith('pc-') ? baseSlug : `pc-${baseSlug}`;
|
||
// Plugin Name can change safely; keep it stable-ish for UX.
|
||
session.pluginName = payload.title?.trim() ? `Plugin Compass ${payload.title.trim()}` : 'Plugin Compass Plugin';
|
||
|
||
await ensureSessionPaths(session);
|
||
state.sessions.unshift(session);
|
||
|
||
// Track new session/project creation
|
||
trackUserSession(userId, 'project_created', {
|
||
sessionId: session.id,
|
||
appId: session.appId,
|
||
plan: ownerPlan,
|
||
isNewApp: isNewApp
|
||
});
|
||
trackFeatureUsage('project_creation', userId, ownerPlan);
|
||
trackConversionFunnel('app_creation', 'project_created', userId, {
|
||
appId: session.appId,
|
||
plan: ownerPlan,
|
||
isNewApp: isNewApp
|
||
});
|
||
|
||
log('session created', { id: session.id, opencodeSessionId: 'pending', model: session.model, cli: session.cli, userId: session.userId, appId: session.appId, pluginSlug: session.pluginSlug });
|
||
return session;
|
||
}
|
||
function updatePending(sessionId, delta, userId) { const session = getSession(sessionId, userId); if (!session) return; session.pending = Math.max(0, (session.pending || 0) + delta); session.updatedAt = new Date().toISOString(); }
|
||
|
||
async function parseJsonBody(req, maxBytes = MAX_JSON_BODY_SIZE) { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => { body += chunk; if (body.length > maxBytes) { req.socket.destroy(); reject(new Error('Payload too large')); } }); req.on('end', () => { if (!body) return resolve({}); try { const parsed = JSON.parse(body); resolve(parsed); } catch (error) { reject(error); } }); }); }
|
||
|
||
function decodeBase64Payload(raw) {
|
||
if (!raw) return Buffer.alloc(0);
|
||
const trimmed = String(raw).trim();
|
||
const base64 = trimmed.includes(',') ? trimmed.slice(trimmed.indexOf(',') + 1) : trimmed;
|
||
return Buffer.from(base64, 'base64');
|
||
}
|
||
|
||
async function loadExternalTestingSpec(workspaceDir) {
|
||
if (!workspaceDir) return null;
|
||
const specPath = path.join(workspaceDir, 'external-wp-tests.json');
|
||
try {
|
||
const raw = await fs.readFile(specPath, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== 'object') return null;
|
||
return parsed;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function resolvePluginRoot(workspaceDir) {
|
||
if (!workspaceDir) return null;
|
||
const validFiles = [];
|
||
await collectValidFiles(workspaceDir, workspaceDir, validFiles, [
|
||
'node_modules',
|
||
'.git',
|
||
'.data',
|
||
'uploads',
|
||
'*.zip',
|
||
'*.log'
|
||
]);
|
||
|
||
for (const fileInfo of validFiles) {
|
||
if (!fileInfo.fullPath.endsWith('.php')) continue;
|
||
try {
|
||
const content = await fs.readFile(fileInfo.fullPath, 'utf8');
|
||
if (content.includes('Plugin Name:') && content.includes('Plugin URI:')) {
|
||
return path.dirname(fileInfo.fullPath);
|
||
}
|
||
} catch (_) {
|
||
// skip unreadable files
|
||
}
|
||
}
|
||
return workspaceDir;
|
||
}
|
||
|
||
// Basic ZIP signature check: PK\x03\x04 (local header) or PK\x05\x06 (empty archives)
|
||
function isLikelyZip(buffer) {
|
||
if (!buffer || buffer.length < 4) return false;
|
||
const matchesSig = (sig) => buffer.subarray(0, 4).equals(Buffer.from(sig));
|
||
return matchesSig(ZIP_LOCAL_HEADER_SIG) || matchesSig(ZIP_EOCD_EMPTY_SIG);
|
||
}
|
||
|
||
function findCommonRoot(entries) {
|
||
if (!entries || entries.length === 0) return null;
|
||
|
||
let commonPrefix = null;
|
||
|
||
for (const entry of entries) {
|
||
const rawName = (entry.entryName || '').replace(/\\/g, '/');
|
||
const cleaned = rawName.replace(/^\/+/, '');
|
||
|
||
if (!cleaned) continue;
|
||
|
||
const parts = cleaned.split('/');
|
||
const topDir = parts[0];
|
||
|
||
if (!topDir) continue;
|
||
|
||
if (commonPrefix === null) {
|
||
commonPrefix = topDir;
|
||
} else if (commonPrefix !== topDir) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return commonPrefix;
|
||
}
|
||
|
||
async function extractZipToWorkspace(buffer, workspaceDir) {
|
||
const zip = new AdmZip(buffer);
|
||
const entries = zip.getEntries() || [];
|
||
let fileCount = 0;
|
||
const root = path.resolve(workspaceDir);
|
||
|
||
const commonRoot = findCommonRoot(entries);
|
||
|
||
for (const entry of entries) {
|
||
const rawName = (entry.entryName || '').replace(/\\/g, '/');
|
||
const cleaned = rawName.replace(/^\/+/, '');
|
||
|
||
let entryPath = cleaned;
|
||
if (commonRoot && cleaned.startsWith(commonRoot)) {
|
||
entryPath = cleaned.slice(commonRoot.length).replace(/^\/+/, '');
|
||
}
|
||
|
||
const decoded = (() => { try { return decodeURIComponent(entryPath); } catch (_) { return entryPath; } })();
|
||
const normalized = path.normalize(decoded);
|
||
if (!entryPath || normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) continue;
|
||
if (path.isAbsolute(normalized)) continue;
|
||
if (BLOCKED_PATH_PATTERN.test(normalized)) continue;
|
||
|
||
const targetPath = path.join(workspaceDir, normalized);
|
||
const resolved = path.resolve(targetPath);
|
||
if (!resolved.startsWith(root)) continue;
|
||
|
||
if (entry.isDirectory) {
|
||
await fs.mkdir(resolved, { recursive: true });
|
||
continue;
|
||
}
|
||
|
||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||
const data = entry.getData();
|
||
await fs.writeFile(resolved, data);
|
||
fileCount += 1;
|
||
}
|
||
|
||
if (fileCount === 0) {
|
||
throw new Error('ZIP archive contained no valid files (entries may have been blocked)');
|
||
}
|
||
|
||
return fileCount;
|
||
}
|
||
|
||
|
||
function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }
|
||
function serveFile(res, filePath, contentType = 'text/html') { return fs.readFile(filePath).then((content) => { res.writeHead(200, { 'Content-Type': contentType }); res.end(content); }).catch(() => { res.writeHead(404); res.end('Not found'); }); }
|
||
function guessContentType(filePath) { const ext = path.extname(filePath); switch (ext) { case '.css': return 'text/css'; case '.js': return 'application/javascript'; case '.svg': return 'image/svg+xml'; case '.json': return 'application/json'; case '.html': return 'text/html'; default: return 'text/plain'; } }
|
||
function guessContentTypeFromExt(ext) { switch (ext) { case '.css': return 'text/css'; case '.js': return 'application/javascript'; case '.svg': return 'image/svg+xml'; case '.json': return 'application/json'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.gif': return 'image/gif'; default: return 'application/octet-stream'; } }
|
||
function safeStaticPath(relativePath) { const clean = relativePath.replace(/^\/+/, '').replace(/\.\.+/g, ''); const target = path.resolve(STATIC_ROOT, clean || 'index.html'); const root = path.resolve(STATIC_ROOT); if (!target.startsWith(root)) throw new Error('Invalid path'); return target; }
|
||
|
||
function runCommand(command, args, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const spawnOpts = { ...options, stdio: ['ignore', 'pipe', 'pipe'] };
|
||
const child = spawn(command, args, spawnOpts);
|
||
const processId = randomUUID();
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let finished = false;
|
||
|
||
// Track this child process for cleanup
|
||
if (child.pid) {
|
||
registerChildProcess(processId, child.pid, options.sessionId || '', options.messageId || '');
|
||
}
|
||
|
||
const cleanup = () => {
|
||
unregisterChildProcess(processId);
|
||
};
|
||
|
||
const timer = options.timeout
|
||
? setTimeout(() => {
|
||
finished = true;
|
||
cleanup();
|
||
try {
|
||
child.kill('SIGTERM');
|
||
// Force kill after 5 seconds if still running
|
||
setTimeout(() => {
|
||
try { child.kill('SIGKILL'); } catch (_) {}
|
||
}, 5000);
|
||
} catch (ignored) { }
|
||
const err = new Error(`Command timed out after ${options.timeout}ms`);
|
||
err.code = 'TIMEOUT';
|
||
err.stdout = stdout;
|
||
err.stderr = stderr;
|
||
reject(err);
|
||
}, options.timeout)
|
||
: null;
|
||
|
||
child.stdout.on('data', (data) => {
|
||
const chunk = data.toString();
|
||
stdout += chunk;
|
||
if (options.onData) options.onData('stdout', chunk);
|
||
});
|
||
child.stderr.on('data', (data) => {
|
||
const chunk = data.toString();
|
||
stderr += chunk;
|
||
if (options.onData) options.onData('stderr', chunk);
|
||
});
|
||
child.on('error', (error) => {
|
||
if (finished) return;
|
||
if (timer) clearTimeout(timer);
|
||
finished = true;
|
||
cleanup();
|
||
const err = new Error(String(error) || 'Failed to spawn process');
|
||
err.code = error.code || 'spawn_error';
|
||
err.stdout = stdout;
|
||
err.stderr = stderr;
|
||
reject(err);
|
||
});
|
||
child.on('close', (code) => {
|
||
if (finished) return;
|
||
if (timer) clearTimeout(timer);
|
||
finished = true;
|
||
cleanup();
|
||
if (code === 0) return resolve({ stdout, stderr, code });
|
||
const err = new Error(`Command exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`);
|
||
err.code = code;
|
||
err.stdout = stdout;
|
||
err.stderr = stderr;
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// Fetch models from OpenRouter API when API key is configured
|
||
async function fetchOpenRouterModels() {
|
||
// Try paid API key first, then fall back to regular key
|
||
const apiKey = OPENROUTER_PAID_API_KEY || OPENROUTER_API_KEY;
|
||
if (!apiKey) return [];
|
||
try {
|
||
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||
});
|
||
if (!res.ok) {
|
||
log('OpenRouter models fetch failed', { status: res.status });
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.data) ? data.data : [];
|
||
return models.map((m) => ({
|
||
name: m.id || m.name,
|
||
label: `${m.id || m.name} (OpenRouter)`,
|
||
provider: 'openrouter'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('OpenRouter models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch models from Mistral API when API key is configured
|
||
async function fetchMistralModels() {
|
||
if (!MISTRAL_API_KEY) return [];
|
||
try {
|
||
const res = await fetch('https://api.mistral.ai/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${MISTRAL_API_KEY}` }
|
||
});
|
||
if (!res.ok) {
|
||
log('Mistral models fetch failed', { status: res.status });
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.data) ? data.data : [];
|
||
return models.map((m) => ({
|
||
name: m.id || m.name,
|
||
label: `${m.id || m.name} (Mistral)`,
|
||
provider: 'mistral'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('Mistral models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch models from Groq API when API key is configured
|
||
async function fetchGroqModels() {
|
||
if (!GROQ_API_KEY) return [];
|
||
try {
|
||
const res = await fetch('https://api.groq.com/openai/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` }
|
||
});
|
||
if (!res.ok) {
|
||
log('Groq models fetch failed', { status: res.status });
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.data) ? data.data : [];
|
||
return models.map((m) => ({
|
||
name: m.id || m.name,
|
||
label: `${m.id || m.name} (Groq)`,
|
||
provider: 'groq'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('Groq models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch models from Google Gemini API when API key is configured
|
||
async function fetchGoogleModels() {
|
||
if (!GOOGLE_API_KEY) return [];
|
||
try {
|
||
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${GOOGLE_API_KEY}`);
|
||
if (!res.ok) {
|
||
log('Google models fetch failed', { status: res.status });
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.models) ? data.models : [];
|
||
return models.map((m) => ({
|
||
name: m.name || m.id,
|
||
label: `${m.name || m.id} (Google)`,
|
||
provider: 'google'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('Google models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch models from NVIDIA API when API key is configured
|
||
async function fetchNvidiaModels() {
|
||
if (!NVIDIA_API_KEY) return [];
|
||
try {
|
||
const res = await fetch('https://integrate.api.nvidia.com/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${NVIDIA_API_KEY}` }
|
||
});
|
||
if (!res.ok) {
|
||
log('NVIDIA models fetch failed', { status: res.status });
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.data) ? data.data : [];
|
||
return models.map((m) => ({
|
||
name: m.id || m.name,
|
||
label: `${m.id || m.name} (NVIDIA)`,
|
||
provider: 'nvidia'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('NVIDIA models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch models from Chutes API when API key is configured
|
||
async function fetchChutesModels() {
|
||
if (!CHUTES_API_KEY) {
|
||
log('Chutes API key not configured');
|
||
return [];
|
||
}
|
||
try {
|
||
log('Fetching Chutes models', {
|
||
url: `${CHUTES_API_URL}/models`,
|
||
keyPrefix: CHUTES_API_KEY.substring(0, 8) + '...'
|
||
});
|
||
const res = await fetch(`${CHUTES_API_URL}/models`, {
|
||
headers: { 'Authorization': `Bearer ${CHUTES_API_KEY}` }
|
||
});
|
||
if (!res.ok) {
|
||
const errorText = await res.text().catch(() => '');
|
||
log('Chutes models fetch failed', {
|
||
status: res.status,
|
||
statusText: res.statusText,
|
||
error: errorText.substring(0, 200),
|
||
keyPrefix: CHUTES_API_KEY.substring(0, 8) + '...'
|
||
});
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const models = Array.isArray(data?.data) ? data.data : [];
|
||
log('Chutes models fetched successfully', { count: models.length });
|
||
return models.map((m) => ({
|
||
name: m.id || m.name,
|
||
label: `${m.id || m.name} (Chutes)`,
|
||
provider: 'chutes'
|
||
})).filter((m) => m.name);
|
||
} catch (error) {
|
||
log('Chutes models fetch error', { error: String(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Fetch all models for a specific provider from models.dev API
|
||
async function fetchModelsDevProviderModels(providerId) {
|
||
const now = Date.now();
|
||
const cacheKey = providerId.toLowerCase();
|
||
|
||
// Check cache
|
||
if (cachedModelsDevProviders.has(cacheKey)) {
|
||
const cachedAt = cachedModelsDevProvidersAt.get(cacheKey) || 0;
|
||
if (now - cachedAt < MODELS_DEV_CACHE_TTL) {
|
||
log('Using cached models.dev data', { provider: providerId });
|
||
return cachedModelsDevProviders.get(cacheKey);
|
||
}
|
||
}
|
||
|
||
try {
|
||
log('Fetching models from models.dev', { provider: providerId });
|
||
const res = await fetch('https://models.dev/api.json', { timeout: 10000 });
|
||
if (!res.ok) {
|
||
log('models.dev fetch failed', { status: res.status, provider: providerId });
|
||
return [];
|
||
}
|
||
|
||
const data = await res.json();
|
||
const providerData = data[providerId.toLowerCase()];
|
||
|
||
if (!providerData || !providerData.models) {
|
||
log('No models found for provider', { provider: providerId });
|
||
return [];
|
||
}
|
||
|
||
// Convert models.dev format to opencode format
|
||
const models = {};
|
||
for (const [modelId, modelInfo] of Object.entries(providerData.models)) {
|
||
models[modelId] = {
|
||
id: modelId,
|
||
name: modelInfo.name || modelId,
|
||
tool_call: modelInfo.tool_call ?? true,
|
||
temperature: modelInfo.temperature ?? true
|
||
};
|
||
}
|
||
|
||
// Cache the results
|
||
cachedModelsDevProviders.set(cacheKey, models);
|
||
cachedModelsDevProvidersAt.set(cacheKey, now);
|
||
|
||
log('Fetched models from models.dev', {
|
||
provider: providerId,
|
||
count: Object.keys(models).length
|
||
});
|
||
|
||
return models;
|
||
} catch (error) {
|
||
log('models.dev fetch error', { error: String(error), provider: providerId });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function listModels(cliName = 'opencode') {
|
||
const now = Date.now();
|
||
const normalizedCli = normalizeCli(cliName);
|
||
const cacheKey = normalizedCli;
|
||
if (cachedModels.has(cacheKey) && now - (cachedModelsAt.get(cacheKey) || 0) < 60_000) return cachedModels.get(cacheKey);
|
||
|
||
const collected = [];
|
||
function addModel(m) {
|
||
if (!m) return;
|
||
if (typeof m === 'string') collected.push({ name: m, label: m });
|
||
else if (m.name) collected.push({ name: m.name, label: m.label || m.name });
|
||
else if (m.label) collected.push({ name: m.label, label: m.label });
|
||
}
|
||
|
||
const cliCommand = resolveCliCommand(normalizedCli);
|
||
|
||
// Try CLI models (text output format: provider/model per line)
|
||
try {
|
||
const { stdout } = await runCommand(cliCommand, ['models'], { timeout: 15000 });
|
||
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||
const modelLines = lines.filter(l => !l.match(/^(Usage:|Options:|Commands:|Examples?:|Description:|\s*-)/i));
|
||
modelLines.forEach((name) => {
|
||
if (name && name.length > 0 && name.length < 100) {
|
||
addModel({ name, label: name });
|
||
}
|
||
});
|
||
} catch (error) { log('Unable to read models', { cli: normalizedCli, error: String(error) }); }
|
||
|
||
// Try provider and connection commands in parallel
|
||
const providerCmds = [
|
||
[cliCommand, ['providers', '--json']],
|
||
[cliCommand, ['providers', 'list', '--json']],
|
||
[cliCommand, ['connections', '--json']],
|
||
[cliCommand, ['connections', 'list', '--json']],
|
||
[cliCommand, ['providers']],
|
||
[cliCommand, ['connections']],
|
||
];
|
||
|
||
const providerPromises = providerCmds.map(([cmd, args]) =>
|
||
runCommand(cmd, args, { timeout: 8000 })
|
||
.then(({ stdout }) => ({ stdout, cmd, args }))
|
||
.catch(err => ({ err, cmd, args }))
|
||
);
|
||
|
||
const results = await Promise.allSettled(providerPromises);
|
||
|
||
for (const result of results) {
|
||
if (result.status === 'fulfilled' && !result.value.err) {
|
||
const { stdout } = result.value;
|
||
try {
|
||
const parsed = JSON.parse(stdout);
|
||
const arr = Array.isArray(parsed) ? parsed : parsed.providers || parsed.connections || [];
|
||
arr.forEach((p) => {
|
||
if (!p) return;
|
||
const providerName = p.name || p.id || p.provider || normalizedCli;
|
||
if (Array.isArray(p.models) && p.models.length) p.models.forEach((m) => addModel({ name: `${providerName}/${m}`, label: `${providerName}/${m}` }));
|
||
else if (Array.isArray(p.availableModels) && p.availableModels.length) p.availableModels.forEach((m) => addModel({ name: `${providerName}/${m}`, label: `${providerName}/${m}` }));
|
||
else if (p.defaultModel) addModel({ name: `${providerName}/${p.defaultModel}`, label: `${providerName}/${p.defaultModel}` });
|
||
else if (p.model) addModel({ name: `${providerName}/${p.model}`, label: `${providerName}/${p.model}` });
|
||
});
|
||
} catch (parseError) {
|
||
// Fallback for text output
|
||
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||
const contentLines = lines.filter(l => !l.match(/^(Usage:|Options:|Commands:|Examples?:|Description:|\s*-)/i));
|
||
contentLines.forEach((line) => {
|
||
const m = line.split(/[\s:\t-]+/).pop();
|
||
if (m && m.length > 0 && m.length < 100) addModel({ name: m, label: `${m} (connected)` });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fetch models from configured external providers
|
||
const externalProviderFetches = [
|
||
fetchOpenRouterModels(),
|
||
fetchMistralModels(),
|
||
fetchGroqModels(),
|
||
fetchGoogleModels(),
|
||
fetchNvidiaModels(),
|
||
fetchChutesModels()
|
||
];
|
||
|
||
const externalResults = await Promise.allSettled(externalProviderFetches);
|
||
for (const result of externalResults) {
|
||
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
|
||
result.value.forEach((m) => addModel(m));
|
||
}
|
||
}
|
||
|
||
// Add fallback models per CLI
|
||
const fallbackModels = {
|
||
opencode: [],
|
||
};
|
||
|
||
// Add models from OPENCODE_EXTRA_MODELS env var
|
||
if (process.env.OPENCODE_EXTRA_MODELS) {
|
||
const extras = process.env.OPENCODE_EXTRA_MODELS.split(',').map((s) => s.trim()).filter(Boolean);
|
||
extras.forEach((name) => addModel({ name, label: name }));
|
||
}
|
||
|
||
const unique = new Map();
|
||
for (const m of collected) {
|
||
const key = `${normalizedCli}:${encodeURIComponent((m.name || m.label || '').toLowerCase())}`;
|
||
if (!unique.has(key)) unique.set(key, { name: m.name, label: m.label || m.name });
|
||
}
|
||
let result = Array.from(unique.values());
|
||
if (!result.length) result = [{ name: 'default', label: 'default' }];
|
||
cachedModels.set(cacheKey, result);
|
||
cachedModelsAt.set(cacheKey, now);
|
||
return result;
|
||
}
|
||
|
||
// Filter out opencode status messages that aren't meant for the user
|
||
function filterOpencodeStatusMessages(text) {
|
||
if (!text) return '';
|
||
const lines = text.split('\n');
|
||
const filtered = lines.filter(line => {
|
||
const lower = line.toLowerCase();
|
||
// Filter out common status messages
|
||
if (lower.includes('you need to open a file first')) return false;
|
||
if (lower.includes('no file is currently open')) return false;
|
||
if (lower.includes('use /open') && lower.includes('to open a file')) return false;
|
||
if (lower.includes('session created') && lower.includes('ses-')) return false;
|
||
if (lower.match(/^session:\s*ses-/i)) return false;
|
||
if (lower.match(/^model:\s*\w+/i) && line.length < 50) return false;
|
||
return true;
|
||
});
|
||
return filtered.join('\n').trim();
|
||
}
|
||
|
||
// Strip ANSI color codes and stray bracket-only codes and leading pipe prefixes
|
||
function stripAnsiAndPrefixes(text) {
|
||
if (!text) return '';
|
||
// Remove standard ANSI escape sequences like \x1b[93m
|
||
let t = text.replace(/\x1b\[[0-9;]*m/g, '');
|
||
// Remove stray bracket-only sequences like [93m or [0m that may appear when ESC is stripped
|
||
t = t.replace(/\[\d+(?:;\d+)*m/g, '');
|
||
// Remove any remaining ESC characters
|
||
t = t.replace(/\u001b/g, '');
|
||
// Remove leading pipe prefixes on each line (some CLIs prefix lines with "| ")
|
||
t = t.split('\n').map(l => l.replace(/^\s*\|\s*/, '')).join('\n');
|
||
return t;
|
||
}
|
||
|
||
// Detect if output looks like terminal/command output
|
||
function detectOutputType(text) {
|
||
if (!text) return 'text';
|
||
const lines = text.split('\n');
|
||
|
||
// Check for terminal indicators
|
||
const hasCommandPrompt = lines.some(l => l.match(/^\$\s+/) || l.match(/^>\s+/) || l.match(/^#\s+/));
|
||
const hasExitCode = text.match(/exit\s+code:\s*\d+/i);
|
||
const hasShellOutput = lines.some(l => l.match(/^\w+:\s*command not found/i) || l.match(/^\/[\w\/]+/));
|
||
|
||
if (hasCommandPrompt || hasExitCode || hasShellOutput) {
|
||
return 'terminal';
|
||
}
|
||
|
||
// Check for code blocks
|
||
const hasCodeFence = text.includes('```');
|
||
if (hasCodeFence) {
|
||
return 'code';
|
||
}
|
||
|
||
return 'text';
|
||
}
|
||
|
||
function normalizeModels(list) {
|
||
return (list || []).map((m) => (m || '').trim()).filter(Boolean);
|
||
}
|
||
|
||
function resolveOpenRouterModel() {
|
||
const candidates = normalizeModels([
|
||
openrouterSettings.primaryModel,
|
||
openrouterSettings.backupModel1,
|
||
openrouterSettings.backupModel2,
|
||
openrouterSettings.backupModel3,
|
||
...OPENROUTER_FALLBACK_MODELS,
|
||
...OPENROUTER_STATIC_FALLBACK_MODELS,
|
||
]);
|
||
return candidates[0] || OPENROUTER_DEFAULT_MODEL;
|
||
}
|
||
|
||
function resolveMistralModel() {
|
||
const candidates = normalizeModels([
|
||
mistralSettings.primaryModel,
|
||
mistralSettings.backupModel1,
|
||
mistralSettings.backupModel2,
|
||
mistralSettings.backupModel3,
|
||
]);
|
||
return candidates[0] || MISTRAL_DEFAULT_MODEL;
|
||
}
|
||
|
||
function uniqueStrings(values = []) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
(values || []).forEach((val) => {
|
||
const trimmed = (val || '').trim();
|
||
if (trimmed && !seen.has(trimmed)) {
|
||
seen.add(trimmed);
|
||
result.push(trimmed);
|
||
}
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function buildOpenRouterPlanChain() {
|
||
return uniqueStrings([
|
||
openrouterSettings.primaryModel,
|
||
openrouterSettings.backupModel1,
|
||
openrouterSettings.backupModel2,
|
||
openrouterSettings.backupModel3,
|
||
...OPENROUTER_FALLBACK_MODELS,
|
||
...OPENROUTER_STATIC_FALLBACK_MODELS,
|
||
OPENROUTER_DEFAULT_MODEL,
|
||
]);
|
||
}
|
||
|
||
function buildMistralPlanChain() {
|
||
return uniqueStrings([
|
||
mistralSettings.primaryModel,
|
||
mistralSettings.backupModel1,
|
||
mistralSettings.backupModel2,
|
||
mistralSettings.backupModel3,
|
||
MISTRAL_DEFAULT_MODEL,
|
||
]);
|
||
}
|
||
|
||
function buildGroqPlanChain() {
|
||
// Groq uses fast models like Llama 3.3 70B and Mixtral
|
||
return uniqueStrings([
|
||
'llama-3.3-70b-versatile',
|
||
'mixtral-8x7b-32768',
|
||
'llama-3.1-70b-versatile',
|
||
]);
|
||
}
|
||
|
||
function buildGooglePlanChain() {
|
||
// Google Gemini models
|
||
return uniqueStrings([
|
||
'gemini-1.5-flash',
|
||
'gemini-1.5-pro',
|
||
'gemini-pro',
|
||
]);
|
||
}
|
||
|
||
function buildNvidiaPlanChain() {
|
||
// NVIDIA NIM models
|
||
return uniqueStrings([
|
||
'meta/llama-3.1-70b-instruct',
|
||
'meta/llama-3.1-8b-instruct',
|
||
]);
|
||
}
|
||
|
||
function parseModelString(modelString) {
|
||
// Parse a model string like "groq/compound-mini" or "compound-mini" into { provider, model }
|
||
if (!modelString || typeof modelString !== 'string') {
|
||
return { provider: null, model: '' };
|
||
}
|
||
|
||
const trimmed = modelString.trim();
|
||
const parts = trimmed.split('/');
|
||
|
||
if (parts.length > 1 && parts[0] && PLANNING_PROVIDERS.includes(normalizeProviderName(parts[0]))) {
|
||
// Format: "provider/model"
|
||
return {
|
||
provider: normalizeProviderName(parts[0]),
|
||
model: parts.slice(1).join('/')
|
||
};
|
||
}
|
||
|
||
// No provider prefix, return just the model
|
||
return { provider: null, model: trimmed };
|
||
}
|
||
|
||
function buildPlanModelChain() {
|
||
const chain = normalizePlanningChain(planSettings.planningChain);
|
||
if (chain.length) return chain;
|
||
|
||
// Check if freePlanModel has a provider prefix (e.g., "groq/compound-mini")
|
||
const freePlanModel = (planSettings.freePlanModel || '').trim();
|
||
if (freePlanModel) {
|
||
const parsed = parseModelString(freePlanModel);
|
||
if (parsed.provider) {
|
||
// User specified a provider prefix, use it directly
|
||
return [{ provider: parsed.provider, model: parsed.model }];
|
||
}
|
||
}
|
||
|
||
return defaultPlanningChainFromSettings(planSettings.provider);
|
||
}
|
||
|
||
function isPlanProviderConfigured(providerName) {
|
||
const normalized = normalizeProviderName(providerName);
|
||
if (normalized === 'openrouter') return !!OPENROUTER_API_KEY;
|
||
if (normalized === 'mistral') return !!MISTRAL_API_KEY;
|
||
if (normalized === 'google') return !!GOOGLE_API_KEY;
|
||
if (normalized === 'groq') return !!GROQ_API_KEY;
|
||
if (normalized === 'nvidia') return !!NVIDIA_API_KEY;
|
||
if (normalized === 'ollama') return !!(OLLAMA_API_URL || OLLAMA_API_KEY);
|
||
return false;
|
||
}
|
||
|
||
function parseProviderErrorDetail(detailText) {
|
||
if (!detailText) return { text: '', code: '' };
|
||
try {
|
||
const parsed = JSON.parse(detailText);
|
||
const text = parsed?.error?.message || parsed?.message || parsed?.error || detailText;
|
||
const code = parsed?.error?.code || parsed?.code || parsed?.error?.type || '';
|
||
return { text: typeof text === 'string' ? text : JSON.stringify(text), code: typeof code === 'string' ? code : '' };
|
||
} catch (_) {
|
||
return { text: detailText, code: '' };
|
||
}
|
||
}
|
||
|
||
function buildProviderError(provider, status, detailText, codeHint = '') {
|
||
const { text, code } = parseProviderErrorDetail(detailText || '');
|
||
const lower = (text || '').toLowerCase();
|
||
const err = new Error(`${provider} request failed (${status || 'error'}): ${text || 'Unknown error'}`);
|
||
err.provider = provider;
|
||
err.status = status;
|
||
err.detail = text ? text.slice(0, 600) : '';
|
||
err.code = codeHint || code || '';
|
||
err.isAuthError = status === 401 || lower.includes('unauthorized') || lower.includes('api key') || lower.includes('invalid token') || lower.includes('invalid auth');
|
||
err.isBillingError = status === 402 || lower.includes('payment required') || lower.includes('insufficient credit') || lower.includes('insufficient quota') || lower.includes('billing');
|
||
err.isRateLimit = status === 429 || lower.includes('rate limit') || lower.includes('too many requests') || lower.includes('tokens per minute') || lower.includes('tpm');
|
||
err.isTokenLimit = lower.includes('request too large') || lower.includes('token limit') || lower.includes('context length exceeded') || lower.includes('maximum context') || lower.includes('max tokens') || lower.includes('reduce your message size');
|
||
err.isModelMissing = status === 404 || lower.includes('model not found') || lower.includes('unknown model') || lower.includes('does not exist');
|
||
err.isServerError = typeof status === 'number' && status >= 500;
|
||
err.shouldFallback = err.isModelMissing || err.isRateLimit || err.isTokenLimit || err.isServerError || err.isBillingError || err.isAuthError;
|
||
err.rawDetail = detailText;
|
||
return err;
|
||
}
|
||
|
||
function shouldFallbackProviderError(err) {
|
||
return !!(err && err.shouldFallback);
|
||
}
|
||
|
||
async function loadOpenRouterPlanPrompt(userRequest) {
|
||
const sanitizedRequest = sanitizePromptInput(userRequest);
|
||
const fallback = `You are the planning specialist for the WordPress Plugin Builder. Stay in PLAN mode only and never write code.
|
||
|
||
User request:
|
||
{{USER_REQUEST}}
|
||
|
||
Create a concise, actionable plan: key features, WordPress hooks/APIs, data models, UI, security/GDPR, and a numbered roadmap. Ask for approval or changes.`;
|
||
try {
|
||
const prompt = await fs.readFile(OPENROUTER_PLAN_PROMPT_PATH, 'utf8');
|
||
const trimmed = prompt?.trim();
|
||
if (trimmed) {
|
||
// Use a placeholder to prevent template injection
|
||
return trimmed.replace('{{USER_REQUEST}}', sanitizedRequest);
|
||
}
|
||
} catch (err) {
|
||
log('Failed to load OpenRouter plan prompt file', { path: OPENROUTER_PLAN_PROMPT_PATH, err: String(err) });
|
||
}
|
||
return fallback.replace('{{USER_REQUEST}}', sanitizedRequest);
|
||
}
|
||
|
||
async function sendOpenRouterChat({ messages, model }) {
|
||
// Determine which API key to use based on model
|
||
// Premium/paid models use OPENROUTER_PAID_API_KEY
|
||
const isPremiumModel = model && (
|
||
model.includes('gpt-4') ||
|
||
model.includes('claude-3-opus') ||
|
||
model.includes('claude-3-5-sonnet') ||
|
||
model.includes('gemini-1.5-pro') ||
|
||
model.includes('llama-3.1-405b')
|
||
);
|
||
|
||
const apiKey = isPremiumModel && OPENROUTER_PAID_API_KEY
|
||
? OPENROUTER_PAID_API_KEY
|
||
: OPENROUTER_API_KEY;
|
||
|
||
if (!apiKey) {
|
||
log('OpenRouter API key missing, cannot fulfill planning request');
|
||
throw new Error('OpenRouter API key is not configured');
|
||
}
|
||
if (!process.env.OPENROUTER_API_URL && !warnedOpenRouterApiUrl) {
|
||
log('OPENROUTER_API_URL not set; using default OpenRouter endpoint', { url: DEFAULT_OPENROUTER_API_URL });
|
||
warnedOpenRouterApiUrl = true;
|
||
}
|
||
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) throw new Error('OpenRouter messages must be a non-empty array');
|
||
|
||
const payload = { model: model || resolveOpenRouterModel(), messages: safeMessages };
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
};
|
||
if (OPENROUTER_SITE_URL) headers['HTTP-Referer'] = OPENROUTER_SITE_URL;
|
||
headers['X-Title'] = OPENROUTER_APP_NAME;
|
||
const res = await fetch(OPENROUTER_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = await res.text(); } catch (err) {
|
||
log('OpenRouter error body read failed', { status: res.status, err: String(err) });
|
||
}
|
||
const err = buildProviderError('OpenRouter', res.status, detail || res.statusText);
|
||
log('OpenRouter request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
throw err;
|
||
}
|
||
const data = await res.json();
|
||
const reply = data?.choices?.[0]?.message?.content || '';
|
||
return { reply: reply ? String(reply).trim() : '', raw: data };
|
||
}
|
||
|
||
async function sendMistralChat({ messages, model }) {
|
||
if (!MISTRAL_API_KEY) {
|
||
console.error('[MISTRAL] API key missing');
|
||
log('Mistral API key missing, cannot fulfill planning request');
|
||
throw new Error('Mistral API key is not configured');
|
||
}
|
||
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) {
|
||
console.error('[MISTRAL] Empty messages array');
|
||
throw new Error('Mistral messages must be a non-empty array');
|
||
}
|
||
|
||
const resolvedModel = model || resolveMistralModel();
|
||
const payload = { model: resolvedModel, messages: safeMessages };
|
||
|
||
console.log('[MISTRAL] Starting API request', {
|
||
url: MISTRAL_API_URL,
|
||
model: resolvedModel,
|
||
messageCount: safeMessages.length,
|
||
hasApiKey: !!MISTRAL_API_KEY,
|
||
apiKeyPrefix: MISTRAL_API_KEY ? MISTRAL_API_KEY.substring(0, 8) + '...' : 'none'
|
||
});
|
||
|
||
console.log('[MISTRAL] Request payload:', {
|
||
model: payload.model,
|
||
messagesCount: payload.messages.length,
|
||
firstMessage: payload.messages[0] ? {
|
||
role: payload.messages[0].role,
|
||
contentLength: payload.messages[0].content?.length || 0,
|
||
contentPreview: payload.messages[0].content?.substring(0, 100)
|
||
} : null,
|
||
lastMessage: payload.messages[payload.messages.length - 1] ? {
|
||
role: payload.messages[payload.messages.length - 1].role,
|
||
contentLength: payload.messages[payload.messages.length - 1].content?.length || 0,
|
||
contentPreview: payload.messages[payload.messages.length - 1].content?.substring(0, 100)
|
||
} : null
|
||
});
|
||
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||
};
|
||
|
||
try {
|
||
const res = await fetch(MISTRAL_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||
|
||
console.log('[MISTRAL] Response received', {
|
||
status: res.status,
|
||
statusText: res.statusText,
|
||
ok: res.ok,
|
||
headers: Object.fromEntries(res.headers.entries())
|
||
});
|
||
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try {
|
||
detail = await res.text();
|
||
console.error('[MISTRAL] Error response body:', detail);
|
||
} catch (err) {
|
||
console.error('[MISTRAL] Failed to read error body', String(err));
|
||
log('Mistral error body read failed', { status: res.status, err: String(err) });
|
||
}
|
||
const err = buildProviderError('Mistral', res.status, detail || res.statusText);
|
||
console.error('[MISTRAL] Request failed', { status: res.status, detail: err.detail });
|
||
log('Mistral request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
throw err;
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Log the FULL raw response for debugging
|
||
console.log('[MISTRAL] Full API response:', JSON.stringify(data, null, 2));
|
||
|
||
// Log the response data structure analysis
|
||
console.log('[MISTRAL] Response data structure:', {
|
||
hasChoices: !!data?.choices,
|
||
choicesLength: data?.choices?.length || 0,
|
||
firstChoiceKeys: data?.choices?.[0] ? Object.keys(data.choices[0]) : [],
|
||
hasMessage: !!data?.choices?.[0]?.message,
|
||
messageKeys: data?.choices?.[0]?.message ? Object.keys(data.choices[0].message) : [],
|
||
hasContent: !!data?.choices?.[0]?.message?.content,
|
||
contentLength: data?.choices?.[0]?.message?.content?.length || 0,
|
||
rawDataKeys: Object.keys(data || {})
|
||
});
|
||
|
||
// Log each step of content extraction
|
||
console.log('[MISTRAL] Choices array:', data?.choices);
|
||
console.log('[MISTRAL] First choice:', data?.choices?.[0]);
|
||
console.log('[MISTRAL] Message object:', data?.choices?.[0]?.message);
|
||
console.log('[MISTRAL] Content value:', data?.choices?.[0]?.message?.content);
|
||
console.log('[MISTRAL] Content type:', typeof data?.choices?.[0]?.message?.content);
|
||
|
||
const reply = data?.choices?.[0]?.message?.content || '';
|
||
|
||
console.log('[MISTRAL] Extracted reply:', {
|
||
reply: reply,
|
||
replyType: typeof reply,
|
||
replyLength: reply?.length || 0,
|
||
isEmpty: reply === '',
|
||
isNull: reply === null,
|
||
isUndefined: reply === undefined,
|
||
isFalsy: !reply
|
||
});
|
||
|
||
if (!reply) {
|
||
console.error('[MISTRAL] No content in response!', {
|
||
fullData: JSON.stringify(data, null, 2),
|
||
extractedReply: reply,
|
||
replyType: typeof reply
|
||
});
|
||
} else {
|
||
console.log('[MISTRAL] Successfully extracted reply', {
|
||
replyLength: reply.length,
|
||
replyPreview: reply.substring(0, 200)
|
||
});
|
||
}
|
||
|
||
log('Mistral request succeeded', { model: resolvedModel, replyLength: reply.length });
|
||
return { reply: reply ? String(reply).trim() : '', model: resolvedModel, raw: data };
|
||
} catch (fetchErr) {
|
||
console.error('[MISTRAL] Fetch error:', {
|
||
error: String(fetchErr),
|
||
message: fetchErr.message,
|
||
stack: fetchErr.stack
|
||
});
|
||
throw fetchErr;
|
||
}
|
||
}
|
||
|
||
async function sendOpenRouterPlanWithFallback(messages, preferredModel) {
|
||
const chain = preferredModel ? uniqueStrings([preferredModel, ...buildOpenRouterPlanChain()]) : buildOpenRouterPlanChain();
|
||
const attempts = [];
|
||
let lastError = null;
|
||
for (const candidate of chain) {
|
||
try {
|
||
const result = await sendOpenRouterChat({ messages, model: candidate });
|
||
if (attempts.length) {
|
||
log('OpenRouter plan succeeded after fallback', { attempts, model: candidate });
|
||
}
|
||
return { ...result, model: candidate, attempts };
|
||
} catch (err) {
|
||
lastError = err;
|
||
attempts.push({ model: candidate, error: err.message || String(err), status: err.status || null });
|
||
if (!shouldFallbackProviderError(err)) break;
|
||
}
|
||
}
|
||
const err = new Error('OpenRouter plan failed after trying all configured models');
|
||
err.attempts = attempts;
|
||
err.cause = lastError;
|
||
throw err;
|
||
}
|
||
|
||
// Direct Google/Groq/NVIDIA handlers (use provider-specific API if configured; otherwise fall back to OpenRouter)
|
||
async function sendGoogleChat({ messages, model }) {
|
||
if (!GOOGLE_API_KEY) throw new Error('Google API key is not configured');
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) throw new Error('Google messages must be a non-empty array');
|
||
// Attempt typical Gemini-like endpoint; callers can override via GOOGLE_API_URL
|
||
const targetModel = model || 'gemini-alpha';
|
||
const url = `${GOOGLE_API_URL}/models/${encodeURIComponent(targetModel)}:generateMessage`;
|
||
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${GOOGLE_API_KEY}` };
|
||
const payload = { messages: safeMessages };
|
||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = await res.text(); } catch (_) { }
|
||
const err = buildProviderError('Google', res.status, detail || res.statusText);
|
||
log('Google request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
throw err;
|
||
}
|
||
const data = await res.json();
|
||
const reply = data?.candidates?.[0]?.content || data?.output?.[0]?.content || '';
|
||
return { reply: reply ? String(reply).trim() : '', model: targetModel, raw: data };
|
||
}
|
||
|
||
async function sendGroqChat({ messages, model }) {
|
||
if (!GROQ_API_KEY) throw new Error('Groq API key is not configured');
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) throw new Error('Groq messages must be a non-empty array');
|
||
// Use a valid Groq model - llama-3.3-70b-versatile is a good default
|
||
const targetModel = model || 'llama-3.3-70b-versatile';
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${GROQ_API_KEY}`
|
||
};
|
||
// Groq uses OpenAI-compatible API format
|
||
const payload = {
|
||
model: targetModel,
|
||
messages: safeMessages
|
||
};
|
||
|
||
console.log('[GROQ] Starting API request', {
|
||
url: GROQ_API_URL,
|
||
model: targetModel,
|
||
messageCount: safeMessages.length
|
||
});
|
||
|
||
const res = await fetch(GROQ_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||
|
||
console.log('[GROQ] Response received', {
|
||
status: res.status,
|
||
ok: res.ok
|
||
});
|
||
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = await res.text(); } catch (_) { }
|
||
const err = buildProviderError('Groq', res.status, detail || res.statusText);
|
||
log('Groq request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
console.error('[GROQ] Request failed', { status: res.status, detail });
|
||
throw err;
|
||
}
|
||
const data = await res.json();
|
||
|
||
console.log('[GROQ] Response data:', {
|
||
hasChoices: !!data?.choices,
|
||
choicesLength: data?.choices?.length || 0,
|
||
model: data?.model
|
||
});
|
||
|
||
// Extract reply from OpenAI-compatible response format with validation
|
||
if (!data?.choices || !Array.isArray(data.choices) || data.choices.length === 0) {
|
||
console.error('[GROQ] Invalid response structure - no choices array', {
|
||
dataKeys: Object.keys(data || {}),
|
||
hasChoices: !!data?.choices,
|
||
choicesType: typeof data?.choices
|
||
});
|
||
throw new Error('Groq API returned invalid response structure - missing choices array');
|
||
}
|
||
|
||
const reply = data.choices[0]?.message?.content || '';
|
||
|
||
if (!reply) {
|
||
console.error('[GROQ] No content in response', {
|
||
firstChoice: data.choices[0],
|
||
hasMessage: !!data.choices[0]?.message,
|
||
messageKeys: data.choices[0]?.message ? Object.keys(data.choices[0].message) : []
|
||
});
|
||
}
|
||
|
||
console.log('[GROQ] Extracted reply:', {
|
||
replyLength: reply?.length || 0,
|
||
replyPreview: reply ? reply.substring(0, 150) : 'empty'
|
||
});
|
||
|
||
return { reply: reply ? String(reply).trim() : '', model: data?.model || targetModel, raw: data };
|
||
}
|
||
|
||
async function sendNvidiaChat({ messages, model }) {
|
||
if (!NVIDIA_API_KEY) throw new Error('NVIDIA API key is not configured');
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) throw new Error('NVIDIA messages must be a non-empty array');
|
||
const targetModel = model || 'nvidia-model';
|
||
const url = `${NVIDIA_API_URL}/models/${encodeURIComponent(targetModel)}/generate`;
|
||
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${NVIDIA_API_KEY}` };
|
||
const payload = { messages: safeMessages };
|
||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = await res.text(); } catch (_) { }
|
||
const err = buildProviderError('NVIDIA', res.status, detail || res.statusText);
|
||
log('NVIDIA request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
throw err;
|
||
}
|
||
const data = await res.json();
|
||
const reply = data?.output?.text || '';
|
||
return { reply: reply ? String(reply).trim() : '', model: targetModel, raw: data };
|
||
}
|
||
|
||
async function sendOllamaChat({ messages, model }) {
|
||
const urlBase = OLLAMA_API_URL;
|
||
if (!urlBase) throw new Error('Ollama API URL is not configured');
|
||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||
if (!safeMessages.length) throw new Error('Ollama messages must be a non-empty array');
|
||
|
||
// Build a simple prompt by joining roles - Ollama expects a text prompt by default
|
||
const prompt = safeMessages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join('\n\n');
|
||
const targetModel = model || OLLAMA_DEFAULT_MODEL || '';
|
||
const endpoint = `${String(urlBase).replace(/\/$/, '')}/api/generate`;
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
|
||
// Always include Authorization header if API key is configured
|
||
// Some Ollama servers require authentication even if the key is empty
|
||
if (OLLAMA_API_KEY) {
|
||
headers['Authorization'] = `Bearer ${OLLAMA_API_KEY}`;
|
||
} else {
|
||
console.log('[OLLAMA] Warning: No API key configured. Server may reject request.');
|
||
}
|
||
|
||
const payload = { model: targetModel, prompt };
|
||
|
||
const res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(payload),
|
||
signal: AbortSignal.timeout(30000) // 30 second timeout to prevent 504 gateway timeouts
|
||
});
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = await res.text(); } catch (_) { }
|
||
const err = buildProviderError('Ollama', res.status, detail || res.statusText);
|
||
log('Ollama request failed', { status: res.status, detail: err.detail || res.statusText });
|
||
throw err;
|
||
}
|
||
|
||
let data;
|
||
try { data = await res.json(); } catch (_) { data = {}; }
|
||
|
||
// Try common fields returned by Ollama
|
||
const reply = data?.text || data?.generated_text || data?.result || (data?.response?.[0]?.content) || '';
|
||
return { reply: reply ? String(reply).trim() : '', model: targetModel || data?.model || '', raw: data };
|
||
}
|
||
|
||
async function sendMistralPlanWithFallback(messages, preferredModel) {
|
||
const chain = preferredModel ? uniqueStrings([preferredModel, ...buildMistralPlanChain()]) : buildMistralPlanChain();
|
||
console.log('[MISTRAL] Starting fallback chain', {
|
||
preferredModel,
|
||
chainLength: chain.length,
|
||
models: chain
|
||
});
|
||
|
||
const attempts = [];
|
||
let lastError = null;
|
||
for (const candidate of chain) {
|
||
console.log('[MISTRAL] Trying model:', candidate);
|
||
try {
|
||
const result = await sendMistralChat({ messages, model: candidate });
|
||
if (attempts.length) {
|
||
console.log('[MISTRAL] Plan succeeded after fallback', { attempts, model: candidate });
|
||
log('Mistral plan succeeded after fallback', { attempts, model: candidate });
|
||
} else {
|
||
console.log('[MISTRAL] Plan succeeded on first try', { model: candidate });
|
||
}
|
||
return { ...result, model: candidate, attempts };
|
||
} catch (err) {
|
||
lastError = err;
|
||
console.error('[MISTRAL] Model failed:', {
|
||
model: candidate,
|
||
error: err.message || String(err),
|
||
status: err.status || null,
|
||
shouldFallback: shouldFallbackProviderError(err)
|
||
});
|
||
attempts.push({ model: candidate, error: err.message || String(err), status: err.status || null });
|
||
if (!shouldFallbackProviderError(err)) {
|
||
console.error('[MISTRAL] Breaking fallback chain - error not fallback-eligible');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
console.error('[MISTRAL] All models failed', {
|
||
attempts,
|
||
lastError: lastError ? String(lastError) : 'none'
|
||
});
|
||
const err = new Error('Mistral plan failed after trying all configured models');
|
||
err.attempts = attempts;
|
||
err.cause = lastError;
|
||
throw err;
|
||
}
|
||
|
||
|
||
async function handlePlanMessage(req, res, userId) {
|
||
let body;
|
||
try {
|
||
body = await parseJsonBody(req);
|
||
} catch (error) {
|
||
return sendJson(res, 400, { error: 'Invalid JSON body' });
|
||
}
|
||
const sessionId = body.sessionId || body.session || body.session_id;
|
||
if (!sessionId) return sendJson(res, 400, { error: 'sessionId is required' });
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
// Sanitize user input to prevent prompt injection
|
||
const content = sanitizePromptInput(body.content || '');
|
||
const displayContent = typeof body.displayContent === 'string' && body.displayContent.trim() ? sanitizePromptInput(body.displayContent) : content;
|
||
if (!content) return sendJson(res, 400, { error: 'Message is required' });
|
||
const userPlan = resolveUserPlan(session.userId);
|
||
const allowance = canConsumeTokens(session.userId, userPlan, estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER);
|
||
if (!allowance.allowed) {
|
||
return sendJson(res, 402, { error: 'You have reached your token allowance. Upgrade or add a boost.', allowance });
|
||
}
|
||
|
||
try {
|
||
await ensureSessionPaths(session);
|
||
const planRoot = sanitizePromptInput(session.planUserRequest || content);
|
||
const systemPrompt = await loadOpenRouterPlanPrompt(planRoot);
|
||
// Replace plugin slug and name placeholders if present in the planning prompt
|
||
const safePluginSlugRaw = session.pluginSlug || session.title || 'plugin';
|
||
const safePluginSlugBase = sanitizeSegment(safePluginSlugRaw, 'plugin');
|
||
const safePluginSlug = safePluginSlugBase.startsWith('pc-') ? safePluginSlugBase : `pc-${safePluginSlugBase}`;
|
||
const safePluginName = session.pluginName || `Plugin Compass ${session.title || 'Plugin'}`;
|
||
let finalSystemPrompt = (systemPrompt || '').replace('{{PLUGIN_SLUG}}', safePluginSlug);
|
||
finalSystemPrompt = finalSystemPrompt.replace('{{PLUGIN_NAME}}', safePluginName);
|
||
const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan');
|
||
const messages = [{ role: 'system', content: finalSystemPrompt }];
|
||
historyMessages.forEach((m) => {
|
||
if (m.content) messages.push({ role: 'user', content: sanitizePromptInput(m.content) });
|
||
if (m.reply) messages.push({ role: 'assistant', content: sanitizeAiOutput(m.reply) });
|
||
});
|
||
messages.push({ role: 'user', content });
|
||
|
||
let model;
|
||
let reply;
|
||
let cli;
|
||
let failoverAttempts = [];
|
||
let providerUsed = null;
|
||
// Build the normalized plan chain and filter out providers that are not configured
|
||
const normalizedChain = buildPlanModelChain();
|
||
const planChain = normalizedChain.filter((entry) => isPlanProviderConfigured(entry.provider));
|
||
|
||
if (!planChain.length) {
|
||
// If the normalized chain requested providers that are not configured, report which ones
|
||
const requestedProviders = Array.from(new Set((normalizedChain || []).map((e) => normalizeProviderName(e.provider))));
|
||
const missing = requestedProviders.filter((p) => !isPlanProviderConfigured(p));
|
||
console.log('[PLAN] No configured providers found for plan chain', { requestedProviders, missing });
|
||
const msg = missing.length
|
||
? `No configured planning providers available. Missing provider API keys: ${missing.join(', ')}.`
|
||
: 'Planning is not configured. Please configure a planning provider first.';
|
||
return sendJson(res, 503, { error: msg, missingProviders: missing });
|
||
}
|
||
|
||
console.log('[PLAN] Starting plan message handling', {
|
||
sessionId: session.id,
|
||
planChainLength: planChain.length,
|
||
providers: planChain.map(e => `${e.provider}:${e.model || 'default'}`)
|
||
});
|
||
|
||
for (const entry of planChain) {
|
||
const providerName = normalizeProviderName(entry.provider);
|
||
const modelHint = entry.raw || entry.model || '';
|
||
console.log('[PLAN] Trying provider', { provider: providerName, model: modelHint });
|
||
|
||
const limitState = isProviderLimited(providerName, modelHint || 'plan');
|
||
if (limitState.limited) {
|
||
console.log('[PLAN] Provider limited, skipping', { provider: providerName, reason: limitState.reason });
|
||
failoverAttempts.push({ provider: providerName, model: modelHint, error: `limit: ${limitState.reason}` });
|
||
continue;
|
||
}
|
||
try {
|
||
let result;
|
||
if (providerName === 'mistral') {
|
||
console.log('[PLAN] Using Mistral provider', { modelHint, messagesCount: messages.length });
|
||
result = await sendMistralPlanWithFallback(messages, modelHint);
|
||
cli = 'mistral';
|
||
console.log('[PLAN] Mistral result received (raw):', {
|
||
hasResult: !!result,
|
||
resultKeys: result ? Object.keys(result) : [],
|
||
hasReply: !!result?.reply,
|
||
replyValue: result?.reply,
|
||
replyType: typeof result?.reply,
|
||
replyLength: result?.reply?.length || 0,
|
||
model: result?.model,
|
||
fullResult: JSON.stringify(result, null, 2)
|
||
});
|
||
} else if (providerName === 'google') {
|
||
// Google is a separate provider and requires GOOGLE_API_KEY to be configured
|
||
console.log('[PLAN] Using Google provider', { modelHint });
|
||
result = await sendGoogleChat({ messages, model: modelHint });
|
||
cli = 'google';
|
||
} else if (providerName === 'groq') {
|
||
console.log('[PLAN] Using Groq provider', { modelHint });
|
||
result = await sendGroqChat({ messages, model: modelHint });
|
||
cli = 'groq';
|
||
} else if (providerName === 'nvidia') {
|
||
console.log('[PLAN] Using NVIDIA provider', { modelHint });
|
||
result = await sendNvidiaChat({ messages, model: modelHint });
|
||
cli = 'nvidia';
|
||
} else if (providerName === 'ollama') {
|
||
console.log('[PLAN] Using Ollama provider', { modelHint });
|
||
result = await sendOllamaChat({ messages, model: modelHint });
|
||
cli = 'ollama';
|
||
} else {
|
||
// default to OpenRouter if configured (explicit choice)
|
||
console.log('[PLAN] Using OpenRouter provider', { modelHint });
|
||
result = await sendOpenRouterPlanWithFallback(messages, modelHint);
|
||
cli = 'openrouter';
|
||
}
|
||
|
||
model = result.model || modelHint;
|
||
|
||
console.log('[PLAN] Before sanitization:', {
|
||
provider: providerName,
|
||
rawReply: result.reply,
|
||
rawReplyType: typeof result.reply,
|
||
rawReplyLength: result.reply?.length || 0
|
||
});
|
||
|
||
reply = sanitizeAiOutput(result.reply);
|
||
|
||
console.log('[PLAN] After sanitization:', {
|
||
provider: providerName,
|
||
sanitizedReply: reply,
|
||
sanitizedReplyType: typeof reply,
|
||
sanitizedReplyLength: reply?.length || 0,
|
||
wasModified: result.reply !== reply
|
||
});
|
||
|
||
console.log('[PLAN] Provider succeeded', {
|
||
provider: providerName,
|
||
model,
|
||
replyLength: reply?.length || 0,
|
||
replyPreview: reply ? reply.substring(0, 150) : 'no reply'
|
||
});
|
||
|
||
failoverAttempts = failoverAttempts.concat(result.attempts || []);
|
||
providerUsed = providerName;
|
||
const tokensUsed = extractTokenUsageFromResult(result, messages);
|
||
// Ensure we log provider usage
|
||
recordProviderUsage(providerName, model || modelHint || providerName, tokensUsed, 1);
|
||
|
||
const recordId = userId || session.userId;
|
||
if (recordId) {
|
||
console.log(`[PLAN] Recording tokens: user=${recordId} tokens=${tokensUsed} provider=${providerName}`);
|
||
// Await this to ensure file is written before we return response to client
|
||
await recordUserTokens(recordId, tokensUsed);
|
||
} else {
|
||
console.error('[PLAN] Cannot record tokens: no userId available');
|
||
}
|
||
break;
|
||
} catch (err) {
|
||
console.error('[PLAN] Provider failed', {
|
||
provider: providerName,
|
||
model: modelHint,
|
||
error: err.message || String(err),
|
||
status: err.status || null,
|
||
shouldFallback: shouldFallbackProviderError(err)
|
||
});
|
||
failoverAttempts.push({ provider: providerName, model: modelHint, error: err.message || String(err), status: err.status || null });
|
||
if (!shouldFallbackProviderError(err)) {
|
||
console.error('[PLAN] Breaking provider chain - error not fallback-eligible');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!providerUsed) {
|
||
console.error('[PLAN] No provider succeeded', { failoverAttempts });
|
||
return sendJson(res, 429, { error: 'All planning providers are rate limited or unavailable' });
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
|
||
console.log('[PLAN] Creating message object with reply:', {
|
||
reply: reply,
|
||
replyType: typeof reply,
|
||
replyLength: reply?.length || 0,
|
||
replyPreview: reply ? reply.substring(0, 200) : 'empty'
|
||
});
|
||
|
||
const message = {
|
||
id: randomUUID(),
|
||
role: 'user',
|
||
content,
|
||
displayContent,
|
||
model,
|
||
cli,
|
||
status: 'done',
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
finishedAt: now,
|
||
reply,
|
||
phase: 'plan',
|
||
};
|
||
|
||
if (failoverAttempts.length) message.failoverAttempts = failoverAttempts;
|
||
|
||
if (!session.planUserRequest) session.planUserRequest = planRoot;
|
||
const cleanReply = reply && reply.trim ? reply.trim() : reply;
|
||
|
||
console.log('[PLAN] Final message details:', {
|
||
messageId: message.id,
|
||
messageReply: message.reply,
|
||
messageReplyLength: message.reply?.length || 0,
|
||
cleanReply: cleanReply,
|
||
cleanReplyLength: cleanReply?.length || 0,
|
||
planSummary: session.planSummary,
|
||
planSummaryLength: session.planSummary?.length || 0
|
||
});
|
||
|
||
session.planSummary = cleanReply || session.planSummary;
|
||
session.planApproved = false;
|
||
session.messages.push(message);
|
||
session.updatedAt = now;
|
||
await persistState();
|
||
|
||
console.log('[PLAN] Plan message completed successfully', {
|
||
sessionId: session.id,
|
||
provider: providerUsed,
|
||
model,
|
||
replyLength: cleanReply?.length || 0,
|
||
hasFailovers: failoverAttempts.length > 0
|
||
});
|
||
|
||
return sendJson(res, 200, { message, model, planSummary: session.planSummary });
|
||
} catch (error) {
|
||
console.error('[PLAN] Plan message handler error', {
|
||
error: error.message || String(error),
|
||
stack: error.stack,
|
||
attempts: error.attempts
|
||
});
|
||
const attemptInfo = Array.isArray(error.attempts)
|
||
? error.attempts.map((a) => `${a.model || 'unknown'}: ${a.error || 'error'}`).join(' | ')
|
||
: '';
|
||
const message = error.message || 'Failed to process plan message';
|
||
const composed = attemptInfo ? `${message} (${attemptInfo})` : message;
|
||
return sendJson(res, 500, { error: composed });
|
||
}
|
||
}
|
||
|
||
async function sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId }) {
|
||
const clean = sanitizeMessage(content);
|
||
|
||
// Validate content is properly sanitized
|
||
if (clean === null || clean === undefined) {
|
||
throw new Error('Message content failed sanitization');
|
||
}
|
||
|
||
// Convert to string if needed and ensure it's not empty after trimming
|
||
const contentStr = String(clean).trim();
|
||
if (!contentStr) {
|
||
throw new Error('Message cannot be empty after sanitization');
|
||
}
|
||
if (!session) throw new Error('Session is required for OpenCode commands');
|
||
await ensureSessionPaths(session);
|
||
if (!session.workspaceDir) throw new Error('Session workspace directory not initialized');
|
||
const workspaceDir = session.workspaceDir;
|
||
const cliName = normalizeCli(cli || session?.cli);
|
||
const cliCommand = resolveCliCommand(cliName);
|
||
verifyOpencodeSetup(cliCommand);
|
||
|
||
// Ensure model is properly resolved
|
||
const resolvedModel = model || session.model;
|
||
if (!resolvedModel) {
|
||
throw new Error('Model is required for OpenCode commands');
|
||
}
|
||
|
||
const args = ['run', '--format', 'json', '--model', resolvedModel];
|
||
|
||
// Add session ID if available
|
||
if (opencodeSessionId) {
|
||
args.push('--session', opencodeSessionId);
|
||
}
|
||
|
||
// Configure WP CLI Testing MCP server if feature is enabled
|
||
const wpCliMcpEnabled = message?.externalTestingEnabled === true;
|
||
log('WP CLI Testing MCP server configuration', {
|
||
enabled: wpCliMcpEnabled,
|
||
messageId: message?.id,
|
||
sessionId: session.id
|
||
});
|
||
|
||
// Ensure content is properly passed as the final argument
|
||
if (typeof clean !== 'string' || clean.length === 0) {
|
||
throw new Error('Message content is invalid or empty after sanitization');
|
||
}
|
||
|
||
args.push(clean);
|
||
|
||
log('Preparing OpenCode CLI command', {
|
||
cli: cliName,
|
||
cmd: cliCommand,
|
||
model: resolvedModel,
|
||
sessionId: session.id,
|
||
messageId: message?.id,
|
||
contentLength: clean.length,
|
||
contentPreview: clean.substring(0, 100).replace(/\n/g, '\\n')
|
||
});
|
||
let partialOutput = '';
|
||
let lastStreamTime = Date.now();
|
||
const messageKey = message?.id;
|
||
let sessionsBefore = null;
|
||
|
||
try {
|
||
log('Running CLI', { cli: cliName, cmd: cliCommand, args: args.slice(0, -1).concat(['[content]']), messageId: messageKey, workspaceDir, opencodeSessionId });
|
||
|
||
if (!opencodeSessionId) {
|
||
try {
|
||
sessionsBefore = await listOpencodeSessions(workspaceDir);
|
||
} catch (err) {
|
||
log('Failed to list opencode sessions before run', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
// Verify CLI command exists before attempting to run
|
||
try {
|
||
fsSync.accessSync(cliCommand, fsSync.constants.X_OK);
|
||
log('OpenCode CLI verified', { cliCommand });
|
||
} catch (cliError) {
|
||
log('OpenCode CLI not found or not executable', { cliCommand, error: String(cliError) });
|
||
throw new Error(`OpenCode CLI not found or not executable: ${cliCommand}. Please ensure OpenCode is properly installed.`);
|
||
}
|
||
|
||
// Log the full command that will be executed
|
||
const fullCommandStr = `${cliCommand} ${args.map(arg => {
|
||
const str = String(arg);
|
||
if (str.includes(' ') || str.includes('"') || str.includes("'") || str.includes('\\')) {
|
||
return `"${str.replace(/"/g, '\\"')}"`;
|
||
}
|
||
return str;
|
||
}).join(' ')}`;
|
||
|
||
log('Executing OpenCode command', {
|
||
command: fullCommandStr,
|
||
workspaceDir,
|
||
messageId: messageKey
|
||
});
|
||
|
||
// Mark process as running - this allows tracking even if SSE stream closes
|
||
// The process will continue running independently of the HTTP connection
|
||
if (messageKey) {
|
||
runningProcesses.set(messageKey, { started: Date.now(), cli: cliName, model });
|
||
}
|
||
|
||
let capturedSessionId = null;
|
||
|
||
// Use the OpenCode process manager for execution
|
||
// This ensures all sessions share the same OpenCode instance when possible
|
||
|
||
// Build environment variables including WP CLI MCP server config
|
||
const executionEnv = {
|
||
...process.env,
|
||
OPENAI_API_KEY: OPENCODE_OLLAMA_API_KEY,
|
||
CHUTES_API_KEY: CHUTES_API_KEY,
|
||
GROQ_API_KEY: GROQ_API_KEY,
|
||
GOOGLE_API_KEY: GOOGLE_API_KEY,
|
||
NVIDIA_API_KEY: NVIDIA_API_KEY,
|
||
};
|
||
|
||
// Add WP CLI Testing MCP server to OpenCode config if enabled
|
||
if (wpCliMcpEnabled) {
|
||
const wpMcpServerPath = path.resolve(__dirname, '../opencode/mcp-servers/wp-cli-testing/index.js');
|
||
log('Enabling WP CLI Testing MCP server', { path: wpMcpServerPath, messageId: message?.id });
|
||
|
||
// Configure OpenCode to include the WP CLI testing MCP server
|
||
// We pass this via environment variable that OpenCode can read
|
||
executionEnv.OPENCODE_EXTRA_MCP_SERVERS = JSON.stringify([
|
||
{
|
||
name: 'wp-cli-testing',
|
||
command: 'node',
|
||
args: [wpMcpServerPath],
|
||
disabled: false
|
||
}
|
||
]);
|
||
} else {
|
||
// Safety: ensure the tools are not injected when the builder toggle is off,
|
||
// even if the parent process environment happens to have the variable set.
|
||
if (executionEnv.OPENCODE_EXTRA_MCP_SERVERS) {
|
||
delete executionEnv.OPENCODE_EXTRA_MCP_SERVERS;
|
||
}
|
||
}
|
||
|
||
const { stdout, stderr } = await opencodeManager.executeInSession(
|
||
session?.id || 'standalone',
|
||
workspaceDir,
|
||
cliCommand,
|
||
args,
|
||
{
|
||
timeout: 600000, // 10 minute timeout to prevent stuck processes
|
||
env: executionEnv,
|
||
onData: (type, chunk) => {
|
||
try {
|
||
const now = Date.now();
|
||
const chunkStr = chunk.toString();
|
||
|
||
// Split chunk into lines (since output is newline-delimited JSON)
|
||
const lines = chunkStr.split('\n');
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
|
||
// Parse JSON line
|
||
try {
|
||
const event = JSON.parse(line);
|
||
|
||
// Capture session ID from any event that has it
|
||
// CRITICAL FIX: Only update session ID if we don't already have an explicit one
|
||
// This prevents overwriting the passed opencodeSessionId with a new auto-generated one
|
||
if (!capturedSessionId && event.sessionID) {
|
||
capturedSessionId = event.sessionID;
|
||
log('Captured session ID from JSON event', { capturedSessionId, messageType: event.type, messageId: messageKey, existingOpencodeSessionId: opencodeSessionId });
|
||
if (session) {
|
||
// Only update session.opencodeSessionId if no explicit session was passed
|
||
// This ensures we don't overwrite the intended session ID with an auto-generated one
|
||
if (!opencodeSessionId || !session.opencodeSessionId) {
|
||
session.opencodeSessionId = capturedSessionId;
|
||
// Lock this as the initial session if not already set
|
||
if (!session.initialOpencodeSessionId) {
|
||
session.initialOpencodeSessionId = capturedSessionId;
|
||
log('Locked initial opencode session', { initialOpencodeSessionId: capturedSessionId, sessionId: session.id });
|
||
}
|
||
persistState().catch(() => { });
|
||
} else {
|
||
log('Preserving existing opencode session ID (not overwriting with captured)', {
|
||
existing: session.opencodeSessionId || opencodeSessionId,
|
||
captured: capturedSessionId
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract token usage from step_finish events
|
||
if (event.type === 'step_finish' && event.part?.tokens) {
|
||
const inputTokens = event.part.tokens.input || 0;
|
||
const outputTokens = event.part.tokens.output || 0;
|
||
const reasoningTokens = event.part.tokens.reasoning || 0;
|
||
const totalTokens = inputTokens + outputTokens + reasoningTokens;
|
||
|
||
if (totalTokens > 0) {
|
||
// Accumulate tokens if we've already captured some
|
||
if (message.opencodeTokensUsed) {
|
||
message.opencodeTokensUsed += totalTokens;
|
||
} else {
|
||
message.opencodeTokensUsed = totalTokens;
|
||
}
|
||
log('Captured token usage from step_finish event', {
|
||
inputTokens,
|
||
outputTokens,
|
||
reasoningTokens,
|
||
totalTokens,
|
||
accumulatedTokens: message.opencodeTokensUsed,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
}
|
||
|
||
// Capture todo tool events
|
||
if (event.type === 'tool' && event.tool === 'todowrite' && event.input?.todos) {
|
||
const todos = event.input.todos;
|
||
if (message) {
|
||
message.todos = todos;
|
||
log('Captured todos from todowrite tool', {
|
||
messageId: messageKey,
|
||
todoCount: todos.length,
|
||
todos: todos.map(t => ({ id: t.id, content: t.content?.substring(0, 50), status: t.status }))
|
||
});
|
||
|
||
// Broadcast todos to SSE clients
|
||
if (messageKey && activeStreams.has(messageKey)) {
|
||
const streams = activeStreams.get(messageKey);
|
||
const data = JSON.stringify({
|
||
type: 'todos',
|
||
todos: todos,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
streams.forEach(res => {
|
||
try {
|
||
res.write(`data: ${data}\n\n`);
|
||
} catch (err) {
|
||
log('SSE todos write error', { err: String(err) });
|
||
}
|
||
});
|
||
}
|
||
persistState().catch(() => { });
|
||
}
|
||
}
|
||
|
||
if (event.type === 'todo.updated' && event.properties?.todos) {
|
||
const todos = event.properties.todos;
|
||
if (message) {
|
||
message.todos = todos;
|
||
log('Captured todos from todo.updated event', {
|
||
messageId: messageKey,
|
||
todoCount: todos.length,
|
||
todos: todos.map(t => ({ id: t.id, content: t.content?.substring(0, 50), status: t.status }))
|
||
});
|
||
|
||
if (messageKey && activeStreams.has(messageKey)) {
|
||
const streams = activeStreams.get(messageKey);
|
||
const data = JSON.stringify({
|
||
type: 'todos',
|
||
todos: todos,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
streams.forEach(res => {
|
||
try {
|
||
res.write(`data: ${data}\n\n`);
|
||
} catch (err) {
|
||
log('SSE todos write error', { err: String(err) });
|
||
}
|
||
});
|
||
}
|
||
persistState().catch(() => { });
|
||
}
|
||
}
|
||
|
||
// Extract text from text events
|
||
if (event.type === 'text' && event.part?.text) {
|
||
partialOutput += event.part.text;
|
||
|
||
// Filter out status messages
|
||
const filtered = filterOpencodeStatusMessages(partialOutput);
|
||
|
||
if (message) {
|
||
message.partialOutput = filtered;
|
||
message.outputType = detectOutputType(filtered);
|
||
message.partialUpdatedAt = new Date().toISOString();
|
||
|
||
// Only persist every 500ms to avoid excessive writes
|
||
if (now - lastStreamTime > 500) {
|
||
persistState().catch(() => { });
|
||
lastStreamTime = now;
|
||
}
|
||
}
|
||
|
||
// Stream to clients immediately
|
||
const cleanChunk = event.part.text;
|
||
if (streamCallback) {
|
||
streamCallback({
|
||
type: 'chunk',
|
||
content: cleanChunk,
|
||
filtered: filtered,
|
||
outputType: message?.outputType,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
}
|
||
|
||
// Broadcast to SSE clients
|
||
if (messageKey && activeStreams.has(messageKey)) {
|
||
const streams = activeStreams.get(messageKey);
|
||
const data = JSON.stringify({
|
||
type: 'chunk',
|
||
content: cleanChunk,
|
||
filtered: filtered,
|
||
outputType: message?.outputType,
|
||
partialOutput: filtered,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
streams.forEach(res => {
|
||
try {
|
||
res.write(`data: ${data}\n\n`);
|
||
} catch (err) {
|
||
log('SSE write error', { err: String(err) });
|
||
}
|
||
});
|
||
}
|
||
|
||
log('cli chunk', { cli: cliName, type: 'text', messageId: messageKey, length: cleanChunk.length, filtered: filtered.slice(0, 100) });
|
||
}
|
||
} catch (jsonErr) {
|
||
// Line is not valid JSON - might be partial line or error output
|
||
log('Failed to parse JSON line', { line: line.substring(0, 200), error: String(jsonErr), messageId: messageKey });
|
||
}
|
||
}
|
||
} catch (err) {
|
||
log('onData handler error', { err: String(err) });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Process complete
|
||
if (messageKey) {
|
||
runningProcesses.delete(messageKey);
|
||
}
|
||
|
||
// Use accumulated text output (partialOutput) since we're using --format json
|
||
const finalOutput = filterOpencodeStatusMessages(partialOutput || '');
|
||
|
||
// Check for completion signal and process it
|
||
const completionMatch = finalOutput.match(/\s*[COMPLETE]\s*$/i);
|
||
let processedOutput = finalOutput;
|
||
if (completionMatch) {
|
||
// Remove the completion signal from the output
|
||
processedOutput = finalOutput.slice(0, completionMatch.index).trim();
|
||
log('OpenCode completion signal detected', {
|
||
messageId: messageKey,
|
||
hadCompletionSignal: true,
|
||
originalLength: finalOutput.length,
|
||
processedLength: processedOutput.length
|
||
});
|
||
}
|
||
|
||
// Mark message as done to prevent false "stalled" detection
|
||
if (message) {
|
||
message.status = 'done';
|
||
message.finishedAt = new Date().toISOString();
|
||
if (session) {
|
||
session.updatedAt = message.finishedAt;
|
||
}
|
||
if (message.todos && Array.isArray(message.todos) && message.todos.length) {
|
||
message.todos = [];
|
||
}
|
||
}
|
||
|
||
// The reply is the final accumulated output
|
||
let reply = processedOutput;
|
||
|
||
if (message) {
|
||
message.partialOutput = finalOutput;
|
||
message.reply = reply; // Set reply on message object before sending completion event
|
||
message.outputType = detectOutputType(finalOutput);
|
||
message.partialUpdatedAt = new Date().toISOString();
|
||
message.opencodeExitCode = 0;
|
||
message.opencodeSummary = finalOutput.slice(0, 800) || `No output from ${cliName} (exit 0)`;
|
||
persistState().catch(() => { });
|
||
}
|
||
|
||
// Send completion event to SSE clients with processed reply
|
||
if (messageKey && activeStreams.has(messageKey)) {
|
||
const streams = activeStreams.get(messageKey);
|
||
const data = JSON.stringify({
|
||
type: 'complete',
|
||
content: reply, // Send the processed reply instead of raw finalOutput
|
||
outputType: message?.outputType,
|
||
exitCode: 0,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
streams.forEach(res => {
|
||
try {
|
||
res.write(`data: ${data}\n\n`);
|
||
res.end();
|
||
} catch (err) {
|
||
log('SSE completion error', { err: String(err) });
|
||
}
|
||
});
|
||
activeStreams.delete(messageKey);
|
||
}
|
||
|
||
log('cli finished', { cli: cliName, messageId: messageKey, outputLength: finalOutput.length, replyLength: reply.length });
|
||
|
||
if (!opencodeSessionId && session && !session.opencodeSessionId) {
|
||
try {
|
||
const sessionsAfter = await listOpencodeSessions(workspaceDir);
|
||
const beforeIds = new Set((sessionsBefore || []).map((s) => s.id));
|
||
const newSessions = sessionsAfter.filter((s) => s.id && !beforeIds.has(s.id));
|
||
const candidates = newSessions.length ? newSessions : sessionsAfter;
|
||
|
||
if (candidates.length) {
|
||
const sorted = candidates.slice().sort((a, b) => {
|
||
const aTime = Date.parse(a.updatedAt || a.createdAt || '') || 0;
|
||
const bTime = Date.parse(b.updatedAt || b.createdAt || '') || 0;
|
||
return bTime - aTime;
|
||
});
|
||
const detected = sorted[0]?.id || null;
|
||
if (detected) {
|
||
session.opencodeSessionId = detected;
|
||
// CRITICAL: Only set initialOpencodeSessionId if not already set
|
||
// This prevents overwriting an existing initial session with a new one
|
||
if (!session.initialOpencodeSessionId) {
|
||
session.initialOpencodeSessionId = detected;
|
||
log('Recovered and locked opencode session ID from session list', { detected, messageId: messageKey });
|
||
} else {
|
||
log('Recovered opencode session ID but preserving existing initial session', {
|
||
detected,
|
||
initialSessionId: session.initialOpencodeSessionId,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
opencodeSessionId = session.initialOpencodeSessionId || detected;
|
||
persistState().catch(() => { });
|
||
}
|
||
}
|
||
} catch (err) {
|
||
log('Failed to recover opencode session ID from session list', { error: String(err), messageId: messageKey });
|
||
}
|
||
} else if (!opencodeSessionId && session && session.opencodeSessionId) {
|
||
// No explicit session was passed but session has one - log for continuity verification
|
||
log('Using stored opencode session ID (no explicit session in request)', {
|
||
sessionId: session.id,
|
||
opencodeSessionId: session.opencodeSessionId,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
|
||
// Extract token usage from the parsed response if available
|
||
let tokensUsed = 0;
|
||
let tokenSource = 'none';
|
||
const tokenExtractionLog = [];
|
||
|
||
// First, check if we captured token usage from the stream (JSON events)
|
||
if (message && message.opencodeTokensUsed) {
|
||
const candidateTokens = message.opencodeTokensUsed;
|
||
const validation = validateTokenCount(candidateTokens, {
|
||
contentLength: finalOutput?.length || 0,
|
||
source: 'stream'
|
||
});
|
||
|
||
if (validation.valid) {
|
||
tokensUsed = candidateTokens;
|
||
tokenSource = 'stream';
|
||
tokenExtractionLog.push({ method: 'stream', success: true, value: tokensUsed, validation: 'passed' });
|
||
log('✓ Token extraction: Using token usage captured from stream', { tokensUsed, messageId: messageKey });
|
||
} else {
|
||
tokenExtractionLog.push({
|
||
method: 'stream',
|
||
success: false,
|
||
value: candidateTokens,
|
||
validation: 'failed',
|
||
validationReason: validation.reason
|
||
});
|
||
log('✗ Token extraction: Stream tokens failed validation', {
|
||
tokens: candidateTokens,
|
||
reason: validation.reason,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
} else if (message) {
|
||
tokenExtractionLog.push({ method: 'stream', success: false, reason: 'message.opencodeTokensUsed not set during streaming' });
|
||
}
|
||
|
||
// If no tokens found in response, try to get from session
|
||
if (!tokensUsed && capturedSessionId && workspaceDir) {
|
||
try {
|
||
tokenExtractionLog.push({ method: 'session_query', attempt: 'starting', sessionId: capturedSessionId });
|
||
const sessionTokens = await getOpencodeSessionTokenUsage(capturedSessionId, workspaceDir);
|
||
if (sessionTokens > 0) {
|
||
const validation = validateTokenCount(sessionTokens, {
|
||
contentLength: finalOutput?.length || 0,
|
||
source: 'session'
|
||
});
|
||
|
||
if (validation.valid) {
|
||
tokensUsed = sessionTokens;
|
||
tokenSource = 'session';
|
||
tokenExtractionLog.push({ method: 'session_query', success: true, value: tokensUsed, validation: 'passed' });
|
||
log('✓ Token extraction: Got tokens from session info', { opencodeSessionId: capturedSessionId, tokensUsed, messageId: messageKey });
|
||
} else {
|
||
tokenExtractionLog.push({
|
||
method: 'session_query',
|
||
success: false,
|
||
value: sessionTokens,
|
||
validation: 'failed',
|
||
validationReason: validation.reason
|
||
});
|
||
log('✗ Token extraction: Session tokens failed validation', {
|
||
opencodeSessionId: capturedSessionId,
|
||
tokens: sessionTokens,
|
||
reason: validation.reason,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
} else {
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, reason: 'session query returned 0 tokens' });
|
||
log('✗ Token extraction: Session query returned 0 tokens', { opencodeSessionId: capturedSessionId, messageId: messageKey });
|
||
}
|
||
} catch (sessionErr) {
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, error: String(sessionErr) });
|
||
log('✗ Token extraction: Failed to get session token usage', { opencodeSessionId: capturedSessionId, error: String(sessionErr), messageId: messageKey });
|
||
}
|
||
} else if (!tokensUsed) {
|
||
const reason = !capturedSessionId ? 'no capturedSessionId' : !workspaceDir ? 'no workspaceDir' : 'unknown';
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, reason });
|
||
}
|
||
|
||
// Log full extraction summary
|
||
if (!tokensUsed) {
|
||
log('⚠️ Token extraction: All methods failed, will fall back to estimation', {
|
||
messageId: messageKey,
|
||
extractionLog: tokenExtractionLog,
|
||
hadStream: !!message?.opencodeTokensUsed,
|
||
hadOutput: !!finalOutput,
|
||
hadSessionId: !!capturedSessionId
|
||
});
|
||
} else {
|
||
log('✅ Token extraction successful', {
|
||
messageId: messageKey,
|
||
tokensUsed,
|
||
tokenSource,
|
||
extractionLog: tokenExtractionLog
|
||
});
|
||
}
|
||
|
||
return { reply, raw: null, tokensUsed, tokenSource, tokenExtractionLog };
|
||
} catch (error) {
|
||
// Process failed
|
||
if (messageKey) {
|
||
runningProcesses.delete(messageKey);
|
||
}
|
||
|
||
// Try to parse error from JSON format
|
||
let errorOutput = '';
|
||
const errorStr = error.stderr || error.stdout || '';
|
||
const errorLines = errorStr.split('\n');
|
||
|
||
for (const line of errorLines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const event = JSON.parse(line);
|
||
if (event.type === 'error' && event.error) {
|
||
errorOutput += event.error.data?.message || event.error.message || JSON.stringify(event.error);
|
||
} else if (event.type === 'text' && event.part?.text) {
|
||
errorOutput += event.part.text;
|
||
}
|
||
} catch (_) {
|
||
// Not JSON, include as-is
|
||
errorOutput += line + '\n';
|
||
}
|
||
}
|
||
|
||
if (!errorOutput) {
|
||
errorOutput = filterOpencodeStatusMessages(stripAnsiAndPrefixes(errorStr));
|
||
}
|
||
|
||
const msg = `${cliName} failed: ${String(error.message || error)}${errorOutput ? `\n${errorOutput}` : ''}`;
|
||
log('cli error', {
|
||
cli: cliName,
|
||
cmd: `${cliCommand} ${args.join(' ')}`,
|
||
code: error.code || 'error',
|
||
messageId: messageKey,
|
||
errorDetails: errorOutput.substring(0, 200)
|
||
});
|
||
|
||
const err = new Error(msg);
|
||
err.code = error.code || 'error';
|
||
err.stderr = error.stderr;
|
||
err.stdout = error.stdout;
|
||
|
||
const isEarlyTerminated = isEarlyTerminationError(error, error.stderr, error.stdout);
|
||
if (isEarlyTerminated) {
|
||
err.shouldFallback = true;
|
||
err.earlyTermination = true;
|
||
log('Early session termination detected', {
|
||
error: error.message,
|
||
pattern: 'early termination indicator',
|
||
sessionId: opencodeSessionId,
|
||
messageId: messageKey
|
||
});
|
||
}
|
||
|
||
if (message) {
|
||
message.opencodeExitCode = err.code;
|
||
message.opencodeSummary = errorOutput.slice(0, 800) || String(error.message || error).slice(0, 800);
|
||
message.outputType = detectOutputType(errorOutput);
|
||
message.partialUpdatedAt = new Date().toISOString();
|
||
persistState().catch(() => { });
|
||
}
|
||
|
||
// Send error event to SSE clients
|
||
if (messageKey && activeStreams.has(messageKey)) {
|
||
const streams = activeStreams.get(messageKey);
|
||
const data = JSON.stringify({
|
||
type: 'error',
|
||
error: msg,
|
||
content: errorOutput || '', // Include error output as content for the client
|
||
code: err.code,
|
||
exitCode: err.code,
|
||
outputType: message?.outputType,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
streams.forEach(res => {
|
||
try {
|
||
res.write(`data: ${data}\n\n`);
|
||
res.end();
|
||
} catch (err) {
|
||
log('SSE error event error', { err: String(err) });
|
||
}
|
||
});
|
||
activeStreams.delete(messageKey);
|
||
}
|
||
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function isEarlyTerminationError(error, stderr, stdout) {
|
||
const errorOutput = (stderr || '').toLowerCase();
|
||
const combinedOutput = `${errorOutput} ${(stdout || '').toLowerCase()}`;
|
||
|
||
// Exclude warnings and info messages - these are not termination failures
|
||
const isWarningOrInfo = /(warn|warning|info|notice)/i.test(errorOutput);
|
||
if (isWarningOrInfo) return false;
|
||
|
||
// Only trigger on specific error patterns that indicate actual early termination
|
||
// These must be explicit errors, not warnings or informational messages
|
||
const terminationPatterns = [
|
||
/error:.*proper prefixing/i,
|
||
/error:.*tool.*call.*format/i,
|
||
/error:.*tool.?call.*prefix/i,
|
||
/error:.*session terminated/i,
|
||
/error:.*unexpected end/i,
|
||
/error:.*premature exit/i,
|
||
/error:.*incomplete output/i,
|
||
/error:.*bracket prefix/i,
|
||
/error:.*xml tag.*missing/i,
|
||
/error:.*function call/i,
|
||
/error:.*invalid tool call/i,
|
||
/error:.*stream.*closed/i,
|
||
/error:.*connection.*lost/i,
|
||
/error:.*process.*exited/i
|
||
];
|
||
|
||
return terminationPatterns.some(pattern => pattern.test(errorOutput));
|
||
}
|
||
|
||
function isSuccessfulCompletion(stderr, stdout, exitCode) {
|
||
const errorOutput = (stderr || '').toLowerCase();
|
||
const normalOutput = (stdout || '').toLowerCase();
|
||
|
||
// If there's substantial output, check if it indicates successful completion
|
||
if (normalOutput.length > 200) {
|
||
// Check for success indicators in output
|
||
const successIndicators = [
|
||
/completed successfully/i,
|
||
/done\.$/i,
|
||
/finished/i,
|
||
/output generated/i,
|
||
/response complete/i,
|
||
/task completed/i
|
||
];
|
||
|
||
// Also check for absence of critical error patterns
|
||
const criticalErrors = [
|
||
/error:/i,
|
||
/failed/i,
|
||
/exception/i,
|
||
/crashed/i,
|
||
/terminated abnormally/i
|
||
];
|
||
|
||
const hasSuccessIndicator = successIndicators.some(ind => ind.test(normalOutput));
|
||
const hasCriticalError = criticalErrors.some(err => err.test(errorOutput) || err.test(normalOutput));
|
||
|
||
return hasSuccessIndicator && !hasCriticalError;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function resetMessageStreamingFields(message) {
|
||
if (!message) return;
|
||
delete message.partialOutput;
|
||
delete message.partialUpdatedAt;
|
||
delete message.opencodeExitCode;
|
||
delete message.opencodeSummary;
|
||
delete message.outputType;
|
||
}
|
||
|
||
function resolveModelProviders(modelName) {
|
||
const target = getAdminModelByIdOrName(modelName);
|
||
if (target && Array.isArray(target.providers) && target.providers.length) {
|
||
return target.providers.map((p, idx) => ({
|
||
provider: normalizeProviderName(p.provider || p.name || 'opencode'),
|
||
model: (p.model || p.name || modelName || '').trim() || modelName,
|
||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
||
cli: target.cli || 'opencode',
|
||
}));
|
||
}
|
||
return [{
|
||
provider: 'opencode',
|
||
model: modelName,
|
||
primary: true,
|
||
cli: 'opencode',
|
||
}];
|
||
}
|
||
|
||
function buildOpencodeAttemptChain(cli, preferredModel) {
|
||
const chain = [];
|
||
const seen = new Set();
|
||
const addProviderOptions = (modelName) => {
|
||
const providers = resolveModelProviders(modelName);
|
||
providers.forEach((p, idx) => {
|
||
const key = `${p.provider}:${p.model || modelName}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
chain.push({
|
||
provider: p.provider,
|
||
model: p.model || modelName,
|
||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
||
cli: normalizeCli(p.cli || cli || 'opencode'),
|
||
sourceModel: modelName,
|
||
});
|
||
});
|
||
};
|
||
|
||
// Only add preferredModel if it's a non-empty string
|
||
if (typeof preferredModel === 'string' && preferredModel.trim()) {
|
||
addProviderOptions(preferredModel);
|
||
}
|
||
getConfiguredModels(cli).forEach((m) => {
|
||
if (m.name && m.name !== preferredModel) addProviderOptions(m.name);
|
||
});
|
||
addProviderOptions('default');
|
||
|
||
// Log the built chain for debugging
|
||
log('Built model attempt chain', {
|
||
cli,
|
||
preferredModel: (typeof preferredModel === 'string' && preferredModel.trim()) ? preferredModel : '(none)',
|
||
chainLength: chain.length,
|
||
models: chain.map(c => `${c.provider}:${c.model}`).slice(0, 5) // First 5 to avoid log spam
|
||
});
|
||
|
||
return chain;
|
||
}
|
||
|
||
function buildCliFallbackModels(cli, preferredModel) {
|
||
const chain = [];
|
||
const add = (name) => {
|
||
const trimmed = (name || '').trim();
|
||
if (trimmed && !chain.includes(trimmed)) chain.push(trimmed);
|
||
};
|
||
add(preferredModel);
|
||
getConfiguredModels(cli).forEach((m) => add(m.name));
|
||
add('default');
|
||
return chain;
|
||
}
|
||
|
||
function classifyProviderError(error, provider) {
|
||
const statusCode = error.statusCode || error.code;
|
||
const errorMessage = (error.message || '').toLowerCase();
|
||
|
||
const providerPatterns = {
|
||
openai: {
|
||
transient: [500, 502, 503, 504, 529],
|
||
rateLimit: 429,
|
||
auth: [401, 402],
|
||
permission: 403,
|
||
userError: [400],
|
||
notFound: 404,
|
||
timeout: 408
|
||
},
|
||
anthropic: {
|
||
transient: [500, 529],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400, 413],
|
||
notFound: 404
|
||
},
|
||
openrouter: {
|
||
transient: [502, 503],
|
||
rateLimit: 429,
|
||
auth: [401, 402],
|
||
permission: 403,
|
||
userError: [400],
|
||
timeout: 408,
|
||
notFound: 404
|
||
},
|
||
chutes: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400, 413],
|
||
notFound: 404
|
||
},
|
||
nvidia: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400],
|
||
notFound: 404
|
||
},
|
||
together: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: [401, 402],
|
||
permission: 403,
|
||
userError: [400],
|
||
notFound: 404
|
||
},
|
||
fireworks: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
userError: [400],
|
||
notFound: 404
|
||
},
|
||
mistral: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400],
|
||
notFound: 404
|
||
},
|
||
groq: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: [401, 402],
|
||
permission: 403,
|
||
userError: [400, 413],
|
||
notFound: 404
|
||
},
|
||
google: {
|
||
transient: [500, 502, 503],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400, 413],
|
||
notFound: 404
|
||
},
|
||
ollama: {
|
||
transient: [500, 502, 503, 504, 529],
|
||
rateLimit: 429,
|
||
auth: 401,
|
||
permission: 403,
|
||
userError: [400],
|
||
notFound: 404
|
||
},
|
||
default: {
|
||
transient: [500, 502, 503, 529],
|
||
rateLimit: 429,
|
||
auth: [401, 402],
|
||
permission: 403,
|
||
userError: [400, 413],
|
||
notFound: 404
|
||
}
|
||
};
|
||
|
||
const patterns = providerPatterns[provider] || providerPatterns.default;
|
||
|
||
if (error.isToolError) {
|
||
return { category: 'toolError', action: 'return', waitTime: 0 };
|
||
}
|
||
|
||
if (patterns.transient?.includes(statusCode)) {
|
||
return { category: 'transient', action: 'wait', waitTime: 30000 };
|
||
}
|
||
if (statusCode === patterns.rateLimit) {
|
||
return { category: 'rateLimit', action: 'wait', waitTime: 30000 };
|
||
}
|
||
if (patterns.auth?.includes(statusCode)) {
|
||
return { category: 'auth', action: 'switch', waitTime: 0 };
|
||
}
|
||
if (statusCode === patterns.permission) {
|
||
return { category: 'permission', action: 'return', waitTime: 0 };
|
||
}
|
||
if (patterns.userError?.includes(statusCode)) {
|
||
return { category: 'userError', action: 'return', waitTime: 0 };
|
||
}
|
||
if (statusCode === patterns.timeout) {
|
||
return { category: 'timeout', action: 'wait', waitTime: 30000 };
|
||
}
|
||
if (statusCode === patterns.notFound) {
|
||
return { category: 'notFound', action: 'wait', waitTime: 30000 };
|
||
}
|
||
|
||
if (statusCode >= 500) {
|
||
return { category: 'serverError', action: 'wait', waitTime: 30000 };
|
||
}
|
||
|
||
if (errorMessage.includes('model not found') || errorMessage.includes('unknown model')) {
|
||
return { category: 'modelNotFound', action: 'wait', waitTime: 30000 };
|
||
}
|
||
if (errorMessage.includes('insufficient credit') || errorMessage.includes('insufficient quota') || errorMessage.includes('payment required')) {
|
||
return { category: 'billing', action: 'switch', waitTime: 0 };
|
||
}
|
||
if (errorMessage.includes('context length exceeded') || errorMessage.includes('token limit exceeded') || errorMessage.includes('request too large')) {
|
||
return { category: 'userError', action: 'return', waitTime: 0 };
|
||
}
|
||
|
||
return { category: 'unknown', action: 'switch', waitTime: 0 };
|
||
}
|
||
|
||
function shouldFallbackCliError(err, message) {
|
||
if (!err) return false;
|
||
|
||
if (err.isToolError) {
|
||
log('Tool error detected - no fallback needed', {
|
||
error: err.message,
|
||
toolError: true
|
||
});
|
||
return false;
|
||
}
|
||
|
||
// First, check if this was actually a successful completion despite an error being thrown
|
||
// This can happen if the model completed but the process had a non-zero exit code
|
||
if (message && message.partialOutput && message.partialOutput.length > 200) {
|
||
// Check for success indicators in the output
|
||
const successIndicators = [
|
||
/completed successfully/i,
|
||
/finished successfully/i,
|
||
/done\.$/i,
|
||
/task completed/i,
|
||
/output generated/i,
|
||
/response complete/i,
|
||
/✔|✓|success/i
|
||
];
|
||
|
||
const hasSuccessIndicator = successIndicators.some(ind => ind.test(message.partialOutput));
|
||
const hasCriticalError = /error:|failed|exception|crashed/i.test(message.partialOutput);
|
||
|
||
if (hasSuccessIndicator && !hasCriticalError) {
|
||
log('Blocking fallback - model completed successfully', {
|
||
messageId: message.id,
|
||
partialOutputLength: message.partialOutput.length,
|
||
successIndicator: true
|
||
});
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check if message has substantial output - if so, don't fallback even on errors
|
||
// This prevents fallback when model was working fine but hit a minor issue
|
||
if (message && message.partialOutput && message.partialOutput.length > 500) {
|
||
log('Blocking fallback - message has substantial output', {
|
||
messageId: message.id,
|
||
partialOutputLength: message.partialOutput.length
|
||
});
|
||
return false;
|
||
}
|
||
|
||
// Only check stderr and error message for actual errors, not stdout which may contain informational logs
|
||
const errorMessage = (err.message || '').toLowerCase();
|
||
const stderr = (err.stderr || '').toLowerCase();
|
||
const combinedOutput = `${errorMessage} ${stderr}`;
|
||
|
||
// Exclude warnings and info messages - these are not failures requiring fallback
|
||
const isWarningOrInfo = /(warn|warning|info|notice)/i.test(combinedOutput);
|
||
if (isWarningOrInfo) {
|
||
log('Blocking fallback - only warning/info present', {
|
||
hasWarning: true,
|
||
combinedOutput: combinedOutput.substring(0, 200)
|
||
});
|
||
return false;
|
||
}
|
||
|
||
// Use more specific patterns that indicate actual API/model failures requiring fallback
|
||
// These require explicit "error:" prefix or very specific failure indicators
|
||
const apiFailurePatterns = [
|
||
// Model availability errors - these are clear failures requiring fallback
|
||
/error:.*model not found/i,
|
||
/error:.*unknown model/i,
|
||
/error:.*invalid model/i,
|
||
/error:.*unsupported model/i,
|
||
// Authentication errors - clear API failures
|
||
/error:.*api key/i,
|
||
/error:.*unauthorized/i,
|
||
/error:.*forbidden/i,
|
||
// Quota/credit errors - clear service failures
|
||
/error:.*insufficient credit/i,
|
||
/error:.*insufficient quota/i,
|
||
/error:.*no credit/i,
|
||
/error:.*payment required/i,
|
||
// Rate limiting - these require fallback
|
||
/error:.*rate limit exceeded/i,
|
||
/error:.*too many requests/i,
|
||
/error:.*rate limited/i,
|
||
// Size limits - clear model constraints
|
||
/error:.*request too large/i,
|
||
/error:.*token limit exceeded/i,
|
||
/error:.*context length exceeded/i,
|
||
/error:.*maximum context length/i,
|
||
/error:.*max tokens exceeded/i,
|
||
/error:.*reduce your message size/i,
|
||
// Connection and server errors
|
||
/error:.*connection.*refused/i,
|
||
/error:.*connection.*timeout/i,
|
||
/error:.*server.*error/i,
|
||
/error:.*internal server error/i,
|
||
/error:.*service unavailable/i,
|
||
/error:.*gateway.*timeout/i
|
||
];
|
||
|
||
// Check if any API failure pattern is present in error output
|
||
const hasApiFailure = apiFailurePatterns.some((pattern) => pattern.test(combinedOutput));
|
||
|
||
if (hasApiFailure) {
|
||
log('Allowing fallback - API failure detected', {
|
||
pattern: 'api_failure',
|
||
combinedOutput: combinedOutput.substring(0, 200)
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// Additional check: if error mentions a provider with explicit error indicator
|
||
// Only trigger for actual errors, not provider mentions in normal logging
|
||
if (combinedOutput.includes('openrouter') ||
|
||
combinedOutput.includes('mistral') ||
|
||
combinedOutput.includes('nvidia') ||
|
||
combinedOutput.includes('groq')) {
|
||
|
||
// Require explicit error indicators for provider mentions
|
||
const errorIndicators = [
|
||
/^error:/i,
|
||
/ failed/i,
|
||
/ failure/i,
|
||
/ exception/i,
|
||
/ rejected/i,
|
||
/ denied/i
|
||
];
|
||
|
||
const hasErrorIndicator = errorIndicators.some((ind) => ind.test(combinedOutput));
|
||
|
||
if (hasErrorIndicator) {
|
||
log('Allowing fallback - provider error detected', {
|
||
provider: 'provider_mentioned',
|
||
combinedOutput: combinedOutput.substring(0, 200)
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Additional check: HTTP status codes that indicate actual failures
|
||
const httpStatusPatterns = [
|
||
/error.*401/i,
|
||
/error.*402/i,
|
||
/error.*404/i,
|
||
/error.*429/i,
|
||
/error.*5\d{2}/
|
||
];
|
||
|
||
const hasHttpError = httpStatusPatterns.some((pattern) => pattern.test(combinedOutput));
|
||
if (hasHttpError) {
|
||
log('Allowing fallback - HTTP error status detected', {
|
||
pattern: 'http_error',
|
||
combinedOutput: combinedOutput.substring(0, 200)
|
||
});
|
||
return true;
|
||
}
|
||
|
||
log('Blocking fallback - no eligible error pattern detected', {
|
||
combinedOutput: combinedOutput.substring(0, 200)
|
||
});
|
||
return false;
|
||
}
|
||
|
||
async function sendToOpencodeWithFallback({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan }) {
|
||
const cliName = normalizeCli(cli || session?.cli);
|
||
const preferredModel = model || session?.model;
|
||
const chain = buildOpencodeAttemptChain(cliName, preferredModel);
|
||
const tried = new Set();
|
||
const attempts = [];
|
||
let lastError = null;
|
||
let switchedToBackup = false;
|
||
|
||
const continueAttempts = new Map();
|
||
const MAX_CONTINUE_ATTEMPTS = 3;
|
||
const CONTINUE_MESSAGE = '[CONTINUE] Please continue from where you left off.';
|
||
const lastErrorTypes = new Map();
|
||
|
||
log('Fallback sequence initiated', {
|
||
sessionId: session?.id,
|
||
messageId: message?.id,
|
||
primaryModel: preferredModel,
|
||
cliName,
|
||
chainLength: chain.length,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
const tryOption = async (option, isBackup = false) => {
|
||
const key = `${option.provider}:${option.model}`;
|
||
if (tried.has(key)) return null;
|
||
tried.add(key);
|
||
const limit = isProviderLimited(option.provider, option.model);
|
||
if (limit.limited) {
|
||
attempts.push({
|
||
model: option.model,
|
||
provider: option.provider,
|
||
error: `limit: ${limit.reason}`,
|
||
classification: 'rateLimit'
|
||
});
|
||
return null;
|
||
}
|
||
try {
|
||
resetMessageStreamingFields(message);
|
||
|
||
let messageContent = content;
|
||
const modelKey = `${option.provider}:${option.model}`;
|
||
const continueCount = continueAttempts.get(modelKey) || 0;
|
||
|
||
if (continueCount > 0 && continueCount <= MAX_CONTINUE_ATTEMPTS) {
|
||
messageContent = `${CONTINUE_MESSAGE}\n\n${content}`;
|
||
log('Sending continue message', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
attempt: continueCount,
|
||
modelKey
|
||
});
|
||
}
|
||
|
||
// When switching to backup model, preserve session and keep original content
|
||
if (isBackup && !switchedToBackup && attempts.length > 0) {
|
||
switchedToBackup = true;
|
||
log('Switching to backup model with session continuity', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
preservedSession: opencodeSessionId,
|
||
attempts: attempts.length
|
||
});
|
||
}
|
||
const result = await sendToOpencode({ session, model: option.model, content: messageContent, message, cli: cliName, streamCallback, opencodeSessionId });
|
||
const normalizedResult = (result && typeof result === 'object') ? result : { reply: result };
|
||
|
||
let tokensUsed = 0;
|
||
let tokenSource = 'none';
|
||
let tokenExtractionLog = [];
|
||
|
||
// First try to use tokens from sendToOpencode result if available
|
||
if (result && typeof result === 'object' && result.tokensUsed > 0) {
|
||
tokensUsed = result.tokensUsed;
|
||
tokenSource = result.tokenSource || 'result';
|
||
tokenExtractionLog = result.tokenExtractionLog || [];
|
||
log('✓ sendToOpencodeWithFallback: Using tokens from sendToOpencode result', { tokensUsed, tokenSource, messageId: message?.id });
|
||
} else {
|
||
// Fallback to extractTokenUsageFromResult
|
||
tokensUsed = extractTokenUsageFromResult(normalizedResult, [messageContent], { allowEstimate: false });
|
||
if (tokensUsed > 0) {
|
||
tokenSource = 'response-extracted';
|
||
tokenExtractionLog.push({ method: 'extractTokenUsageFromResult', success: true, value: tokensUsed });
|
||
log('✓ sendToOpencodeWithFallback: Extracted tokens from result', { tokensUsed, messageId: message?.id });
|
||
} else {
|
||
tokenExtractionLog.push({ method: 'extractTokenUsageFromResult', success: false, reason: 'returned 0 tokens' });
|
||
}
|
||
}
|
||
|
||
// Check if token usage was captured during streaming
|
||
if (!tokensUsed && message.opencodeTokensUsed) {
|
||
tokensUsed = message.opencodeTokensUsed;
|
||
tokenSource = 'stream';
|
||
tokenExtractionLog.push({ method: 'stream', success: true, value: tokensUsed, context: 'sendToOpencodeWithFallback' });
|
||
log('✓ sendToOpencodeWithFallback: Using token usage captured during streaming', { tokensUsed, messageId: message?.id });
|
||
}
|
||
|
||
// Try session query
|
||
if (!tokensUsed && opencodeSessionId && session?.workspaceDir) {
|
||
log('🔍 sendToOpencodeWithFallback: Attempting session token query', { sessionId: opencodeSessionId, messageId: message?.id });
|
||
tokensUsed = await getOpencodeSessionTokenUsage(opencodeSessionId, session.workspaceDir);
|
||
if (tokensUsed > 0) {
|
||
tokenSource = 'session';
|
||
tokenExtractionLog.push({ method: 'session_query', success: true, value: tokensUsed, context: 'sendToOpencodeWithFallback' });
|
||
log('✓ sendToOpencodeWithFallback: Got tokens from session', { tokensUsed, messageId: message?.id });
|
||
} else {
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, reason: 'returned 0 tokens', context: 'sendToOpencodeWithFallback' });
|
||
}
|
||
}
|
||
|
||
// Use estimation as last resort
|
||
if (!tokensUsed) {
|
||
const inputTokens = estimateTokensFromMessages([messageContent], '');
|
||
const outputTokens = estimateTokensFromMessages([], normalizedResult.reply || '');
|
||
tokensUsed = Math.max(inputTokens, outputTokens) + Math.ceil(Math.min(inputTokens, outputTokens) * 0.5);
|
||
tokensUsed = Math.max(tokensUsed, 50);
|
||
tokenSource = 'estimate-improved';
|
||
|
||
const estimationDetails = {
|
||
inputTokens,
|
||
outputTokens,
|
||
inputLength: (messageContent || '').length,
|
||
outputLength: (normalizedResult.reply || '').length,
|
||
calculatedTokens: Math.max(inputTokens, outputTokens) + Math.ceil(Math.min(inputTokens, outputTokens) * 0.5),
|
||
finalTokens: tokensUsed,
|
||
reason: 'All token extraction methods failed in sendToOpencodeWithFallback'
|
||
};
|
||
|
||
tokenExtractionLog.push({ method: 'estimation', success: true, value: tokensUsed, details: estimationDetails });
|
||
|
||
log('⚠️ sendToOpencodeWithFallback: Using estimation', {
|
||
messageId: message?.id,
|
||
sessionId: session?.id,
|
||
model: option.model,
|
||
provider: option.provider,
|
||
...estimationDetails
|
||
});
|
||
|
||
console.warn('[TOKEN_TRACKING] ⚠️ ESTIMATION USED in sendToOpencodeWithFallback:', JSON.stringify({
|
||
messageId: message?.id,
|
||
sessionId: session?.id,
|
||
model: option.model,
|
||
provider: option.provider,
|
||
estimatedTokens: tokensUsed,
|
||
...estimationDetails
|
||
}, null, 2));
|
||
}
|
||
|
||
log('✅ Token usage determined in sendToOpencodeWithFallback', {
|
||
tokenSource,
|
||
tokensUsed,
|
||
sessionId: session?.id,
|
||
messageId: message?.id,
|
||
extractionLog: tokenExtractionLog
|
||
});
|
||
|
||
recordProviderUsage(option.provider, option.model, tokensUsed, 1);
|
||
|
||
// Reset counters on success
|
||
continueAttempts.delete(modelKey);
|
||
lastErrorTypes.delete(modelKey);
|
||
|
||
if (attempts.length) {
|
||
log('opencode succeeded after fallback', { attempts, model: option.model, provider: option.provider, cli: cliName, backup: isBackup });
|
||
}
|
||
|
||
return {
|
||
reply: normalizedResult.reply,
|
||
model: option.model,
|
||
attempts,
|
||
provider: option.provider,
|
||
raw: normalizedResult.raw,
|
||
tokensUsed,
|
||
tokenSource,
|
||
tokenExtractionLog
|
||
};
|
||
} catch (err) {
|
||
lastError = err;
|
||
|
||
const errorData = {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
error: err.message || String(err),
|
||
code: err.code || null,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
if (err.earlyTermination) {
|
||
const partialOutputLength = (message?.partialOutput || '').length;
|
||
const hasSubstantialOutput = partialOutputLength > 500;
|
||
|
||
if (hasSubstantialOutput) {
|
||
log('Blocking fallback - model has substantial output despite early termination', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
error: err.message,
|
||
partialOutputLength
|
||
});
|
||
return err;
|
||
}
|
||
|
||
const modelKey = `${option.provider}:${option.model}`;
|
||
const currentCount = continueAttempts.get(modelKey) || 0;
|
||
continueAttempts.set(modelKey, currentCount + 1);
|
||
|
||
log('Early termination detected', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
continueAttempt: currentCount + 1,
|
||
maxAttempts: MAX_CONTINUE_ATTEMPTS
|
||
});
|
||
|
||
if (currentCount + 1 < MAX_CONTINUE_ATTEMPTS) {
|
||
errorData.earlyTermination = true;
|
||
errorData.continueAttempt = currentCount + 1;
|
||
errorData.willContinue = true;
|
||
attempts.push(errorData);
|
||
|
||
tried.delete(key);
|
||
return null;
|
||
}
|
||
|
||
log('Max continue attempts reached, switching model', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
totalAttempts: MAX_CONTINUE_ATTEMPTS
|
||
});
|
||
|
||
attempts.push(errorData);
|
||
return null;
|
||
}
|
||
|
||
const classification = classifyProviderError(err, option.provider);
|
||
errorData.classification = classification.category;
|
||
|
||
const modelKey = `${option.provider}:${option.model}`;
|
||
const lastErrorType = lastErrorTypes.get(modelKey);
|
||
|
||
if (lastErrorType === classification.category &&
|
||
classification.category !== 'unknown') {
|
||
log('Repeated error type detected', {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
errorType: classification.category
|
||
});
|
||
lastErrorTypes.set(modelKey, classification.category);
|
||
}
|
||
|
||
if (classification.action === 'return') {
|
||
log('User/permission error - returning to user', {
|
||
category: classification.category,
|
||
model: option.model,
|
||
provider: option.provider
|
||
});
|
||
err.willNotFallback = true;
|
||
attempts.push(errorData);
|
||
return err;
|
||
}
|
||
|
||
if (classification.action === 'wait') {
|
||
log(`Provider error (${classification.category}) - waiting ${classification.waitTime}ms`, {
|
||
model: option.model,
|
||
provider: option.provider,
|
||
category: classification.category,
|
||
waitTime: classification.waitTime
|
||
});
|
||
|
||
errorData.willWait = true;
|
||
errorData.waitTime = classification.waitTime;
|
||
attempts.push(errorData);
|
||
|
||
await new Promise(resolve => setTimeout(resolve, classification.waitTime));
|
||
|
||
return null;
|
||
}
|
||
|
||
errorData.immediateSwitch = true;
|
||
attempts.push(errorData);
|
||
|
||
return null;
|
||
}
|
||
};
|
||
|
||
for (const option of chain) {
|
||
const result = await tryOption(option);
|
||
if (result instanceof Error) break;
|
||
if (result) return result;
|
||
}
|
||
|
||
const backupModel = (providerLimits.opencodeBackupModel || '').trim();
|
||
if (backupModel) {
|
||
const backupChain = buildOpencodeAttemptChain(cliName, backupModel);
|
||
for (const option of backupChain) {
|
||
const result = await tryOption(option, true);
|
||
if (result instanceof Error) break;
|
||
if (result) return result;
|
||
}
|
||
}
|
||
|
||
const MAX_EARLY_TERMINATIONS = 2;
|
||
const recentEarlyTerminations = attempts.filter(
|
||
a => a.earlyTermination &&
|
||
Date.now() - new Date(a.timestamp || 0).getTime() < 60000
|
||
).length;
|
||
|
||
if (recentEarlyTerminations >= MAX_EARLY_TERMINATIONS) {
|
||
log('Too many early terminations, giving up', {
|
||
sessionId: session?.id,
|
||
earlyTerminations: recentEarlyTerminations,
|
||
attempts: attempts.length
|
||
});
|
||
}
|
||
|
||
const err = new Error(`All ${cliName.toUpperCase()} models failed`);
|
||
err.attempts = attempts;
|
||
err.cause = lastError;
|
||
throw err;
|
||
}
|
||
|
||
async function queueMessage(sessionId, message) {
|
||
traceMessageLifecycle('queued', sessionId, message);
|
||
const prev = sessionQueues.get(sessionId) || Promise.resolve();
|
||
const next = prev.then(async () => {
|
||
try {
|
||
await processMessage(sessionId, message);
|
||
} catch (error) {
|
||
// Enhanced error handling to ensure resource cleanup
|
||
log('Queue processing error', { sessionId, messageId: message.id, error: String(error) });
|
||
|
||
// Try to find the session and message to mark as error if not already handled
|
||
try {
|
||
const session = getSession(sessionId);
|
||
if (session) {
|
||
const msg = session.messages.find(m => m.id === message.id);
|
||
if (msg && msg.status !== 'error' && msg.status !== 'completed' && msg.status !== 'skipped') {
|
||
msg.status = 'error';
|
||
msg.error = `Queue processing failed: ${String(error)}`;
|
||
msg.finishedAt = new Date().toISOString();
|
||
session.updatedAt = msg.finishedAt;
|
||
updatePending(sessionId, -1, session.userId);
|
||
await persistState();
|
||
}
|
||
}
|
||
} catch (cleanupError) {
|
||
log('Failed to cleanup after queue error', { sessionId, error: String(cleanupError) });
|
||
}
|
||
|
||
// Re-throw to maintain the promise chain behavior
|
||
throw error;
|
||
}
|
||
});
|
||
sessionQueues.set(sessionId, next);
|
||
return next;
|
||
}
|
||
|
||
function formatExternalTestingSummary(result) {
|
||
if (!result) return '';
|
||
if (result.skipped) {
|
||
const limit = Number.isFinite(result.summary?.limit) ? result.summary.limit : 'unlimited';
|
||
return `\n\n---\nExternal WP CLI Testing\nStatus: Skipped\nReason: ${result.reason || 'Not available'}\nUsage: ${result.summary?.used || 0} / ${limit}`;
|
||
}
|
||
if (!result.ok) {
|
||
const errorText = Array.isArray(result.errors) && result.errors.length
|
||
? result.errors.join(' | ')
|
||
: 'Unknown error';
|
||
return `\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${errorText}`;
|
||
}
|
||
const cli = result.test_results?.cli_tests || { passed: 0, failed: 0 };
|
||
const usage = result.usageSummary;
|
||
const limit = Number.isFinite(usage?.limit) ? usage.limit : 'unlimited';
|
||
return `\n\n---\nExternal WP CLI Testing\nStatus: ${cli.failed === 0 ? 'Passed' : 'Failed'}\nCLI Tests: ${cli.passed} passed, ${cli.failed} failed\nSubsite: ${result.subsite_url || 'n/a'}\nUsage: ${usage?.used || 0} / ${limit}`;
|
||
}
|
||
|
||
async function runExternalTestingForSession(session, message, plan) {
|
||
if (!message?.externalTestingEnabled) return null;
|
||
if (!session?.workspaceDir) return { ok: false, errors: ['Workspace directory not available'] };
|
||
|
||
const user = findUserById(session.userId);
|
||
const limitCheck = canUseExternalTesting(session.userId, plan, user?.unlimitedUsage === true);
|
||
if (!limitCheck.allowed) {
|
||
return { skipped: true, reason: 'External testing limit reached. Upgrade to continue.', summary: limitCheck.summary };
|
||
}
|
||
|
||
const pluginRoot = await resolvePluginRoot(session.workspaceDir);
|
||
const spec = await loadExternalTestingSpec(session.workspaceDir);
|
||
const testInput = {
|
||
plugin_path: pluginRoot,
|
||
plugin_slug: session.pluginSlug,
|
||
test_mode: 'cli',
|
||
required_plugins: spec?.required_plugins || [],
|
||
test_scenarios: spec?.test_scenarios || [],
|
||
};
|
||
|
||
await recordExternalTestUsage(session.userId);
|
||
trackFeatureUsage('external_wp_testing', session.userId, plan);
|
||
|
||
const result = await externalWpTester.runTest(testInput, { configOverrides: {} });
|
||
result.usageSummary = getExternalTestUsageSummary(session.userId, plan);
|
||
return result;
|
||
}
|
||
|
||
async function processMessage(sessionId, message) {
|
||
const session = getSession(sessionId);
|
||
if (!session) return;
|
||
|
||
// Track message processing start
|
||
const startTime = Date.now();
|
||
const userPlan = resolveUserPlan(session.userId);
|
||
|
||
if (message.isContinuation && message.originalMessageId) {
|
||
const originalMessage = session.messages?.find(m => m.id === message.originalMessageId);
|
||
|
||
// Ensure we always preserve the session for continuations
|
||
// Priority order: originalMessage.opencodeSessionId > session.opencodeSessionId > session.initialOpencodeSessionId
|
||
if (originalMessage && originalMessage.opencodeSessionId) {
|
||
message.opencodeSessionId = originalMessage.opencodeSessionId;
|
||
} else if (session.opencodeSessionId) {
|
||
message.opencodeSessionId = session.opencodeSessionId;
|
||
} else if (session.initialOpencodeSessionId) {
|
||
message.opencodeSessionId = session.initialOpencodeSessionId;
|
||
}
|
||
|
||
log('Processing continuation message', {
|
||
sessionId,
|
||
originalMessageId: message.originalMessageId,
|
||
newMessageId: message.id,
|
||
model: message.model,
|
||
preservedSessionId: message.opencodeSessionId,
|
||
initialSessionId: session.initialOpencodeSessionId,
|
||
sessionOpencodeSessionId: session.opencodeSessionId,
|
||
originalMessageSessionId: originalMessage?.opencodeSessionId
|
||
});
|
||
}
|
||
|
||
message.status = 'running';
|
||
message.startedAt = new Date().toISOString();
|
||
updatePending(sessionId, 0, session.userId);
|
||
await persistState();
|
||
let releaseResources = null;
|
||
try {
|
||
traceMessageLifecycle('processing', sessionId, message);
|
||
await ensureSessionPaths(session);
|
||
const sessionPlan = resolveUserPlan(session.userId);
|
||
await applyPlanPriorityDelay(sessionPlan);
|
||
|
||
// Wait for resources - this will wait indefinitely until resources are available
|
||
// Messages are never skipped, they stay in queue until they can be processed
|
||
releaseResources = await waitForResources(message.id);
|
||
|
||
const activeCli = normalizeCli(message.cli || session.cli);
|
||
|
||
// Track model usage
|
||
const modelUsed = message.model || session.model || 'default';
|
||
trackModelUsage(modelUsed, session.userId, userPlan);
|
||
trackFeatureUsage('ai_chat', session.userId, userPlan);
|
||
|
||
// Image attachments: append image URLs to message content
|
||
// Images are sent to the opencode session along with the message
|
||
const imageAttachments = Array.isArray(message.attachments)
|
||
? message.attachments.filter((a) => a && isImageMime(a.type) && a.url)
|
||
: [];
|
||
if (imageAttachments.length) {
|
||
const imageTags = imageAttachments.map(a => `@${a.name}`).join(' ');
|
||
message.content = `${imageTags}\n\n${message.content}`;
|
||
}
|
||
|
||
// Ensure opencode session exists before processing
|
||
// CRITICAL FIX: Use message.opencodeSessionId if explicitly provided in request
|
||
// This ensures session continuity for continuations and retries
|
||
let opencodeSessionId;
|
||
if (message.opencodeSessionId) {
|
||
log('Using explicit opencodeSessionId from message', {
|
||
sessionId,
|
||
messageOpencodeSessionId: message.opencodeSessionId,
|
||
sessionOpencodeSessionId: session.opencodeSessionId
|
||
});
|
||
opencodeSessionId = message.opencodeSessionId;
|
||
// Update session to use this session ID
|
||
if (session.opencodeSessionId !== opencodeSessionId) {
|
||
session.opencodeSessionId = opencodeSessionId;
|
||
// Only set initialOpencodeSessionId if not already set to preserve session continuity
|
||
if (!session.initialOpencodeSessionId) {
|
||
session.initialOpencodeSessionId = opencodeSessionId;
|
||
}
|
||
await persistState();
|
||
}
|
||
} else {
|
||
log('Ensuring opencode session (no explicit session ID in message)', {
|
||
sessionId,
|
||
activeCli,
|
||
model: message.model,
|
||
isProceedWithBuild: message.isProceedWithBuild
|
||
});
|
||
opencodeSessionId = await ensureOpencodeSession(session, message.model);
|
||
if (opencodeSessionId && session.opencodeSessionId !== opencodeSessionId) {
|
||
session.opencodeSessionId = opencodeSessionId;
|
||
await persistState();
|
||
}
|
||
}
|
||
// We allow null opencodeSessionId, which means we'll let the CLI create one
|
||
log('opencode session ensured (or pending)', { sessionId, opencodeSessionId, model: message.model, workspaceDir: session.workspaceDir });
|
||
|
||
const opencodeResult = await sendToOpencodeWithFallback({ session, model: message.model, content: message.content, message, cli: activeCli, opencodeSessionId, plan: sessionPlan });
|
||
let reply = opencodeResult.reply;
|
||
if (opencodeResult.model) {
|
||
message.model = opencodeResult.model;
|
||
}
|
||
if (Array.isArray(opencodeResult.attempts) && opencodeResult.attempts.length) {
|
||
message.failoverAttempts = opencodeResult.attempts;
|
||
}
|
||
|
||
// Track AI response time
|
||
const responseTime = Date.now() - startTime;
|
||
const provider = opencodeResult.provider || 'opencode';
|
||
trackAIResponseTime(responseTime, provider, true, null);
|
||
|
||
// Calculate tokens: prefer OpenCode-reported usage, then stream capture, then real session usage, then estimation
|
||
let tokensUsed = 0;
|
||
let tokenSource = 'none';
|
||
let tokenExtractionLog = [];
|
||
|
||
// First check if we got tokens from the result
|
||
if (typeof opencodeResult.tokensUsed === 'number' && opencodeResult.tokensUsed > 0) {
|
||
tokensUsed = opencodeResult.tokensUsed;
|
||
tokenSource = opencodeResult.tokenSource || 'result';
|
||
tokenExtractionLog = opencodeResult.tokenExtractionLog || [];
|
||
log('✓ processMessage: Using tokens from opencodeResult', { tokensUsed, tokenSource, messageId: message.id });
|
||
}
|
||
|
||
// Check if token usage was captured during streaming (fallback if result didn't have it)
|
||
if (!tokensUsed && message.opencodeTokensUsed) {
|
||
tokensUsed = message.opencodeTokensUsed;
|
||
tokenSource = 'stream';
|
||
tokenExtractionLog.push({ method: 'stream', success: true, value: tokensUsed, context: 'processMessage fallback' });
|
||
log('✓ processMessage: Using token usage captured during streaming', { tokensUsed, messageId: message.id });
|
||
}
|
||
|
||
// Try session query if still no tokens
|
||
if (!tokensUsed && session.opencodeSessionId && session.workspaceDir) {
|
||
try {
|
||
log('🔍 processMessage: Attempting session token query as fallback', { sessionId: session.opencodeSessionId, messageId: message.id });
|
||
tokensUsed = await getOpencodeSessionTokenUsage(session.opencodeSessionId, session.workspaceDir);
|
||
if (tokensUsed > 0) {
|
||
tokenSource = 'session';
|
||
tokenExtractionLog.push({ method: 'session_query', success: true, value: tokensUsed, context: 'processMessage fallback' });
|
||
log('✓ processMessage: Got tokens from session query', { tokensUsed, messageId: message.id });
|
||
} else {
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, reason: 'returned 0 tokens', context: 'processMessage fallback' });
|
||
}
|
||
} catch (sessionErr) {
|
||
tokenExtractionLog.push({ method: 'session_query', success: false, error: String(sessionErr), context: 'processMessage fallback' });
|
||
log('✗ processMessage: Session token query failed', { error: String(sessionErr), messageId: message.id });
|
||
}
|
||
}
|
||
|
||
// If still no tokens, use estimation with detailed logging
|
||
if (!tokensUsed) {
|
||
const inputTokens = estimateTokensFromMessages([message.content], '');
|
||
const outputTokens = estimateTokensFromMessages([], reply || '');
|
||
// Use a more accurate ratio for AI-generated code (typically ~3-4 chars per token for code)
|
||
tokensUsed = Math.max(inputTokens, outputTokens) + Math.ceil(Math.min(inputTokens, outputTokens) * 0.5);
|
||
// Ensure minimum token count for any AI interaction
|
||
tokensUsed = Math.max(tokensUsed, 50);
|
||
tokenSource = 'estimate-improved';
|
||
|
||
const estimationDetails = {
|
||
inputTokens,
|
||
outputTokens,
|
||
inputLength: (message.content || '').length,
|
||
outputLength: (reply || '').length,
|
||
calculatedTokens: Math.max(inputTokens, outputTokens) + Math.ceil(Math.min(inputTokens, outputTokens) * 0.5),
|
||
finalTokens: tokensUsed,
|
||
reason: 'All token extraction methods failed'
|
||
};
|
||
|
||
tokenExtractionLog.push({
|
||
method: 'estimation',
|
||
success: true,
|
||
value: tokensUsed,
|
||
details: estimationDetails,
|
||
context: 'processMessage fallback'
|
||
});
|
||
|
||
log('⚠️ processMessage: Using estimation because all extraction methods failed', {
|
||
messageId: message.id,
|
||
sessionId: session.id,
|
||
...estimationDetails
|
||
});
|
||
|
||
console.warn('[TOKEN_TRACKING] ⚠️ ESTIMATION USED:', JSON.stringify({
|
||
messageId: message.id,
|
||
sessionId: session.id,
|
||
model: message.model,
|
||
provider,
|
||
estimatedTokens: tokensUsed,
|
||
...estimationDetails
|
||
}, null, 2));
|
||
|
||
// Mark message as having token extraction failure for client-side error detection
|
||
// This allows the client to detect server-side errors even when status is 'done'
|
||
message.tokenExtractionFailed = true;
|
||
message.tokenSource = tokenSource;
|
||
}
|
||
|
||
log('✅ Token usage determined for processMessage', {
|
||
tokenSource,
|
||
tokensUsed,
|
||
sessionId,
|
||
messageId: message.id,
|
||
extractionLog: tokenExtractionLog
|
||
});
|
||
|
||
console.log(`[USAGE] processMessage: recording tokens user=${session.userId} tokens=${tokensUsed} model=${message.model} source=${tokenSource}`);
|
||
|
||
if (session.userId) {
|
||
await recordUserTokens(session.userId, tokensUsed);
|
||
} else {
|
||
console.error('[USAGE] ERROR: Cannot record tokens in processMessage because session.userId is missing', { sessionId, messageId: message.id });
|
||
}
|
||
|
||
// Check if message appears incomplete due to token extraction failure
|
||
// If token extraction failed and output is suspiciously short, mark as incomplete
|
||
const outputLength = (reply || '').length;
|
||
const isSuspiciouslyShort = outputLength < 100 && tokenSource === 'estimate-improved';
|
||
const isTokenExtractionFailed = tokenSource === 'estimate-improved';
|
||
|
||
if (isTokenExtractionFailed && isSuspiciouslyShort && message.cli === 'opencode') {
|
||
console.warn('[COMPLETION_DETECTION] Message appears incomplete due to token extraction failure', {
|
||
messageId: message.id,
|
||
sessionId: session.id,
|
||
outputLength,
|
||
tokenSource,
|
||
isSuspiciouslyShort
|
||
});
|
||
// Mark message as potentially incomplete for client-side detection
|
||
message.potentiallyIncomplete = true;
|
||
}
|
||
|
||
if (message.isProceedWithBuild && message.externalTestingEnabled) {
|
||
try {
|
||
const testingResult = await runExternalTestingForSession(session, message, sessionPlan);
|
||
message.externalTesting = testingResult;
|
||
reply = `${reply || ''}${formatExternalTestingSummary(testingResult)}`.trim();
|
||
} catch (testErr) {
|
||
message.externalTesting = { ok: false, errors: [testErr.message || String(testErr)] };
|
||
reply = `${reply || ''}\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${testErr.message || String(testErr)}`.trim();
|
||
}
|
||
}
|
||
|
||
message.status = 'done';
|
||
message.reply = reply;
|
||
if (message.todos && Array.isArray(message.todos) && message.todos.length) {
|
||
message.todos = [];
|
||
}
|
||
message.finishedAt = new Date().toISOString();
|
||
session.updatedAt = message.finishedAt;
|
||
session.cli = activeCli;
|
||
session.model = message.model; // Update session's active model after any fallback
|
||
await persistState(); // Persist the updated model
|
||
traceMessageLifecycle('done', sessionId, message);
|
||
} catch (error) {
|
||
// Provide helpful and parseable error details in the message
|
||
message.status = 'error';
|
||
if (message.todos && Array.isArray(message.todos) && message.todos.length) {
|
||
message.todos = [];
|
||
}
|
||
const details = [];
|
||
if (error.code) details.push(`code: ${error.code}`);
|
||
if (error.stderr) details.push(`stderr: ${error.stderr.trim()}`);
|
||
if (error.stdout) details.push(`stdout: ${error.stdout.trim()}`);
|
||
if (Array.isArray(error.attempts) && error.attempts.length) {
|
||
const attemptSummary = error.attempts.map((a) => `${a.model || 'unknown'}: ${a.error || 'error'}`).join(' | ');
|
||
details.push(`attempts: ${attemptSummary}`);
|
||
const lastAttempt = error.attempts[error.attempts.length - 1];
|
||
if (lastAttempt?.model) message.model = lastAttempt.model;
|
||
}
|
||
const messageText = (error.message && error.message.length) ? String(error.message) : `${String(error)}`;
|
||
message.error = `${messageText}${details.length ? ` -- ${details.join(' | ')}` : ''}`;
|
||
|
||
// Track AI errors
|
||
const errorType = error.code || 'processing_error';
|
||
trackUserSession(session.userId, 'error', {
|
||
errorType: errorType,
|
||
sessionId: sessionId,
|
||
messageId: message.id
|
||
});
|
||
trackAIResponseTime(Date.now() - startTime, 'opencode', false, errorType);
|
||
|
||
log('message processing failed', { sessionId, messageId: message.id, error: message.error });
|
||
message.finishedAt = new Date().toISOString();
|
||
session.updatedAt = message.finishedAt;
|
||
} finally {
|
||
releaseResources?.();
|
||
updatePending(sessionId, -1, session.userId);
|
||
|
||
// Clean up any active streams for this message
|
||
if (activeStreams.has(message.id)) {
|
||
const streams = activeStreams.get(message.id);
|
||
if (streams instanceof Set) {
|
||
for (const stream of streams) {
|
||
try {
|
||
const finalData = JSON.stringify({
|
||
type: message.status === 'error' ? 'error' : 'complete',
|
||
content: message.reply || message.partialOutput || '',
|
||
error: message.error,
|
||
outputType: message.outputType,
|
||
exitCode: message.opencodeExitCode,
|
||
timestamp: message.finishedAt || new Date().toISOString()
|
||
});
|
||
stream.write(`data: ${finalData}\n\n`);
|
||
stream.end();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
activeStreams.delete(message.id);
|
||
}
|
||
|
||
// Clean up process tracking
|
||
if (runningProcesses.has(message.id)) {
|
||
runningProcesses.delete(message.id);
|
||
}
|
||
|
||
// Trigger memory cleanup after processing completes
|
||
const processTime = Date.now() - startTime;
|
||
if (processTime > 60000) { // If processing took > 1 minute, trigger cleanup
|
||
triggerMemoryCleanup('long_process_complete');
|
||
}
|
||
|
||
await persistState();
|
||
}
|
||
}
|
||
|
||
function getConfiguredModels(cliParam = 'opencode') {
|
||
const cli = normalizeCli(cliParam || 'opencode');
|
||
const filtered = adminModels.filter((m) => !m.cli || normalizeCli(m.cli) === cli);
|
||
const mapped = filtered.map((m) => ({
|
||
id: m.id,
|
||
name: m.name,
|
||
label: m.label || m.name,
|
||
icon: m.icon || '',
|
||
cli: m.cli || 'opencode',
|
||
providers: Array.isArray(m.providers) ? m.providers : [],
|
||
primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
|
||
tier: m.tier || 'free',
|
||
multiplier: getTierMultiplier(m.tier || 'free'),
|
||
supportsMedia: m.supportsMedia ?? false,
|
||
}));
|
||
return mapped.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
|
||
}
|
||
|
||
async function handleModels(_req, res, cliParam = null) {
|
||
try {
|
||
const models = getConfiguredModels(cliParam || 'opencode');
|
||
sendJson(res, 200, { models, empty: models.length === 0 });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Failed to load models' });
|
||
}
|
||
}
|
||
|
||
async function handleUserLogin(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const email = (body.email || body.username || body.user || '').trim().toLowerCase();
|
||
const password = (body.password || body.pass || '').trim();
|
||
const remember = body.remember === true || body.remember === 'true';
|
||
const clientIp = req.socket?.remoteAddress || 'unknown';
|
||
|
||
// Check honeypot
|
||
if (checkHoneypot(body)) {
|
||
log('user login honeypot triggered', { ip: clientIp });
|
||
return sendJson(res, 400, { error: 'Invalid request' });
|
||
}
|
||
|
||
if (!email || !password) {
|
||
return sendJson(res, 400, { error: 'Email and password are required' });
|
||
}
|
||
|
||
// Check rate limit
|
||
const rateLimitKey = `${email}:${clientIp}`;
|
||
const rateLimit = checkLoginRateLimit(rateLimitKey, USER_LOGIN_RATE_LIMIT, loginAttempts);
|
||
if (rateLimit.blocked) {
|
||
log('user login rate limited', { email, ip: clientIp, retryAfter: rateLimit.retryAfter });
|
||
return sendJson(res, 429, {
|
||
error: 'Too many login attempts. Please try again later.',
|
||
retryAfter: rateLimit.retryAfter || 60
|
||
});
|
||
}
|
||
|
||
const user = await verifyUserPassword(email, password);
|
||
if (!user) {
|
||
log('failed user login attempt', { email, ip: clientIp, reason: 'invalid_credentials' });
|
||
return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||
}
|
||
|
||
// Check account lockout
|
||
if (user.lockedUntil && user.lockedUntil > Date.now()) {
|
||
log('login attempt on locked account', { email, ip: clientIp, lockedUntil: user.lockedUntil });
|
||
return sendJson(res, 429, {
|
||
error: 'Account temporarily locked due to too many failed attempts.',
|
||
retryAfter: Math.ceil((user.lockedUntil - Date.now()) / 1000)
|
||
});
|
||
}
|
||
|
||
if (!user.emailVerified) {
|
||
return sendJson(res, 403, { error: 'Please verify your email address before signing in.' });
|
||
}
|
||
|
||
// Clear failed attempts on success
|
||
loginAttempts.delete(rateLimitKey);
|
||
|
||
// Reset failed logins on successful login
|
||
user.failedLogins = 0;
|
||
user.lockedUntil = null;
|
||
await persistUsersDb();
|
||
|
||
const token = startUserSession(res, user.id, remember);
|
||
const ttl = remember ? USER_SESSION_TTL_MS : USER_SESSION_SHORT_TTL_MS;
|
||
const expiresAt = Date.now() + ttl;
|
||
|
||
// Track user login
|
||
trackUserSession(user.id, 'login', { plan: user.plan });
|
||
trackConversionFunnel('signup_to_login', 'login', user.id, { plan: user.plan });
|
||
|
||
log('successful user login', { userId: user.id, email: user.email, remember });
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
user: { id: user.id, email: user.email, plan: user.plan },
|
||
token,
|
||
expiresAt
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to login' });
|
||
}
|
||
}
|
||
|
||
async function subscribeToEmailMarketing(email) {
|
||
try {
|
||
await fetch('https://emailmarketing.modelrailway3d.co.uk/api/webhooks/incoming/wh_0Z49zi_DGj4-lKJMOPO8-g', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
email,
|
||
source: 'plugin_compass_signup',
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
});
|
||
log('email marketing subscription added', { email });
|
||
} catch (error) {
|
||
log('email marketing subscription failed', { email, error: error.message });
|
||
}
|
||
}
|
||
|
||
async function handleUserRegister(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const email = (body.email || body.username || body.user || '').trim().toLowerCase();
|
||
const password = (body.password || body.pass || '').trim();
|
||
const clientIp = req.socket?.remoteAddress || 'unknown';
|
||
|
||
// Check honeypot
|
||
if (checkHoneypot(body)) {
|
||
log('user registration honeypot triggered', { ip: clientIp });
|
||
return sendJson(res, 400, { error: 'Invalid request' });
|
||
}
|
||
|
||
if (!email || !password) {
|
||
return sendJson(res, 400, { error: 'Email and password are required' });
|
||
}
|
||
|
||
// Basic email validation
|
||
if (!EMAIL_REGEX.test(email)) {
|
||
return sendJson(res, 400, { error: 'Invalid email format' });
|
||
}
|
||
|
||
// Enhanced password strength validation
|
||
const passwordValidation = validatePassword(password);
|
||
if (!passwordValidation.valid) {
|
||
return sendJson(res, 400, {
|
||
error: 'Password does not meet requirements',
|
||
requirements: passwordValidation.errors
|
||
});
|
||
}
|
||
|
||
const affiliateCode = sanitizeAffiliateCode(body.affiliateCode || body.ref || readAffiliateReferralCode(req));
|
||
const trackedAffiliate = affiliateCode && findAffiliateByCode(affiliateCode) ? affiliateCode : '';
|
||
if (trackedAffiliate) {
|
||
setAffiliateReferralCookie(res, trackedAffiliate);
|
||
}
|
||
|
||
const user = await createUser(email, password, { referredByAffiliateCode: trackedAffiliate });
|
||
|
||
subscribeToEmailMarketing(user.email).catch(err => {
|
||
log('background email marketing subscription failed', { userId: user.id, email: user.email });
|
||
});
|
||
|
||
// Track signup conversion and start conversion funnel
|
||
trackConversion('signup', req);
|
||
trackConversionFunnel('signup_process', 'signup_completed', user.id, {
|
||
source: body.source || 'direct',
|
||
has_affiliate: !!trackedAffiliate
|
||
});
|
||
|
||
// Send verification email in the background to avoid holding up the response
|
||
sendVerificationEmail(user, resolveBaseUrl(req)).catch(err => {
|
||
log('background verification email failed', { userId: user.id, email: user.email });
|
||
});
|
||
|
||
log('user registered successfully', { userId: user.id, email: user.email });
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
user: { id: user.id, email: user.email, emailVerified: user.emailVerified },
|
||
verificationRequired: true,
|
||
message: 'Please check your email to verify your account.'
|
||
});
|
||
} catch (error) {
|
||
if (error.message === 'User already exists with this email') {
|
||
sendJson(res, 409, { error: error.message });
|
||
} else {
|
||
sendJson(res, 400, { error: error.message || 'Unable to register' });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleAffiliateSignup(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const email = (body.email || body.username || '').trim().toLowerCase();
|
||
const password = (body.password || '').trim();
|
||
const name = (body.name || body.fullName || '').trim();
|
||
if (!EMAIL_REGEX.test(email)) return sendJson(res, 400, { error: 'Email is invalid' });
|
||
if (!password || password.length < 6) return sendJson(res, 400, { error: 'Password must be at least 6 characters long' });
|
||
const affiliate = await registerAffiliate({ email, password, name });
|
||
sendAffiliateVerificationEmail(affiliate, resolveBaseUrl(req)).catch(err => {
|
||
log('background affiliate verification email failed', { affiliateId: affiliate.id, email: affiliate.email });
|
||
});
|
||
return sendJson(res, 201, { ok: true, verificationRequired: true, message: 'Please check your email to verify your account.' });
|
||
} catch (error) {
|
||
if (error.message && error.message.includes('already exists')) {
|
||
return sendJson(res, 409, { error: error.message });
|
||
}
|
||
return sendJson(res, 400, { error: error.message || 'Unable to create affiliate' });
|
||
}
|
||
}
|
||
|
||
async function handleAffiliateLogin(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const email = (body.email || '').trim().toLowerCase();
|
||
const password = (body.password || '').trim();
|
||
if (!email || !password) return sendJson(res, 400, { error: 'Email and password are required' });
|
||
const affiliate = await verifyAffiliatePassword(email, password);
|
||
if (!affiliate) return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||
if (!affiliate.emailVerified) {
|
||
return sendJson(res, 403, { error: 'Please verify your email before logging in.', verificationRequired: true });
|
||
}
|
||
const token = startAffiliateSession(res, affiliate.id);
|
||
return sendJson(res, 200, { ok: true, affiliate: summarizeAffiliate(affiliate), token });
|
||
} catch (error) {
|
||
return sendJson(res, 400, { error: error.message || 'Unable to login' });
|
||
}
|
||
}
|
||
|
||
async function handleAffiliateVerifyEmailApi(req, res, url) {
|
||
try {
|
||
const tokenFromQuery = (url && url.searchParams && url.searchParams.get('token')) || '';
|
||
const body = req.method === 'POST' ? await parseJsonBody(req).catch(() => ({})) : {};
|
||
const token = (body.token || tokenFromQuery || '').trim();
|
||
if (!token) return sendJson(res, 400, { error: 'Verification token is required' });
|
||
|
||
const affiliate = affiliatesDb.find((a) => a.verificationToken === token);
|
||
if (!affiliate) return sendJson(res, 400, { error: 'Verification link is invalid' });
|
||
|
||
if (affiliate.verificationExpiresAt) {
|
||
const expires = new Date(affiliate.verificationExpiresAt).getTime();
|
||
if (Number.isFinite(expires) && expires < Date.now()) {
|
||
return sendJson(res, 400, { error: 'Verification link has expired. Please request a new one.' });
|
||
}
|
||
}
|
||
|
||
affiliate.emailVerified = true;
|
||
affiliate.verificationToken = '';
|
||
affiliate.verificationExpiresAt = null;
|
||
await persistAffiliatesDb();
|
||
const tokenValue = startAffiliateSession(res, affiliate.id);
|
||
log('affiliate email verified', { affiliateId: affiliate.id, email: affiliate.email });
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
affiliate: summarizeAffiliate(affiliate),
|
||
token: tokenValue,
|
||
expiresAt: Date.now() + AFFILIATE_SESSION_TTL_MS,
|
||
message: 'Email verified successfully.',
|
||
redirect: '/affiliate-dashboard',
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to verify email' });
|
||
}
|
||
}
|
||
|
||
async function handleAffiliateLogout(_req, res) {
|
||
clearAffiliateSession(res);
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
async function handleAffiliateMe(req, res, url) {
|
||
const auth = requireAffiliateAuth(req, res);
|
||
if (!auth) return;
|
||
const affiliate = auth.affiliate;
|
||
const summary = summarizeAffiliate(affiliate);
|
||
const baseUrl = resolveBaseUrl(req, url);
|
||
const firstLink = (summary.trackingLinks || [])[0];
|
||
const firstCode = firstLink?.code || '';
|
||
const firstPath = firstLink?.targetPath || '/pricing';
|
||
const sampleLink = firstCode ? `${baseUrl}${firstPath}${firstPath.includes('?') ? '&' : '?'}aff=${firstCode}` : `${baseUrl}/pricing`;
|
||
sendJson(res, 200, { ok: true, affiliate: summary, sampleLink });
|
||
}
|
||
|
||
async function handleAffiliateTransactions(req, res) {
|
||
const auth = requireAffiliateAuth(req, res);
|
||
if (!auth) return;
|
||
const affiliate = auth.affiliate;
|
||
const records = Array.isArray(affiliate.earnings) ? affiliate.earnings : [];
|
||
// Sort by date descending
|
||
const sorted = [...records].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
sendJson(res, 200, { ok: true, transactions: sorted });
|
||
}
|
||
|
||
async function handleInvoicesList(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const invoices = getInvoicesByUserId(user.id);
|
||
sendJson(res, 200, { ok: true, invoices });
|
||
}
|
||
|
||
async function handleInvoiceDownload(req, res, url, invoiceId) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const invoice = invoicesDb.find(inv => inv.id === invoiceId);
|
||
if (!invoice) return sendJson(res, 404, { error: 'Invoice not found' });
|
||
if (invoice.userId !== user.id) return sendJson(res, 403, { error: 'Access denied' });
|
||
|
||
const pdfPath = path.join(INVOICES_DIR, `${invoiceId}.pdf`);
|
||
try {
|
||
await fs.access(pdfPath);
|
||
const fileStream = fsSync.createReadStream(pdfPath);
|
||
res.setHeader('Content-Type', 'application/pdf');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoiceNumber}.pdf"`);
|
||
fileStream.pipe(res);
|
||
} catch (error) {
|
||
log('Failed to read invoice PDF', { invoiceId, error: String(error) });
|
||
return sendJson(res, 500, { error: 'Failed to download invoice' });
|
||
}
|
||
}
|
||
|
||
async function handleAffiliateCreateLink(req, res) {
|
||
const auth = requireAffiliateAuth(req, res);
|
||
if (!auth) return;
|
||
const affiliate = auth.affiliate;
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const label = (body.label || body.name || 'New link').toString().trim() || 'New link';
|
||
let targetPath = (body.targetPath || body.path || '/pricing').toString().trim();
|
||
if (!targetPath.startsWith('/')) targetPath = '/' + targetPath;
|
||
|
||
const code = generateTrackingCode(label);
|
||
affiliate.codes = Array.isArray(affiliate.codes) ? affiliate.codes : [];
|
||
affiliate.codes.push({ code, label, targetPath, createdAt: new Date().toISOString() });
|
||
await persistAffiliatesDb();
|
||
sendJson(res, 201, { ok: true, link: { code, label, targetPath }, links: affiliate.codes });
|
||
}
|
||
|
||
async function handleAffiliateCreateWithdrawal(req, res) {
|
||
const auth = requireAffiliateAuth(req, res);
|
||
if (!auth) return;
|
||
const affiliate = auth.affiliate;
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
|
||
const paypalEmail = (body.paypalEmail || '').trim().toLowerCase();
|
||
const currency = (body.currency || 'USD').toUpperCase();
|
||
const amount = Number(body.amount || 0);
|
||
|
||
if (!paypalEmail || !paypalEmail.includes('@')) {
|
||
return sendJson(res, 400, { error: 'PayPal email is required' });
|
||
}
|
||
if (!['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'CHF'].includes(currency)) {
|
||
return sendJson(res, 400, { error: 'Invalid currency' });
|
||
}
|
||
if (amount <= 0) {
|
||
return sendJson(res, 400, { error: 'Amount must be greater than 0' });
|
||
}
|
||
|
||
const earnings = Array.isArray(affiliate.earnings) ? affiliate.earnings : [];
|
||
const totalEarnings = earnings.reduce((sum, e) => sum + Number(e.amount || 0), 0);
|
||
if (amount > totalEarnings) {
|
||
return sendJson(res, 400, { error: 'Amount exceeds available balance' });
|
||
}
|
||
|
||
const withdrawal = {
|
||
id: randomUUID(),
|
||
affiliateId: affiliate.id,
|
||
affiliateEmail: affiliate.email,
|
||
paypalEmail,
|
||
currency,
|
||
amount,
|
||
status: 'pending',
|
||
createdAt: new Date().toISOString(),
|
||
processedAt: null,
|
||
};
|
||
|
||
withdrawalsDb.push(withdrawal);
|
||
await persistWithdrawalsDb();
|
||
sendJson(res, 201, { ok: true, withdrawal });
|
||
}
|
||
|
||
async function handleAdminWithdrawalsList(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const sorted = [...withdrawalsDb].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
sendJson(res, 200, { withdrawals: sorted });
|
||
}
|
||
|
||
async function handleAdminWithdrawalUpdate(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const withdrawalId = body.withdrawalId;
|
||
const newStatus = body.status;
|
||
|
||
if (!withdrawalId) {
|
||
return sendJson(res, 400, { error: 'Withdrawal ID is required' });
|
||
}
|
||
if (!['pending', 'done'].includes(newStatus)) {
|
||
return sendJson(res, 400, { error: 'Invalid status' });
|
||
}
|
||
|
||
const withdrawal = withdrawalsDb.find(w => w.id === withdrawalId);
|
||
if (!withdrawal) {
|
||
return sendJson(res, 404, { error: 'Withdrawal not found' });
|
||
}
|
||
|
||
withdrawal.status = newStatus;
|
||
if (newStatus === 'done') {
|
||
withdrawal.processedAt = new Date().toISOString();
|
||
}
|
||
|
||
await persistWithdrawalsDb();
|
||
sendJson(res, 200, { ok: true, withdrawal });
|
||
}
|
||
|
||
async function handleFeatureRequestsList(req, res) {
|
||
const session = getUserSession(req);
|
||
const userId = session?.userId || '';
|
||
|
||
const sorted = [...featureRequestsDb].sort((a, b) => {
|
||
if (b.votes !== a.votes) return b.votes - a.votes;
|
||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||
});
|
||
|
||
const result = sorted.map(fr => ({
|
||
id: fr.id,
|
||
title: fr.title,
|
||
description: fr.description,
|
||
votes: fr.votes,
|
||
createdAt: fr.createdAt,
|
||
status: fr.status || 'pending',
|
||
adminReply: fr.adminReply || '',
|
||
hasVoted: userId ? fr.upvoters.includes(userId) : false,
|
||
}));
|
||
|
||
sendJson(res, 200, { featureRequests: result });
|
||
}
|
||
|
||
async function handleFeatureRequestCreate(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const title = (body.title || '').toString().trim();
|
||
const description = (body.description || '').toString().trim();
|
||
|
||
if (!title || title.length < 3) {
|
||
return sendJson(res, 400, { error: 'Title must be at least 3 characters' });
|
||
}
|
||
if (!description || description.length < 10) {
|
||
return sendJson(res, 400, { error: 'Description must be at least 10 characters' });
|
||
}
|
||
|
||
const featureRequest = {
|
||
id: randomUUID(),
|
||
title,
|
||
description,
|
||
votes: 1,
|
||
upvoters: [session.userId],
|
||
authorEmail: user.email || '',
|
||
authorId: session.userId,
|
||
status: 'pending',
|
||
adminReply: '',
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
featureRequestsDb.push(featureRequest);
|
||
await persistFeatureRequestsDb();
|
||
|
||
log('Feature request created', { id: featureRequest.id, title, userId: session.userId });
|
||
sendJson(res, 201, {
|
||
ok: true,
|
||
featureRequest: {
|
||
id: featureRequest.id,
|
||
title: featureRequest.title,
|
||
description: featureRequest.description,
|
||
votes: featureRequest.votes,
|
||
createdAt: featureRequest.createdAt,
|
||
status: featureRequest.status,
|
||
adminReply: featureRequest.adminReply,
|
||
hasVoted: true,
|
||
}
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to create feature request' });
|
||
}
|
||
}
|
||
|
||
async function handleFeatureRequestUpvote(req, res, id) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const featureRequest = featureRequestsDb.find(fr => fr.id === id);
|
||
if (!featureRequest) return sendJson(res, 404, { error: 'Feature request not found' });
|
||
|
||
const userId = session.userId;
|
||
const hasUpvoted = featureRequest.upvoters.includes(userId);
|
||
|
||
if (hasUpvoted) {
|
||
featureRequest.upvoters = featureRequest.upvoters.filter(uid => uid !== userId);
|
||
featureRequest.votes = Math.max(0, featureRequest.votes - 1);
|
||
} else {
|
||
featureRequest.upvoters.push(userId);
|
||
featureRequest.votes = (featureRequest.votes || 0) + 1;
|
||
}
|
||
|
||
await persistFeatureRequestsDb();
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
votes: featureRequest.votes,
|
||
hasVoted: !hasUpvoted,
|
||
});
|
||
}
|
||
|
||
async function handleAdminFeatureRequestsList(req, res) {
|
||
const session = getAdminSession(req);
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const sorted = [...featureRequestsDb].sort((a, b) => {
|
||
if (b.votes !== a.votes) return b.votes - a.votes;
|
||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||
});
|
||
|
||
const result = sorted.map(fr => ({
|
||
id: fr.id,
|
||
title: fr.title,
|
||
description: fr.description,
|
||
votes: fr.votes,
|
||
createdAt: fr.createdAt,
|
||
updatedAt: fr.updatedAt,
|
||
authorEmail: fr.authorEmail,
|
||
authorId: fr.authorId,
|
||
status: fr.status || 'pending',
|
||
adminReply: fr.adminReply || '',
|
||
upvoters: fr.upvoters || [],
|
||
}));
|
||
|
||
sendJson(res, 200, { featureRequests: result });
|
||
}
|
||
|
||
async function handleFeatureRequestReply(req, res, id) {
|
||
const session = getAdminSession(req);
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const featureRequest = featureRequestsDb.find(fr => fr.id === id);
|
||
if (!featureRequest) {
|
||
return sendJson(res, 404, { error: 'Feature request not found' });
|
||
}
|
||
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const reply = (body.reply || '').toString().trim();
|
||
|
||
featureRequest.adminReply = reply;
|
||
featureRequest.updatedAt = new Date().toISOString();
|
||
await persistFeatureRequestsDb();
|
||
|
||
log('Feature request reply added', { id, adminId: session.userId });
|
||
sendJson(res, 200, { ok: true, adminReply: reply });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to add reply' });
|
||
}
|
||
}
|
||
|
||
async function handleFeatureRequestUpdateStatus(req, res, id) {
|
||
const session = getAdminSession(req);
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const featureRequest = featureRequestsDb.find(fr => fr.id === id);
|
||
if (!featureRequest) {
|
||
return sendJson(res, 404, { error: 'Feature request not found' });
|
||
}
|
||
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const status = (body.status || '').toString().trim();
|
||
const validStatuses = ['pending', 'planned', 'in-progress', 'completed', 'declined'];
|
||
|
||
if (!validStatuses.includes(status)) {
|
||
return sendJson(res, 400, { error: 'Invalid status. Must be one of: ' + validStatuses.join(', ') });
|
||
}
|
||
|
||
featureRequest.status = status;
|
||
featureRequest.updatedAt = new Date().toISOString();
|
||
await persistFeatureRequestsDb();
|
||
|
||
log('Feature request status updated', { id, status, adminId: session.userId });
|
||
sendJson(res, 200, { ok: true, status });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to update status' });
|
||
}
|
||
}
|
||
|
||
async function handleFeatureRequestDelete(req, res, id) {
|
||
const session = getAdminSession(req);
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const index = featureRequestsDb.findIndex(fr => fr.id === id);
|
||
if (index === -1) {
|
||
return sendJson(res, 404, { error: 'Feature request not found' });
|
||
}
|
||
|
||
featureRequestsDb.splice(index, 1);
|
||
await persistFeatureRequestsDb();
|
||
|
||
log('Feature request deleted', { id, adminId: session.userId });
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
async function handleContactMessagesList(req, res) {
|
||
const session = getAdminSession(req);
|
||
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const sorted = [...contactMessagesDb].sort((a, b) => {
|
||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||
});
|
||
|
||
const result = sorted.map(msg => ({
|
||
id: msg.id,
|
||
name: msg.name,
|
||
email: msg.email,
|
||
subject: msg.subject,
|
||
message: msg.message,
|
||
createdAt: msg.createdAt,
|
||
read: msg.read || false,
|
||
}));
|
||
|
||
sendJson(res, 200, { messages: result });
|
||
}
|
||
|
||
async function handleContactMessageCreate(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const name = (body.name || '').toString().trim();
|
||
const email = (body.email || '').toString().trim();
|
||
const subject = (body.subject || '').toString().trim();
|
||
const message = (body.message || '').toString().trim();
|
||
|
||
if (!name || name.length < 2) {
|
||
return sendJson(res, 400, { error: 'Name must be at least 2 characters' });
|
||
}
|
||
if (!email || !EMAIL_REGEX.test(email)) {
|
||
return sendJson(res, 400, { error: 'Please provide a valid email address' });
|
||
}
|
||
if (!subject || subject.length < 3) {
|
||
return sendJson(res, 400, { error: 'Subject must be at least 3 characters' });
|
||
}
|
||
if (!message || message.length < 10) {
|
||
return sendJson(res, 400, { error: 'Message must be at least 10 characters' });
|
||
}
|
||
|
||
const contactMessage = {
|
||
id: randomUUID(),
|
||
name,
|
||
email,
|
||
subject,
|
||
message,
|
||
read: false,
|
||
createdAt: new Date().toISOString(),
|
||
ip: req.socket?.remoteAddress || '',
|
||
};
|
||
|
||
contactMessagesDb.push(contactMessage);
|
||
await persistContactMessagesDb();
|
||
|
||
log('Contact message received', { id: contactMessage.id, email });
|
||
sendJson(res, 201, { ok: true, id: contactMessage.id });
|
||
} catch (error) {
|
||
log('Contact form error', { error: String(error) });
|
||
sendJson(res, 400, { error: error.message || 'Unable to process your message' });
|
||
}
|
||
}
|
||
|
||
async function handleContactMessageMarkRead(req, res, id) {
|
||
const session = getAdminSession(req);
|
||
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const message = contactMessagesDb.find(msg => msg.id === id);
|
||
if (!message) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
message.read = true;
|
||
await persistContactMessagesDb();
|
||
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
async function handleContactMessageDelete(req, res, id) {
|
||
const session = getAdminSession(req);
|
||
|
||
if (!session) {
|
||
return sendJson(res, 403, { error: 'Admin access required' });
|
||
}
|
||
|
||
const index = contactMessagesDb.findIndex(msg => msg.id === id);
|
||
if (index === -1) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
contactMessagesDb.splice(index, 1);
|
||
await persistContactMessagesDb();
|
||
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
// Blog API Handlers
|
||
async function handleBlogsList(req, res, url) {
|
||
try {
|
||
const type = url.searchParams.get('type');
|
||
const category = url.searchParams.get('category');
|
||
const limit = parseInt(url.searchParams.get('limit')) || 10;
|
||
const offset = parseInt(url.searchParams.get('offset')) || 0;
|
||
|
||
const blogs = await blogSystem.getAllBlogs({
|
||
type,
|
||
category,
|
||
status: 'published',
|
||
limit,
|
||
offset
|
||
});
|
||
|
||
// Sanitize response (don't send full content in list)
|
||
const sanitized = blogs.map(b => ({
|
||
id: b.id,
|
||
slug: b.slug,
|
||
type: b.type,
|
||
title: b.title,
|
||
excerpt: b.excerpt,
|
||
author: b.author,
|
||
featured_image: b.featured_image,
|
||
category: b.category,
|
||
tags: b.tags,
|
||
published_at: b.published_at,
|
||
updated_at: b.updated_at
|
||
}));
|
||
|
||
sendJson(res, 200, { posts: sanitized, total: blogs.length });
|
||
} catch (error) {
|
||
log('Failed to list blogs', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to list blogs' });
|
||
}
|
||
}
|
||
|
||
async function handleBlogGet(req, res, slug) {
|
||
try {
|
||
const blog = await blogSystem.getBlogBySlug(slug);
|
||
if (!blog) {
|
||
return sendJson(res, 404, { error: 'Blog post not found' });
|
||
}
|
||
|
||
// Convert blocks to HTML for easier consumption
|
||
const html = blogSystem.blocksToHtml(blog.content?.blocks || []);
|
||
|
||
sendJson(res, 200, {
|
||
post: {
|
||
...blog,
|
||
html
|
||
}
|
||
});
|
||
} catch (error) {
|
||
log('Failed to get blog', { error: String(error), slug });
|
||
sendJson(res, 500, { error: 'Failed to get blog post' });
|
||
}
|
||
}
|
||
|
||
async function handleBlogCategoriesList(req, res) {
|
||
try {
|
||
const categories = await blogSystem.getCategories();
|
||
sendJson(res, 200, { categories });
|
||
} catch (error) {
|
||
log('Failed to list blog categories', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to list categories' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogsList(req, res, url) {
|
||
try {
|
||
const type = url.searchParams.get('type');
|
||
const status = url.searchParams.get('status');
|
||
const source = url.searchParams.get('source');
|
||
const category = url.searchParams.get('category');
|
||
|
||
const blogs = await blogSystem.getAllBlogs({ type, status, source, category });
|
||
const categories = await blogSystem.getCategories();
|
||
|
||
sendJson(res, 200, { posts: blogs, categories });
|
||
} catch (error) {
|
||
log('Failed to list admin blogs', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to list blogs' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogCreate(req, res) {
|
||
try {
|
||
const body = await readBodyJson(req);
|
||
const { title, slug, type, content, excerpt, author, featured_image, meta_title, meta_description, category, tags, status } = body;
|
||
|
||
if (!title || !slug || !type) {
|
||
return sendJson(res, 400, { error: 'Title, slug, and type are required' });
|
||
}
|
||
|
||
const newPost = await blogSystem.createBlog({
|
||
title,
|
||
slug,
|
||
type,
|
||
content,
|
||
excerpt,
|
||
author: author || 'Admin',
|
||
featured_image,
|
||
meta_title,
|
||
meta_description,
|
||
category,
|
||
tags: tags || [],
|
||
status: status || 'draft'
|
||
});
|
||
|
||
sendJson(res, 201, { post: newPost });
|
||
} catch (error) {
|
||
log('Failed to create blog', { error: String(error) });
|
||
sendJson(res, 500, { error: error.message || 'Failed to create blog post' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogUpdate(req, res, id) {
|
||
try {
|
||
const body = await readBodyJson(req);
|
||
const { title, slug, content, excerpt, author, featured_image, meta_title, meta_description, category, tags, status, published_at } = body;
|
||
|
||
const updated = await blogSystem.updateBlog(id, {
|
||
title,
|
||
slug,
|
||
content,
|
||
excerpt,
|
||
author,
|
||
featured_image,
|
||
meta_title,
|
||
meta_description,
|
||
category,
|
||
tags,
|
||
status,
|
||
published_at
|
||
});
|
||
|
||
sendJson(res, 200, { post: updated });
|
||
} catch (error) {
|
||
log('Failed to update blog', { error: String(error), id });
|
||
sendJson(res, 500, { error: error.message || 'Failed to update blog post' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogDelete(req, res, id) {
|
||
try {
|
||
await blogSystem.deleteBlog(id);
|
||
sendJson(res, 200, { ok: true });
|
||
} catch (error) {
|
||
log('Failed to delete blog', { error: String(error), id });
|
||
sendJson(res, 500, { error: error.message || 'Failed to delete blog post' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogCategoryCreate(req, res) {
|
||
try {
|
||
const body = await readBodyJson(req);
|
||
const { name, slug, description } = body;
|
||
|
||
if (!name || !slug) {
|
||
return sendJson(res, 400, { error: 'Name and slug are required' });
|
||
}
|
||
|
||
const category = await blogSystem.createCategory({ name, slug, description });
|
||
sendJson(res, 201, { category });
|
||
} catch (error) {
|
||
log('Failed to create blog category', { error: String(error) });
|
||
sendJson(res, 500, { error: error.message || 'Failed to create category' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogCategoryUpdate(req, res, id) {
|
||
try {
|
||
const body = await readBodyJson(req);
|
||
const { name, slug, description } = body;
|
||
|
||
const updated = await blogSystem.updateCategory(id, { name, slug, description });
|
||
sendJson(res, 200, { category: updated });
|
||
} catch (error) {
|
||
log('Failed to update blog category', { error: String(error), id });
|
||
sendJson(res, 500, { error: error.message || 'Failed to update category' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogCategoryDelete(req, res, id) {
|
||
try {
|
||
await blogSystem.deleteCategory(id);
|
||
sendJson(res, 200, { ok: true });
|
||
} catch (error) {
|
||
log('Failed to delete blog category', { error: String(error), id });
|
||
sendJson(res, 500, { error: error.message || 'Failed to delete category' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminBlogImageUpload(req, res) {
|
||
try {
|
||
const contentType = req.headers['content-type'] || '';
|
||
if (!contentType.includes('multipart/form-data')) {
|
||
return sendJson(res, 400, { error: 'Expected multipart/form-data' });
|
||
}
|
||
|
||
// Parse multipart form data manually
|
||
const chunks = [];
|
||
for await (const chunk of req) {
|
||
chunks.push(chunk);
|
||
}
|
||
const buffer = Buffer.concat(chunks);
|
||
|
||
// Simple boundary parsing
|
||
const boundary = contentType.split('boundary=')[1];
|
||
if (!boundary) {
|
||
return sendJson(res, 400, { error: 'No boundary found' });
|
||
}
|
||
|
||
// Find file data (simplified - in production use proper multipart parser)
|
||
const boundaryBuffer = Buffer.from('--' + boundary);
|
||
const parts = [];
|
||
let start = 0;
|
||
|
||
while (true) {
|
||
const idx = buffer.indexOf(boundaryBuffer, start);
|
||
if (idx === -1) break;
|
||
const nextIdx = buffer.indexOf(boundaryBuffer, idx + boundaryBuffer.length);
|
||
const part = buffer.slice(idx + boundaryBuffer.length, nextIdx !== -1 ? nextIdx : buffer.length);
|
||
parts.push(part);
|
||
start = idx + boundaryBuffer.length;
|
||
if (nextIdx === -1) break;
|
||
}
|
||
|
||
// Process first file part
|
||
let fileData = null;
|
||
let fileName = null;
|
||
|
||
for (const part of parts) {
|
||
const headerEnd = part.indexOf('\r\n\r\n');
|
||
if (headerEnd === -1) continue;
|
||
|
||
const headers = part.slice(0, headerEnd).toString();
|
||
const data = part.slice(headerEnd + 4);
|
||
|
||
if (headers.includes('filename="')) {
|
||
const match = headers.match(/filename="([^"]+)"/);
|
||
if (match) {
|
||
fileName = match[1];
|
||
// Remove trailing \r\n--
|
||
fileData = data.slice(0, -4);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!fileData || !fileName) {
|
||
return sendJson(res, 400, { error: 'No file found' });
|
||
}
|
||
|
||
// Generate unique filename
|
||
const ext = path.extname(fileName) || '.jpg';
|
||
const uniqueName = `blog-${Date.now()}-${Math.random().toString(36).substr(2, 9)}${ext}`;
|
||
const filePath = path.join(blogSystem.BLOGS_UPLOAD_DIR, uniqueName);
|
||
|
||
await fs.writeFile(filePath, fileData);
|
||
|
||
sendJson(res, 200, {
|
||
success: 1,
|
||
file: {
|
||
url: `/blogs/images/${uniqueName}`,
|
||
name: uniqueName
|
||
}
|
||
});
|
||
} catch (error) {
|
||
log('Failed to upload blog image', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to upload image' });
|
||
}
|
||
}
|
||
|
||
async function handleUserLogout(req, res) {
|
||
const session = getUserSession(req);
|
||
if (session) {
|
||
// Track user logout and session end
|
||
trackUserSession(session.userId, 'logout', { sessionDuration: session.expiresAt - Date.now() });
|
||
userSessions.delete(session.token);
|
||
persistUserSessions().catch(() => {});
|
||
}
|
||
clearUserSession(res);
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
async function handleUserMe(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) {
|
||
return sendJson(res, 404, { error: 'User not found' });
|
||
}
|
||
|
||
// Generate CSRF token for state-changing operations
|
||
const csrfToken = generateCsrfToken(user.id);
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
user: { id: user.id, email: user.email, createdAt: user.createdAt, lastLoginAt: user.lastLoginAt, plan: user.plan },
|
||
expiresAt: session.expiresAt,
|
||
csrfToken
|
||
});
|
||
}
|
||
|
||
// CSRF token refresh endpoint
|
||
async function handleCsrfToken(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const csrfToken = generateCsrfToken(session.userId);
|
||
sendJson(res, 200, { csrfToken });
|
||
}
|
||
|
||
// Validate CSRF token middleware for state-changing endpoints
|
||
function validateCsrfMiddleware(req, res, userId, body = null) {
|
||
const csrfToken = req.headers['x-csrf-token'] || body?.csrfToken;
|
||
if (!csrfToken || !validateCsrfToken(csrfToken, userId)) {
|
||
log('csrf validation failed', { userId, path: req.url });
|
||
return sendJson(res, 403, { error: 'Invalid CSRF token' });
|
||
}
|
||
return null; // Valid
|
||
}
|
||
|
||
async function handleAccountSettingsGet(req, res, url) {
|
||
// Use requireUserId to support both session cookie and X-User-Id header
|
||
// This matches the pattern used by /api/sessions for consistency
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
return sendJson(res, 200, { ok: true, account: await serializeAccount(user) });
|
||
}
|
||
|
||
async function handleAccountUsage(req, res) {
|
||
// Prefer authenticated user-session. If absent, fall back to legacy identity (chat_user cookie / X-User-Id)
|
||
// so the builder usage meter still updates for legacy clients.
|
||
const authed = getUserSession(req);
|
||
const resolvedUserId = authed?.userId || resolveUserId(req);
|
||
if (!resolvedUserId) {
|
||
return sendJson(res, 401, { error: 'User identity required' });
|
||
}
|
||
const user = findUserById(resolvedUserId);
|
||
const plan = user?.plan || DEFAULT_PLAN;
|
||
const summary = getTokenUsageSummary(resolvedUserId, plan);
|
||
const externalTesting = getExternalTestUsageSummary(resolvedUserId, plan);
|
||
const payg = PAYG_ENABLED && isPaidPlan(plan) && !user?.unlimitedUsage
|
||
? computePaygSummary(resolvedUserId, plan)
|
||
: null;
|
||
return sendJson(res, 200, { ok: true, summary: { ...summary, externalTesting }, payg, legacy: !authed });
|
||
}
|
||
|
||
async function handleAccountPlans(_req, res) {
|
||
sendJson(res, 200, { plans: USER_PLANS, defaultPlan: DEFAULT_PLAN });
|
||
}
|
||
|
||
async function handleProviderLimitsGet(_req, res) {
|
||
try {
|
||
sendJson(res, 200, {
|
||
opencodeBackupModel: providerLimits.opencodeBackupModel || '',
|
||
limits: providerLimits.limits || {}
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Unable to load provider limits' });
|
||
}
|
||
}
|
||
|
||
async function handleAccountSettingsUpdate(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
const previousPlan = user.plan;
|
||
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
|
||
// Validate CSRF token
|
||
const csrfError = validateCsrfMiddleware(req, res, session.userId, body);
|
||
if (csrfError) return;
|
||
|
||
let updated = false;
|
||
|
||
if (!user.referredByAffiliateCode) {
|
||
const cookieCode = sanitizeAffiliateCode(readAffiliateReferralCode(req));
|
||
if (cookieCode && findAffiliateByCode(cookieCode)) {
|
||
user.referredByAffiliateCode = cookieCode;
|
||
user.affiliateAttributionAt = new Date().toISOString();
|
||
updated = true;
|
||
}
|
||
}
|
||
|
||
const requestedPlan = normalizePlanSelection(body.plan || body.newPlan);
|
||
const requestedBillingCycle = String(body.billingCycle || '').toLowerCase();
|
||
const requestedCurrency = String(body.currency || '').toLowerCase();
|
||
|
||
// Handle plan changes with Dodo subscription management
|
||
if (requestedPlan && requestedPlan !== user.plan) {
|
||
const isPaidToFree = PAID_PLANS.has(user.plan) && requestedPlan === 'hobby';
|
||
const isPaidToPaid = PAID_PLANS.has(user.plan) && PAID_PLANS.has(requestedPlan);
|
||
|
||
// Cancel Dodo subscription when changing from paid to free hobby plan
|
||
if (isPaidToFree && user.dodoSubscriptionId) {
|
||
await cancelDodoSubscription(user, 'paid_to_free', { clearOnFailure: true });
|
||
user.plan = requestedPlan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.subscriptionRenewsAt = null;
|
||
user.billingCycle = null;
|
||
user.subscriptionCurrency = null;
|
||
updated = true;
|
||
}
|
||
// For paid-to-paid changes, use Dodo's Change Plan API if subscription exists
|
||
else if (isPaidToPaid && user.dodoSubscriptionId) {
|
||
try {
|
||
// Ensure we have valid billing cycle and currency
|
||
const targetBillingCycle = requestedBillingCycle || user.billingCycle || 'monthly';
|
||
const targetCurrency = requestedCurrency || user.subscriptionCurrency || 'usd';
|
||
|
||
// Call Dodo's Change Plan API
|
||
await changeDodoSubscriptionPlan(user, requestedPlan, targetBillingCycle, targetCurrency);
|
||
|
||
// Update user record after successful plan change
|
||
user.plan = requestedPlan;
|
||
user.billingCycle = targetBillingCycle;
|
||
user.subscriptionCurrency = targetCurrency;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.subscriptionRenewsAt = computeRenewalDate(targetBillingCycle);
|
||
updated = true;
|
||
} catch (error) {
|
||
log('Failed to change plan via Dodo API', { userId: user.id, error: String(error) });
|
||
return sendJson(res, 400, { error: error.message || 'Unable to change subscription plan' });
|
||
}
|
||
}
|
||
// For free-to-paid or when no subscription exists, redirect to checkout
|
||
else if (!user.dodoSubscriptionId && PAID_PLANS.has(requestedPlan)) {
|
||
return sendJson(res, 400, {
|
||
error: 'Please use the checkout flow to subscribe to a paid plan',
|
||
requiresCheckout: true
|
||
});
|
||
}
|
||
// Simple plan update for free plans or special cases
|
||
else {
|
||
user.plan = requestedPlan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
|
||
// Only set renewal date for paid plans
|
||
if (requestedPlan === 'hobby') {
|
||
user.subscriptionRenewsAt = null;
|
||
user.billingCycle = null;
|
||
user.subscriptionCurrency = null;
|
||
} else {
|
||
user.subscriptionRenewsAt = computeRenewalDate(requestedBillingCycle === 'yearly' ? 'yearly' : 'monthly');
|
||
}
|
||
|
||
updated = true;
|
||
}
|
||
}
|
||
|
||
// Handle currency updates
|
||
if (requestedCurrency && SUPPORTED_CURRENCIES.includes(requestedCurrency)) {
|
||
user.subscriptionCurrency = requestedCurrency;
|
||
user.currency = requestedCurrency;
|
||
updated = true;
|
||
}
|
||
|
||
// Handle billing cycle updates (separate from plan change)
|
||
if (requestedBillingCycle && BILLING_CYCLES.includes(requestedBillingCycle) && !requestedPlan) {
|
||
user.billingCycle = requestedBillingCycle;
|
||
if (user.plan !== 'hobby') {
|
||
user.subscriptionRenewsAt = computeRenewalDate(requestedBillingCycle);
|
||
}
|
||
updated = true;
|
||
}
|
||
|
||
if (typeof body.billingEmail === 'string' && body.billingEmail.trim()) {
|
||
const nextEmail = body.billingEmail.trim().toLowerCase();
|
||
if (!EMAIL_REGEX.test(nextEmail)) {
|
||
return sendJson(res, 400, { error: 'Billing email is invalid' });
|
||
}
|
||
user.billingEmail = nextEmail;
|
||
updated = true;
|
||
}
|
||
|
||
if (typeof body.unlimitedUsage === 'boolean') {
|
||
user.unlimitedUsage = body.unlimitedUsage;
|
||
updated = true;
|
||
}
|
||
|
||
const action = (body.action || '').toString().toLowerCase();
|
||
if (action === 'cancel') {
|
||
// Cancel Dodo subscription when user explicitly cancels
|
||
if (user.dodoSubscriptionId && PAID_PLANS.has(user.plan)) {
|
||
await cancelDodoSubscription(user, 'manual_cancel', { clearOnFailure: true });
|
||
}
|
||
user.billingStatus = 'canceled';
|
||
user.subscriptionRenewsAt = null;
|
||
updated = true;
|
||
} else if (action === 'resume') {
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
if (!user.subscriptionRenewsAt && user.plan !== 'hobby') {
|
||
user.subscriptionRenewsAt = computeRenewalDate(body.billingCycle === 'yearly' ? 'yearly' : 'monthly');
|
||
}
|
||
updated = true;
|
||
}
|
||
|
||
if (updated) {
|
||
const normalizedPlan = normalizePlanSelection(user.plan);
|
||
const previousNormalized = normalizePlanSelection(previousPlan);
|
||
if (PAID_PLANS.has(normalizedPlan) && previousNormalized !== normalizedPlan) {
|
||
await trackAffiliateCommission(user, normalizedPlan);
|
||
}
|
||
await persistUsersDb();
|
||
}
|
||
|
||
return sendJson(res, 200, { ok: true, account: await serializeAccount(user) });
|
||
}
|
||
|
||
async function handleOnboardingGet(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
sendJson(res, 200, { ok: true, completed: !!user.onboardingCompleted });
|
||
}
|
||
|
||
async function handleOnboardingPost(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const completed = !!body?.completed;
|
||
|
||
if (user.onboardingCompleted !== completed) {
|
||
user.onboardingCompleted = completed;
|
||
await persistUsersDb();
|
||
}
|
||
|
||
sendJson(res, 200, { ok: true, completed });
|
||
}
|
||
|
||
async function handlePaymentMethodsList(req, res, url) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 200, { paymentMethods: [] });
|
||
}
|
||
|
||
try {
|
||
let customerId = user.dodoCustomerId;
|
||
if (!customerId) {
|
||
const customers = await dodoRequest('/customers', {
|
||
method: 'POST',
|
||
body: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.email.split('@')[0],
|
||
metadata: { userId: String(user.id) },
|
||
},
|
||
});
|
||
customerId = customers?.customer_id || customers?.id;
|
||
if (customerId) {
|
||
user.dodoCustomerId = customerId;
|
||
await persistUsersDb();
|
||
}
|
||
}
|
||
|
||
if (!customerId) {
|
||
return sendJson(res, 200, { paymentMethods: [] });
|
||
}
|
||
|
||
const response = await dodoRequest(`/customers/${customerId}/payment-methods`, { method: 'GET' });
|
||
const paymentMethods = Array.isArray(response) ? response : response?.items || [];
|
||
|
||
const serializedMethods = paymentMethods.map((pm, index) => {
|
||
const card = pm.card || {};
|
||
return {
|
||
id: pm.payment_method_id,
|
||
brand: card.brand || pm.payment_method || 'Card',
|
||
last4: card.last4 || card.last_digits || '',
|
||
expiresAt: card.expiry || card.expires_at || '',
|
||
isDefault: index === 0,
|
||
};
|
||
});
|
||
|
||
return sendJson(res, 200, { paymentMethods: serializedMethods });
|
||
} catch (err) {
|
||
console.error('[PaymentMethods] List error:', err.message);
|
||
return sendJson(res, 200, { paymentMethods: [] });
|
||
}
|
||
}
|
||
|
||
async function handlePaymentMethodCreate(req, res) {
|
||
const userId = requireUserId(req, res, new URL(req.url, `http://${req.headers.host}`));
|
||
if (!userId) return;
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
}
|
||
|
||
try {
|
||
let customerId = user.dodoCustomerId;
|
||
if (!customerId) {
|
||
const customers = await dodoRequest('/customers', {
|
||
method: 'POST',
|
||
body: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.email.split('@')[0],
|
||
metadata: { userId: String(user.id) },
|
||
},
|
||
});
|
||
customerId = customers?.customer_id || customers?.id;
|
||
if (customerId) {
|
||
user.dodoCustomerId = customerId;
|
||
await persistUsersDb();
|
||
}
|
||
}
|
||
|
||
if (!customerId) {
|
||
return sendJson(res, 500, { error: 'Unable to create customer' });
|
||
}
|
||
|
||
const returnUrl = `${resolveBaseUrl(req)}/settings?payment_method_added=1`;
|
||
|
||
const portalSession = await dodoRequest(`/customers/${customerId}/customer-portal/session`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
const portalUrl = portalSession?.link || portalSession?.url;
|
||
if (!portalUrl) {
|
||
return sendJson(res, 500, { error: 'Unable to create customer portal session' });
|
||
}
|
||
|
||
// The frontend expects a checkoutUrl or url field. Return all common keys so
|
||
// clients (existing and future) can open the portal to add/save cards without purchasing.
|
||
return sendJson(res, 200, { portalUrl, checkoutUrl: portalUrl, url: portalUrl });
|
||
} catch (err) {
|
||
console.error('[PaymentMethod] Create error:', err.message);
|
||
return sendJson(res, 500, { error: 'Unable to create customer portal session' });
|
||
}
|
||
}
|
||
|
||
async function handleAccountBalanceAdd(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const body = await parseJsonBody(req);
|
||
const amount = Number(body?.amount);
|
||
const currency = String(body?.currency || user.currency || 'usd').toLowerCase();
|
||
|
||
if (!Number.isFinite(amount) || amount < MIN_PAYMENT_AMOUNT) {
|
||
return sendJson(res, 400, { error: `Minimum amount is $${(MIN_PAYMENT_AMOUNT / 100).toFixed(2)}` });
|
||
}
|
||
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
}
|
||
|
||
try {
|
||
let customerId = user.dodoCustomerId;
|
||
if (!customerId) {
|
||
const customers = await dodoRequest('/customers', {
|
||
method: 'POST',
|
||
body: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.email.split('@')[0],
|
||
metadata: { userId: String(user.id) },
|
||
},
|
||
});
|
||
customerId = customers?.customer_id || customers?.id;
|
||
if (customerId) {
|
||
user.dodoCustomerId = customerId;
|
||
await persistUsersDb();
|
||
}
|
||
}
|
||
|
||
if (!customerId) {
|
||
return sendJson(res, 500, { error: 'Unable to create customer' });
|
||
}
|
||
|
||
const returnUrl = `${resolveBaseUrl(req)}/settings?balance_added=1`;
|
||
|
||
const sessionId = `balance_${randomUUID()}`;
|
||
|
||
pendingSubscriptions[sessionId] = {
|
||
userId: user.id,
|
||
amount,
|
||
currency,
|
||
type: 'balance_add',
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
await persistPendingSubscriptions();
|
||
|
||
const productConfigured = Boolean(TOPUP_PRODUCT_IDS[`topup_1_${currency}`]);
|
||
|
||
const checkoutSession = await dodoRequest('/checkout/sessions', {
|
||
method: 'POST',
|
||
body: {
|
||
customer_id: customerId,
|
||
success_url: returnUrl,
|
||
cancel_url: returnUrl,
|
||
mode: 'payment',
|
||
payment_method_types: ['card'],
|
||
line_items: [
|
||
{
|
||
amount: amount,
|
||
currency: currency.toUpperCase(),
|
||
quantity: 1,
|
||
name: `Add Funds - ${currency.toUpperCase()}${(amount / 100).toFixed(2)}`,
|
||
description: `Add funds to account balance`,
|
||
metadata: {
|
||
userId: String(user.id),
|
||
sessionId,
|
||
type: 'balance_add',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const checkoutUrl = checkoutSession?.checkout_url || checkoutSession?.url;
|
||
if (!checkoutUrl) {
|
||
return sendJson(res, 500, { error: 'Unable to create checkout session' });
|
||
}
|
||
|
||
return sendJson(res, 200, { checkoutUrl, sessionId });
|
||
} catch (err) {
|
||
console.error('[Balance] Add error:', err.message);
|
||
return sendJson(res, 500, { error: 'Unable to create checkout' });
|
||
}
|
||
}
|
||
|
||
async function handlePaymentMethodSetDefault(req, res, methodId) {
|
||
const userId = requireUserId(req, res, new URL(req.url, `http://${req.headers.host}`));
|
||
if (!userId) return;
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
}
|
||
|
||
try {
|
||
let customerId = user.dodoCustomerId;
|
||
if (!customerId) {
|
||
return sendJson(res, 400, { error: 'Customer not found. Please add a payment method first.' });
|
||
}
|
||
|
||
const returnUrl = `${resolveBaseUrl(req)}/settings`;
|
||
|
||
const portalSession = await dodoRequest(`/customers/${customerId}/customer-portal/session`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
const portalUrl = portalSession?.link || portalSession?.url;
|
||
if (!portalUrl) {
|
||
return sendJson(res, 500, { error: 'Unable to create customer portal session' });
|
||
}
|
||
|
||
return sendJson(res, 200, { portalUrl, message: 'Please manage payment methods through the customer portal' });
|
||
} catch (err) {
|
||
console.error('[PaymentMethod] Set default error:', err.message);
|
||
return sendJson(res, 500, { error: 'Unable to update payment method. Please try again.' });
|
||
}
|
||
}
|
||
|
||
async function handlePaymentMethodDelete(req, res, methodId) {
|
||
const userId = requireUserId(req, res, new URL(req.url, `http://${req.headers.host}`));
|
||
if (!userId) return;
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
}
|
||
|
||
try {
|
||
let customerId = user.dodoCustomerId;
|
||
if (!customerId) {
|
||
return sendJson(res, 400, { error: 'Customer not found. Please add a payment method first.' });
|
||
}
|
||
|
||
const returnUrl = `${resolveBaseUrl(req)}/settings`;
|
||
|
||
const portalSession = await dodoRequest(`/customers/${customerId}/customer-portal/session`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
const portalUrl = portalSession?.link || portalSession?.url;
|
||
if (!portalUrl) {
|
||
return sendJson(res, 500, { error: 'Unable to create customer portal session' });
|
||
}
|
||
|
||
return sendJson(res, 200, { portalUrl, message: 'Please manage payment methods through the customer portal' });
|
||
} catch (err) {
|
||
console.error('[PaymentMethod] Delete error:', err.message);
|
||
return sendJson(res, 500, { error: 'Unable to delete payment method. Please try again.' });
|
||
}
|
||
}
|
||
|
||
async function fetchTopupProduct(tier, currency = 'usd') {
|
||
const pack = resolveTopupPack(tier, currency);
|
||
if (!pack.productId) throw new Error('Top-up product is not configured');
|
||
const product = await getDodoProductById(pack.productId);
|
||
if (!product) throw new Error('Top-up product is unavailable');
|
||
const baseAmount = getTopupPrice(pack.tier, pack.currency);
|
||
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Top-up price amount is invalid');
|
||
return { pack, product, baseAmount, currency: pack.currency };
|
||
}
|
||
|
||
async function handleTopupOptions(req, res, userId) {
|
||
try {
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) {
|
||
return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
}
|
||
const discount = computeTopupDiscount(user.plan);
|
||
const tiers = ['topup_1', 'topup_2', 'topup_3', 'topup_4'];
|
||
const currencies = ['usd', 'gbp', 'eur'];
|
||
const options = [];
|
||
for (const tier of tiers) {
|
||
for (const currency of currencies) {
|
||
try {
|
||
const pack = resolveTopupPack(tier, currency);
|
||
if (!pack.productId || !pack.tokens) continue;
|
||
const baseAmount = getTopupPrice(tier, currency);
|
||
if (!baseAmount || baseAmount <= 0) continue;
|
||
const finalAmount = applyTopupDiscount(baseAmount, discount);
|
||
options.push({
|
||
tier: pack.tier,
|
||
currency: pack.currency,
|
||
tokens: pack.tokens,
|
||
productId: pack.productId,
|
||
baseAmount,
|
||
finalAmount,
|
||
discountRate: discount,
|
||
});
|
||
} catch (packErr) {
|
||
// Log per-pack errors but continue building remaining options
|
||
log('topup options pack error', { tier, currency, error: String(packErr), stack: packErr.stack });
|
||
}
|
||
}
|
||
}
|
||
if (!options.length) return sendJson(res, 503, { error: 'Top-up products are not configured' });
|
||
return sendJson(res, 200, { options, discount, discountRate: discount });
|
||
} catch (error) {
|
||
log('topup options failed', { error: String(error), stack: error.stack });
|
||
return sendJson(res, 500, { error: error.message || 'Unable to fetch top-up options' });
|
||
}
|
||
}
|
||
|
||
async function handleTopupCheckout(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
try {
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const tier = body?.tier || 'topup_1';
|
||
const currency = body?.currency || 'usd';
|
||
const isInline = Boolean(body.inline);
|
||
const { pack, baseAmount } = await fetchTopupProduct(tier, currency);
|
||
if (!pack.tokens) throw new Error('Top-up tokens are not configured');
|
||
const discount = computeTopupDiscount(user.plan);
|
||
const unitAmount = applyTopupDiscount(baseAmount, discount);
|
||
const returnUrl = `${resolveBaseUrl(req)}/topup`;
|
||
const orderId = `topup_${randomUUID()}`;
|
||
const checkoutBody = {
|
||
product_cart: [{
|
||
product_id: pack.productId,
|
||
quantity: 1,
|
||
amount: unitAmount,
|
||
}],
|
||
customer: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.billingEmail || user.email,
|
||
},
|
||
metadata: {
|
||
type: 'topup',
|
||
orderId,
|
||
userId: String(user.id),
|
||
tokens: String(pack.tokens),
|
||
tier: String(pack.tier),
|
||
currency: String(pack.currency),
|
||
amount: String(unitAmount),
|
||
discountRate: String(discount),
|
||
inline: String(isInline),
|
||
},
|
||
settings: {
|
||
redirect_immediately: false,
|
||
},
|
||
return_url: returnUrl,
|
||
};
|
||
const checkoutSession = await dodoRequest('/checkouts', {
|
||
method: 'POST',
|
||
body: checkoutBody,
|
||
});
|
||
const sessionId = checkoutSession?.session_id || checkoutSession?.id || '';
|
||
if (!sessionId || !checkoutSession?.checkout_url) {
|
||
throw new Error('Dodo checkout session was not created');
|
||
}
|
||
pendingTopups[sessionId] = {
|
||
userId: user.id,
|
||
orderId,
|
||
tokens: pack.tokens,
|
||
tier: pack.tier,
|
||
currency: pack.currency,
|
||
amount: unitAmount,
|
||
productId: pack.productId,
|
||
createdAt: new Date().toISOString(),
|
||
inline: isInline,
|
||
};
|
||
await persistPendingTopups();
|
||
|
||
const response = {
|
||
sessionId,
|
||
amount: unitAmount,
|
||
currency,
|
||
tokens: pack.tokens,
|
||
};
|
||
|
||
if (isInline) {
|
||
response.inlineCheckoutUrl = checkoutSession.checkout_url;
|
||
response.checkoutUrl = checkoutSession.checkout_url;
|
||
} else {
|
||
response.url = checkoutSession.checkout_url;
|
||
response.checkoutUrl = checkoutSession.checkout_url;
|
||
}
|
||
|
||
return sendJson(res, 200, response);
|
||
} catch (error) {
|
||
log('top-up checkout failed', { error: String(error), userId: user?.id });
|
||
const status = /configured/i.test(String(error?.message || '')) ? 503 : 400;
|
||
return sendJson(res, status, { error: error.message || 'Unable to start checkout' });
|
||
}
|
||
}
|
||
|
||
function isDodoPaymentComplete(status) {
|
||
const normalized = String(status || '').toLowerCase();
|
||
return ['paid', 'succeeded', 'success', 'completed', 'complete'].includes(normalized);
|
||
}
|
||
|
||
async function handleTopupConfirm(req, res, url) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
const sessionId = (url && url.searchParams && (url.searchParams.get('session_id') || url.searchParams.get('checkout_id') || url.searchParams.get('session'))) || '';
|
||
if (!sessionId) return sendJson(res, 400, { error: 'session_id is required' });
|
||
|
||
const existing = processedTopups[sessionId];
|
||
if (existing) {
|
||
if (existing.userId !== user.id) return sendJson(res, 403, { error: 'This top-up belongs to another user' });
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'topup', {
|
||
tokens: existing.tokens,
|
||
amount: existing.amount,
|
||
currency: existing.currency,
|
||
tier: existing.tier,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: sessionId,
|
||
orderId: existing.orderId,
|
||
paymentId: existing.paymentId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
return sendJson(res, 200, { ok: true, alreadyApplied: true, tokensAdded: existing.tokens, summary: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN) });
|
||
}
|
||
|
||
const pending = pendingTopups[sessionId];
|
||
if (pending && pending.userId !== user.id) {
|
||
return sendJson(res, 403, { error: 'This top-up belongs to another user' });
|
||
}
|
||
|
||
try {
|
||
const checkout = await dodoRequest(`/checkouts/${sessionId}`, { method: 'GET' });
|
||
const paid = isDodoPaymentComplete(checkout?.payment_status || checkout?.status);
|
||
if (!paid) {
|
||
return sendJson(res, 200, { ok: false, pending: true, paymentStatus: checkout?.payment_status || checkout?.status || 'unpaid' });
|
||
}
|
||
const tokens = Number(pending?.tokens || checkout?.metadata?.tokens || 0);
|
||
if (!tokens) return sendJson(res, 400, { error: 'Top-up tokens missing' });
|
||
|
||
const orderId = pending?.orderId || checkout?.metadata?.orderId || '';
|
||
const tier = pending?.tier || checkout?.metadata?.tier || '';
|
||
const currency = String(pending?.currency || checkout?.metadata?.currency || checkout?.currency || '').toLowerCase() || null;
|
||
|
||
const amountCandidate = pending?.amount !== undefined && pending?.amount !== null
|
||
? Number(pending.amount)
|
||
: Number(checkout?.metadata?.amount || checkout?.amount || checkout?.amount_total || checkout?.total_amount || 0);
|
||
const amount = Number.isFinite(amountCandidate) ? Math.max(0, Math.round(amountCandidate)) : null;
|
||
|
||
const paymentId = checkout?.payment_id || checkout?.paymentId || checkout?.charge_id || checkout?.chargeId || '';
|
||
|
||
const bucket = ensureTokenUsageBucket(user.id);
|
||
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
|
||
await persistTokenUsage();
|
||
|
||
processedTopups[sessionId] = {
|
||
userId: user.id,
|
||
orderId: orderId || null,
|
||
paymentId: paymentId || null,
|
||
tokens,
|
||
tier: tier || null,
|
||
amount,
|
||
currency,
|
||
completedAt: new Date().toISOString(),
|
||
};
|
||
delete pendingTopups[sessionId];
|
||
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
|
||
|
||
await sendPaymentConfirmationEmail(user, 'topup', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
});
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'topup', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
tier,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: sessionId,
|
||
orderId,
|
||
paymentId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
return sendJson(res, 200, { ok: true, tokensAdded: tokens, summary: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN) });
|
||
} catch (error) {
|
||
log('top-up confirmation failed', { error: String(error), userId: user?.id, sessionId });
|
||
return sendJson(res, 400, { error: error.message || 'Unable to confirm payment' });
|
||
}
|
||
|
||
// -------------------------
|
||
// Admin test endpoints for Dodo top-ups
|
||
// -------------------------
|
||
async function handleAdminTopupOptions(req, res) {
|
||
try {
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
const discount = 0; // admin tests run without user plan discount
|
||
const tiers = ['topup_1', 'topup_2', 'topup_3', 'topup_4'];
|
||
const currencies = ['usd', 'gbp', 'eur'];
|
||
const options = [];
|
||
|
||
// Quick debug log to surface configuration state when this endpoint is called
|
||
try {
|
||
const productKeys = TOPUP_PRODUCT_IDS && typeof TOPUP_PRODUCT_IDS === 'object' ? Object.keys(TOPUP_PRODUCT_IDS) : [];
|
||
log('admin topup options debug start', {
|
||
DODO_ENABLED,
|
||
productKeysCount: productKeys.length,
|
||
productKeySample: productKeys.slice(0, 6),
|
||
topupTokensKeys: Object.keys(TOPUP_TOKENS || {}).slice(0, 6),
|
||
topupPricesKeys: Object.keys(TOPUP_PRICES || {}).slice(0, 6),
|
||
});
|
||
} catch (dbgErr) {
|
||
log('admin topup options debug logging failed', { dbgErr: String(dbgErr) });
|
||
}
|
||
|
||
// Build options similarly to the user-facing flow. Allow missing TOPUP_PRODUCT_IDS but log a warning so
|
||
// admins can still use the test page in environments where product IDs may not be pre-populated.
|
||
if (!TOPUP_PRODUCT_IDS || typeof TOPUP_PRODUCT_IDS !== 'object') {
|
||
log('admin topup options warning', { reason: 'TOPUP_PRODUCT_IDS missing or invalid - attempting to build options from fallback data' });
|
||
}
|
||
|
||
for (const tier of tiers) {
|
||
for (const currency of currencies) {
|
||
try {
|
||
const pack = resolveTopupPack(tier, currency);
|
||
if (!pack || !pack.productId || !pack.tokens) {
|
||
log('admin topup options skip pack', { tier, currency, pack });
|
||
continue;
|
||
}
|
||
|
||
const baseAmount = getTopupPrice(tier, currency);
|
||
if (!baseAmount || baseAmount <= 0) {
|
||
log('admin topup options skip price', { tier, currency, baseAmount });
|
||
continue;
|
||
}
|
||
|
||
const finalAmount = applyTopupDiscount(baseAmount, discount);
|
||
options.push({
|
||
tier: pack.tier,
|
||
currency: pack.currency,
|
||
tokens: pack.tokens,
|
||
productId: pack.productId,
|
||
baseAmount,
|
||
finalAmount,
|
||
discountRate: discount,
|
||
});
|
||
} catch (packErr) {
|
||
// Log per-pack errors but continue building remaining options
|
||
log('admin topup options pack error', { tier, currency, error: String(packErr), stack: packErr.stack });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!options.length) {
|
||
log('admin topup options none found', { TOPUP_PRODUCT_IDS: Object.keys(TOPUP_PRODUCT_IDS || {}).slice(0, 10) });
|
||
return sendJson(res, 503, { error: 'Top-up products are not configured' });
|
||
}
|
||
|
||
log('admin topup options success', { optionsCount: options.length });
|
||
return sendJson(res, 200, { options, discount, discountRate: discount });
|
||
} catch (error) {
|
||
log('admin topup options failed', { error: String(error), stack: error.stack });
|
||
return sendJson(res, 500, { error: error.message || 'Unable to fetch admin top-up options' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminTopupCheckout(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
try {
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const tier = body?.tier || 'topup_1';
|
||
const currency = (body?.currency || 'usd').toLowerCase();
|
||
const isInline = Boolean(body.inline);
|
||
const customer = body.customer || {};
|
||
|
||
const { pack, baseAmount } = await fetchTopupProduct(tier, currency);
|
||
if (!pack.tokens) throw new Error('Top-up tokens are not configured');
|
||
const unitAmount = applyTopupDiscount(baseAmount, 0);
|
||
const returnUrl = `${resolveBaseUrl(req)}/test-checkout`;
|
||
const orderId = `admin_test_topup_${randomUUID()}`;
|
||
|
||
const checkoutBody = {
|
||
product_cart: [{
|
||
product_id: pack.productId,
|
||
quantity: 1,
|
||
amount: unitAmount,
|
||
}],
|
||
customer: {
|
||
email: (customer.email || ADMIN_USER || 'admin@example.com'),
|
||
name: (customer.name || ADMIN_USER || 'Admin'),
|
||
},
|
||
metadata: {
|
||
type: 'admin_test_topup',
|
||
orderId,
|
||
admin: 'true',
|
||
adminToken: session.token || '',
|
||
tokens: String(pack.tokens),
|
||
tier: String(pack.tier),
|
||
currency: String(pack.currency),
|
||
amount: String(unitAmount),
|
||
},
|
||
settings: {
|
||
redirect_immediately: false,
|
||
},
|
||
return_url: returnUrl,
|
||
};
|
||
|
||
const checkoutSession = await dodoRequest('/checkouts', {
|
||
method: 'POST',
|
||
body: checkoutBody,
|
||
});
|
||
|
||
const sessionId = checkoutSession?.session_id || checkoutSession?.id || '';
|
||
if (!sessionId || !checkoutSession?.checkout_url) {
|
||
throw new Error('Dodo checkout session was not created');
|
||
}
|
||
|
||
pendingTopups[sessionId] = {
|
||
userId: null,
|
||
admin: true,
|
||
adminToken: session.token,
|
||
orderId,
|
||
tokens: pack.tokens,
|
||
tier: pack.tier,
|
||
currency: pack.currency,
|
||
amount: unitAmount,
|
||
productId: pack.productId,
|
||
createdAt: new Date().toISOString(),
|
||
inline: isInline,
|
||
};
|
||
|
||
await persistPendingTopups();
|
||
|
||
const response = {
|
||
sessionId,
|
||
amount: unitAmount,
|
||
currency,
|
||
tokens: pack.tokens,
|
||
};
|
||
|
||
if (isInline) {
|
||
response.inlineCheckoutUrl = checkoutSession.checkout_url;
|
||
response.checkoutUrl = checkoutSession.checkout_url;
|
||
} else {
|
||
response.checkoutUrl = checkoutSession.checkout_url;
|
||
response.url = checkoutSession.checkout_url;
|
||
}
|
||
|
||
return sendJson(res, 200, response);
|
||
} catch (error) {
|
||
log('admin top-up checkout failed', { error: String(error) });
|
||
const status = /configured/i.test(String(error?.message || '')) ? 503 : 400;
|
||
return sendJson(res, status, { error: error.message || 'Unable to start admin checkout' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminTopupConfirm(req, res, url) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
const sessionId = (url && url.searchParams && (url.searchParams.get('session_id') || url.searchParams.get('checkout_id') || url.searchParams.get('session'))) || '';
|
||
if (!sessionId) return sendJson(res, 400, { error: 'session_id is required' });
|
||
|
||
try {
|
||
const checkout = await dodoRequest(`/checkouts/${sessionId}`, { method: 'GET' });
|
||
const paid = isDodoPaymentComplete(checkout?.payment_status || checkout?.status);
|
||
if (!paid) {
|
||
return sendJson(res, 200, { ok: false, pending: true, paymentStatus: checkout?.payment_status || checkout?.status || 'unpaid' });
|
||
}
|
||
|
||
// Admin test confirmation does not apply tokens to any user; return details for inspection
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
paid: true,
|
||
sessionId,
|
||
amount: checkout?.amount || checkout?.amount_total || checkout?.total_amount || null,
|
||
currency: checkout?.currency || null,
|
||
metadata: checkout?.metadata || null,
|
||
checkout,
|
||
});
|
||
} catch (error) {
|
||
log('admin top-up confirmation failed', { error: String(error), sessionId });
|
||
return sendJson(res, 400, { error: error.message || 'Unable to confirm admin checkout' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminMe(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return sendJson(res, 200, { ok: true, admin: { username: ADMIN_USER || 'admin' } });
|
||
}
|
||
}
|
||
|
||
function resolvePaygProduct(currency = 'usd') {
|
||
const normalized = String(currency || 'usd').toLowerCase();
|
||
return {
|
||
currency: normalized,
|
||
productId: PAYG_PRODUCT_IDS[normalized] || '',
|
||
};
|
||
}
|
||
|
||
async function handlePaygStatus(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
const plan = user.plan || DEFAULT_PLAN;
|
||
if (!isPaidPlan(plan)) return sendJson(res, 200, { ok: true, payg: null, plan });
|
||
const payg = (PAYG_ENABLED && !user?.unlimitedUsage) ? computePaygSummary(user.id, plan) : null;
|
||
const pending = Object.fromEntries(Object.entries(pendingPayg || {}).filter(([, entry]) => entry && entry.userId === user.id));
|
||
return sendJson(res, 200, { ok: true, plan, payg, pending });
|
||
}
|
||
|
||
async function handlePaygCheckout(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
const plan = user.plan || DEFAULT_PLAN;
|
||
if (!PAYG_ENABLED) return sendJson(res, 503, { error: 'Pay-as-you-go billing is not enabled' });
|
||
if (!isPaidPlan(plan)) return sendJson(res, 400, { error: 'Pay-as-you-go is only available on paid plans' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
const payg = computePaygSummary(user.id, plan);
|
||
if (payg.billableTokens <= 0 || payg.amount <= 0) {
|
||
return sendJson(res, 400, { error: 'No pay-as-you-go usage to bill', payg });
|
||
}
|
||
|
||
if (PAYG_MIN_TOKENS > 0 && payg.billableTokens < PAYG_MIN_TOKENS) {
|
||
return sendJson(res, 400, { error: `Minimum overage to bill is ${PAYG_MIN_TOKENS.toLocaleString()} tokens`, payg });
|
||
}
|
||
|
||
const { productId, currency } = resolvePaygProduct(payg.currency);
|
||
if (!productId) {
|
||
return sendJson(res, 503, { error: `Pay-as-you-go product ID for ${currency.toUpperCase()} is not configured` });
|
||
}
|
||
|
||
const amount = Math.max(MIN_PAYMENT_AMOUNT, Math.ceil((payg.billableTokens * payg.pricePerUnit) / PAYG_UNIT_TOKENS));
|
||
const returnUrl = `${resolveBaseUrl(req)}/settings`;
|
||
const orderId = `payg_${randomUUID()}`;
|
||
|
||
try {
|
||
const checkoutSession = await dodoRequest('/checkouts', {
|
||
method: 'POST',
|
||
body: {
|
||
product_cart: [{
|
||
product_id: productId,
|
||
quantity: 1,
|
||
amount,
|
||
}],
|
||
customer: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.billingEmail || user.email,
|
||
},
|
||
return_url: returnUrl,
|
||
metadata: {
|
||
type: 'payg',
|
||
orderId,
|
||
userId: String(user.id),
|
||
payg: 'true',
|
||
tokens: String(payg.billableTokens),
|
||
currency: String(currency),
|
||
amount: String(amount),
|
||
month: String(payg.month),
|
||
},
|
||
},
|
||
});
|
||
|
||
const sessionId = checkoutSession?.session_id || checkoutSession?.id || '';
|
||
if (!sessionId || !checkoutSession?.checkout_url) throw new Error('Dodo checkout session was not created');
|
||
|
||
pendingPayg[sessionId] = {
|
||
userId: user.id,
|
||
orderId,
|
||
tokens: payg.billableTokens,
|
||
amount,
|
||
currency,
|
||
month: payg.month,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
await persistPendingPayg();
|
||
|
||
return sendJson(res, 200, {
|
||
url: checkoutSession.checkout_url,
|
||
checkoutUrl: checkoutSession.checkout_url,
|
||
sessionId,
|
||
amount,
|
||
currency,
|
||
tokens: payg.billableTokens,
|
||
payg,
|
||
});
|
||
} catch (error) {
|
||
log('payg checkout failed', { error: String(error), userId: user?.id });
|
||
const status = /configured/i.test(String(error?.message || '')) ? 503 : 400;
|
||
return sendJson(res, status, { error: error.message || 'Unable to start pay-as-you-go checkout' });
|
||
}
|
||
}
|
||
|
||
async function handlePaygConfirm(req, res, url) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
const sessionId = (url && url.searchParams && (url.searchParams.get('session_id') || url.searchParams.get('checkout_id') || url.searchParams.get('session'))) || '';
|
||
if (!sessionId) return sendJson(res, 400, { error: 'session_id is required' });
|
||
|
||
const existing = processedPayg[sessionId];
|
||
if (existing) {
|
||
if (existing.userId !== user.id) return sendJson(res, 403, { error: 'This pay-as-you-go charge belongs to another user' });
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'payg', {
|
||
tokens: existing.tokens,
|
||
amount: existing.amount,
|
||
currency: existing.currency,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: sessionId,
|
||
orderId: existing.orderId,
|
||
paymentId: existing.paymentId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
return sendJson(res, 200, { ok: true, alreadyApplied: true, payg: computePaygSummary(user.id, user.plan || DEFAULT_PLAN) });
|
||
}
|
||
|
||
const pending = pendingPayg[sessionId];
|
||
if (pending && pending.userId !== user.id) {
|
||
return sendJson(res, 403, { error: 'This pay-as-you-go charge belongs to another user' });
|
||
}
|
||
|
||
try {
|
||
const checkout = await dodoRequest(`/checkouts/${sessionId}`, { method: 'GET' });
|
||
const paid = isDodoPaymentComplete(checkout?.payment_status || checkout?.status);
|
||
if (!paid) {
|
||
return sendJson(res, 200, { ok: false, pending: true, paymentStatus: checkout?.payment_status || checkout?.status || 'unpaid' });
|
||
}
|
||
|
||
const tokens = Number(pending?.tokens || checkout?.metadata?.tokens || 0);
|
||
if (!tokens) return sendJson(res, 400, { error: 'Pay-as-you-go tokens missing' });
|
||
|
||
const bucket = ensureTokenUsageBucket(user.id);
|
||
if (!pending?.month || pending.month === bucket.month) {
|
||
bucket.paygBilled = Math.max(0, Number(bucket.paygBilled || 0) + tokens);
|
||
await persistTokenUsage();
|
||
}
|
||
|
||
const orderId = pending?.orderId || checkout?.metadata?.orderId || '';
|
||
const currency = String(pending?.currency || checkout?.metadata?.currency || checkout?.currency || '').toLowerCase() || null;
|
||
|
||
const amountCandidate = pending?.amount !== undefined && pending?.amount !== null
|
||
? Number(pending.amount)
|
||
: Number(checkout?.metadata?.amount || checkout?.amount || checkout?.amount_total || checkout?.total_amount || 0);
|
||
const amount = Number.isFinite(amountCandidate) ? Math.max(0, Math.round(amountCandidate)) : null;
|
||
|
||
const paymentId = checkout?.payment_id || checkout?.paymentId || checkout?.charge_id || checkout?.chargeId || '';
|
||
|
||
processedPayg[sessionId] = {
|
||
userId: user.id,
|
||
orderId: orderId || null,
|
||
paymentId: paymentId || null,
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
completedAt: new Date().toISOString(),
|
||
month: pending?.month || bucket.month,
|
||
};
|
||
delete pendingPayg[sessionId];
|
||
await Promise.all([persistPaygSessions(), persistPendingPayg()]);
|
||
|
||
await sendPaymentConfirmationEmail(user, 'payg', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
});
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'payg', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: sessionId,
|
||
orderId,
|
||
paymentId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
return sendJson(res, 200, { ok: true, tokensBilled: tokens, payg: computePaygSummary(user.id, user.plan || DEFAULT_PLAN) });
|
||
} catch (error) {
|
||
log('payg confirmation failed', { error: String(error), userId: user?.id, sessionId });
|
||
return sendJson(res, 400, { error: error.message || 'Unable to confirm pay-as-you-go payment' });
|
||
}
|
||
}
|
||
|
||
// Handle subscription checkout creation
|
||
async function handleSubscriptionCheckout(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const plan = normalizePlanSelection(body.plan);
|
||
const billingCycle = String(body.billingCycle || 'monthly').toLowerCase();
|
||
const currency = String(body.currency || 'usd').toLowerCase();
|
||
const isInline = Boolean(body.inline);
|
||
// If the client provided a previousPlan from the UI, prefer that for previous-subscription detection.
|
||
const previousPlan = normalizePlanSelection((body && body.previousPlan) || user.plan);
|
||
const previousSubscriptionId = (
|
||
user.dodoSubscriptionId &&
|
||
PAID_PLANS.has(previousPlan || '') &&
|
||
PAID_PLANS.has(plan) &&
|
||
previousPlan !== plan
|
||
) ? user.dodoSubscriptionId : '';
|
||
|
||
if (previousSubscriptionId) {
|
||
log('subscription checkout: detected previous subscription to cancel after confirm', { userId: user.id, previousPlan, previousSubscriptionId, requestedPlan: plan });
|
||
}
|
||
|
||
if (!plan) {
|
||
return sendJson(res, 400, { error: 'Plan is required' });
|
||
}
|
||
|
||
// Hobby plan is free, no checkout needed
|
||
if (plan === 'hobby') {
|
||
user.plan = plan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.subscriptionRenewsAt = null;
|
||
user.billingCycle = null;
|
||
user.subscriptionCurrency = null;
|
||
await persistUsersDb();
|
||
|
||
log('hobby plan selected (free)', { userId: user.id, email: user.email, plan });
|
||
return sendJson(res, 200, { ok: true, plan: 'hobby', message: 'Free hobby plan activated' });
|
||
}
|
||
|
||
// Validate subscription selection
|
||
if (!validateSubscriptionSelection(plan, billingCycle, currency)) {
|
||
return sendJson(res, 400, { error: 'Invalid plan, billing cycle, or currency combination' });
|
||
}
|
||
|
||
const product = resolveSubscriptionProduct(plan, billingCycle, currency);
|
||
if (!product) {
|
||
return sendJson(res, 400, { error: 'Subscription product not available' });
|
||
}
|
||
|
||
// Create a unique session ID for this checkout
|
||
const checkoutSessionId = randomUUID();
|
||
|
||
// Create checkout session with enhanced metadata for session tracking
|
||
const returnUrl = `${resolveBaseUrl(req)}/apps`;
|
||
const checkoutBody = {
|
||
product_cart: [{
|
||
product_id: product.productId,
|
||
quantity: 1,
|
||
}],
|
||
customer: {
|
||
email: user.billingEmail || user.email,
|
||
name: user.billingEmail || user.email,
|
||
},
|
||
metadata: {
|
||
type: 'subscription',
|
||
orderId: String(checkoutSessionId),
|
||
userId: String(user.id),
|
||
sessionId: String(checkoutSessionId),
|
||
plan: String(plan),
|
||
billingCycle: String(billingCycle),
|
||
currency: String(currency),
|
||
amount: String(product.price),
|
||
inline: String(isInline),
|
||
},
|
||
settings: {
|
||
allow_payment_methods: ['card'],
|
||
redirect_immediately: !isInline,
|
||
},
|
||
};
|
||
checkoutBody.return_url = returnUrl;
|
||
const checkoutSession = await dodoRequest('/checkouts', {
|
||
method: 'POST',
|
||
body: checkoutBody,
|
||
});
|
||
|
||
const sessionId = checkoutSession?.session_id || checkoutSession?.id || '';
|
||
if (!sessionId || !checkoutSession?.checkout_url) {
|
||
throw new Error('Dodo checkout session was not created');
|
||
}
|
||
|
||
// Store pending subscription with enhanced tracking
|
||
pendingSubscriptions[sessionId] = {
|
||
userId: user.id,
|
||
orderId: checkoutSessionId,
|
||
plan: plan,
|
||
billingCycle: billingCycle,
|
||
currency: currency,
|
||
productId: product.productId,
|
||
price: product.price,
|
||
checkoutSessionId,
|
||
previousSubscriptionId,
|
||
createdAt: new Date().toISOString(),
|
||
inline: isInline,
|
||
};
|
||
await persistPendingSubscriptions();
|
||
|
||
// Return appropriate checkout URL based on request type
|
||
const response = {
|
||
sessionId,
|
||
plan,
|
||
billingCycle,
|
||
currency,
|
||
price: product.price,
|
||
};
|
||
|
||
if (isInline) {
|
||
// For inline checkout, return the same URL but mark it as inline
|
||
response.inlineCheckoutUrl = checkoutSession.checkout_url;
|
||
response.checkoutUrl = checkoutSession.checkout_url; // Keep for backward compatibility
|
||
} else {
|
||
response.checkoutUrl = checkoutSession.checkout_url;
|
||
response.url = checkoutSession.checkout_url;
|
||
}
|
||
|
||
return sendJson(res, 200, response);
|
||
} catch (error) {
|
||
log('subscription checkout failed', { error: String(error), userId: user?.id });
|
||
const status = /configured/i.test(String(error?.message || '')) ? 503 : 400;
|
||
return sendJson(res, status, { error: error.message || 'Unable to start subscription checkout' });
|
||
}
|
||
}
|
||
|
||
// Handle subscription confirmation
|
||
async function handleSubscriptionConfirm(req, res, url) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||
|
||
const sessionId = (url && url.searchParams && (url.searchParams.get('session_id') || url.searchParams.get('checkout_id') || url.searchParams.get('session'))) || '';
|
||
if (!sessionId) return sendJson(res, 400, { error: 'session_id is required' });
|
||
|
||
let processed = processedSubscriptions[sessionId];
|
||
let processedKey = sessionId;
|
||
|
||
if (!processed) {
|
||
for (const [key, value] of Object.entries(processedSubscriptions)) {
|
||
if (value?.checkoutSessionId === sessionId || value?.orderId === sessionId) {
|
||
processed = value;
|
||
processedKey = key;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (processed) {
|
||
if (processed.userId !== user.id) return sendJson(res, 403, { error: 'This subscription belongs to another user' });
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'subscription', {
|
||
plan: processed.plan,
|
||
billingCycle: processed.billingCycle,
|
||
currency: processed.currency,
|
||
amount: processed.amount,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: processedKey,
|
||
orderId: processed.orderId || processed.checkoutSessionId,
|
||
paymentId: processed.paymentId,
|
||
subscriptionId: processed.subscriptionId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
const accountData = await serializeAccount(user);
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
alreadyApplied: true,
|
||
plan: processed.plan,
|
||
billingCycle: processed.billingCycle,
|
||
currency: processed.currency,
|
||
account: accountData,
|
||
});
|
||
}
|
||
|
||
// Enhanced session tracking - try multiple ways to find the pending subscription
|
||
let pending = pendingSubscriptions[sessionId];
|
||
let pendingKey = sessionId;
|
||
|
||
// If direct lookup fails, search by checkoutSessionId (for inline payments)
|
||
if (!pending) {
|
||
for (const [key, value] of Object.entries(pendingSubscriptions)) {
|
||
if (value.checkoutSessionId === sessionId || value.orderId === sessionId) {
|
||
pending = value;
|
||
pendingKey = key;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!pending) return sendJson(res, 404, { error: 'Subscription not found or already processed' });
|
||
if (pending.userId !== user.id) return sendJson(res, 403, { error: 'This subscription belongs to another user' });
|
||
|
||
try {
|
||
// Get checkout details from Dodo
|
||
const checkout = await dodoRequest(`/checkouts/${pendingKey}`, { method: 'GET' });
|
||
const paid = isDodoPaymentComplete(checkout?.payment_status || checkout?.status);
|
||
if (!paid) {
|
||
return sendJson(res, 200, { ok: false, pending: true, paymentStatus: checkout?.payment_status || checkout?.status || 'unpaid' });
|
||
}
|
||
|
||
// Activate subscription
|
||
const previousSubscriptionId = pending.previousSubscriptionId;
|
||
|
||
user.plan = pending.plan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.billingCycle = pending.billingCycle;
|
||
user.subscriptionCurrency = pending.currency;
|
||
user.subscriptionRenewsAt = computeRenewalDate(pending.billingCycle);
|
||
user.dodoCustomerId = checkout?.customer_id || user.dodoCustomerId;
|
||
|
||
if (checkout?.subscription_id) {
|
||
user.dodoSubscriptionId = checkout.subscription_id;
|
||
}
|
||
|
||
if (previousSubscriptionId && previousSubscriptionId !== user.dodoSubscriptionId) {
|
||
const cancelTarget = {
|
||
...user,
|
||
dodoSubscriptionId: previousSubscriptionId,
|
||
};
|
||
await cancelDodoSubscription(cancelTarget, 'paid_plan_switch', { clearOnFailure: true });
|
||
}
|
||
|
||
await persistUsersDb();
|
||
|
||
const paymentId = checkout?.payment_id || checkout?.paymentId || checkout?.charge_id || checkout?.chargeId || '';
|
||
const subscriptionId = checkout?.subscription_id || user.dodoSubscriptionId || '';
|
||
|
||
// Mark subscription as processed
|
||
processedSubscriptions[pendingKey] = {
|
||
userId: user.id,
|
||
orderId: pending.orderId || pending.checkoutSessionId || null,
|
||
checkoutSessionId: pending.checkoutSessionId || null,
|
||
subscriptionId: subscriptionId || null,
|
||
paymentId: paymentId || null,
|
||
plan: pending.plan,
|
||
billingCycle: pending.billingCycle,
|
||
currency: pending.currency,
|
||
amount: pending.price,
|
||
completedAt: new Date().toISOString(),
|
||
};
|
||
|
||
// Clean up pending subscription
|
||
delete pendingSubscriptions[pendingKey];
|
||
await Promise.all([persistPendingSubscriptions(), persistProcessedSubscriptions()]);
|
||
|
||
// Track conversion and financial
|
||
trackConversion('paid', req);
|
||
trackFinancial(pending.price / 100, pending.plan); // Convert from cents to dollars
|
||
|
||
await sendPaymentConfirmationEmail(user, 'subscription', {
|
||
plan: pending.plan,
|
||
billingCycle: pending.billingCycle,
|
||
currency: pending.currency,
|
||
amount: pending.price,
|
||
});
|
||
|
||
try {
|
||
await createInvoiceIfMissing(user, 'subscription', {
|
||
plan: pending.plan,
|
||
billingCycle: pending.billingCycle,
|
||
currency: pending.currency,
|
||
amount: pending.price,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey,
|
||
orderId: pending.orderId || pending.checkoutSessionId,
|
||
paymentId,
|
||
subscriptionId,
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
log('subscription activated', {
|
||
userId: user.id,
|
||
email: user.email,
|
||
plan: pending.plan,
|
||
billingCycle: pending.billingCycle,
|
||
currency: pending.currency,
|
||
sessionId: pendingKey,
|
||
checkoutSessionId: sessionId
|
||
});
|
||
|
||
const accountData = await serializeAccount(user);
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
plan: pending.plan,
|
||
billingCycle: pending.billingCycle,
|
||
currency: pending.currency,
|
||
account: accountData,
|
||
sessionId: pendingKey,
|
||
checkoutSessionId: sessionId
|
||
});
|
||
} catch (error) {
|
||
log('subscription confirmation failed', {
|
||
error: String(error),
|
||
userId: user?.id,
|
||
sessionId,
|
||
pendingKey
|
||
});
|
||
return sendJson(res, 400, { error: error.message || 'Unable to confirm subscription' });
|
||
}
|
||
}
|
||
|
||
// Get subscription status
|
||
async function handleSubscriptionStatus(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const isPaidPlan = PAID_PLANS.has(user.plan || '');
|
||
const hasActiveSubscription = isPaidPlan && user.billingStatus === DEFAULT_BILLING_STATUS;
|
||
|
||
let paymentMethod = null;
|
||
|
||
if (user.dodoSubscriptionId) {
|
||
try {
|
||
const subscription = await dodoRequest(`/subscriptions/${user.dodoSubscriptionId}`, {
|
||
method: 'GET',
|
||
});
|
||
|
||
if (subscription && subscription.payment_method) {
|
||
const pm = subscription.payment_method;
|
||
paymentMethod = {
|
||
brand: pm.card?.brand || pm.payment_method || 'Card',
|
||
last4: pm.card?.last4 || pm.card?.last_digits || '',
|
||
expiresAt: pm.card?.expiry || pm.card?.expires_at || '',
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.error('[SubscriptionStatus] Failed to fetch subscription details:', error.message);
|
||
}
|
||
}
|
||
|
||
return sendJson(res, 200, {
|
||
plan: user.plan || DEFAULT_PLAN,
|
||
hasActiveSubscription,
|
||
billingStatus: user.billingStatus,
|
||
billingCycle: user.billingCycle,
|
||
currency: user.subscriptionCurrency,
|
||
renewsAt: user.subscriptionRenewsAt,
|
||
paymentMethod,
|
||
});
|
||
}
|
||
|
||
// Cancel subscription
|
||
async function handleSubscriptionCancel(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
if (!PAID_PLANS.has(user.plan || '')) {
|
||
return sendJson(res, 400, { error: 'No active subscription to cancel' });
|
||
}
|
||
|
||
// Cancel Dodo subscription
|
||
if (user.dodoSubscriptionId) {
|
||
await cancelDodoSubscription(user, 'subscription_cancel', { clearOnFailure: true });
|
||
}
|
||
|
||
user.billingStatus = 'cancelled';
|
||
await persistUsersDb();
|
||
|
||
log('subscription cancelled', { userId: user.id, email: user.email, plan: user.plan });
|
||
|
||
const accountData = await serializeAccount(user);
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
message: 'Subscription cancelled. Access will continue until the end of the billing period.',
|
||
account: accountData,
|
||
});
|
||
}
|
||
|
||
async function handleDodoWebhook(req, res) {
|
||
try {
|
||
const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || '';
|
||
const rawBody = await new Promise((resolve, reject) => {
|
||
let body = '';
|
||
req.on('data', (chunk) => { body += chunk; });
|
||
req.on('end', () => resolve(body));
|
||
req.on('error', reject);
|
||
});
|
||
|
||
const signature = req.headers['dodo-signature'] || '';
|
||
|
||
if (DODO_WEBHOOK_KEY && signature) {
|
||
const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`;
|
||
if (!require('crypto').timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
||
log('Dodo webhook signature verification failed', { signature });
|
||
return sendJson(res, 401, { error: 'Invalid signature' });
|
||
}
|
||
} else if (DODO_WEBHOOK_KEY) {
|
||
log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY });
|
||
return sendJson(res, 401, { error: 'Missing signature' });
|
||
}
|
||
|
||
const event = JSON.parse(rawBody);
|
||
log('Dodo webhook received', { type: event.type, id: event.id });
|
||
|
||
switch (event.type) {
|
||
case 'payment_succeeded':
|
||
case 'subscription_payment_succeeded':
|
||
await handlePaymentSucceeded(event);
|
||
break;
|
||
case 'payment_failed':
|
||
await handlePaymentFailed(event);
|
||
break;
|
||
case 'payment_cancelled':
|
||
await handlePaymentCancelled(event);
|
||
break;
|
||
case 'payment_processing':
|
||
await handlePaymentProcessing(event);
|
||
break;
|
||
case 'payment_dispute_created':
|
||
await handlePaymentDisputeCreated(event);
|
||
break;
|
||
case 'dispute_accepted':
|
||
await handleDisputeAccepted(event);
|
||
break;
|
||
case 'dispute_cancelled':
|
||
await handleDisputeCancelled(event);
|
||
break;
|
||
case 'dispute_challenged':
|
||
await handleDisputeChallenged(event);
|
||
break;
|
||
case 'dispute_expired':
|
||
await handleDisputeExpired(event);
|
||
break;
|
||
case 'dispute_lost':
|
||
await handleDisputeLost(event);
|
||
break;
|
||
case 'dispute_won':
|
||
await handleDisputeWon(event);
|
||
break;
|
||
case 'charge_refunded':
|
||
await handleChargeRefunded(event);
|
||
break;
|
||
case 'refund_failed':
|
||
await handleRefundFailed(event);
|
||
break;
|
||
case 'subscription_canceled':
|
||
await handleSubscriptionCanceled(event);
|
||
break;
|
||
case 'subscription_payment_failed':
|
||
await handleSubscriptionPaymentFailed(event);
|
||
break;
|
||
case 'subscription_active':
|
||
await handleSubscriptionActive(event);
|
||
break;
|
||
case 'subscription_expired':
|
||
await handleSubscriptionExpired(event);
|
||
break;
|
||
case 'subscription_on_hold':
|
||
await handleSubscriptionOnHold(event);
|
||
break;
|
||
case 'subscription_plan_changed':
|
||
await handleSubscriptionPlanChanged(event);
|
||
break;
|
||
case 'subscription_renewed':
|
||
await handleSubscriptionRenewed(event);
|
||
break;
|
||
case 'subscription_updated':
|
||
await handleSubscriptionUpdated(event);
|
||
break;
|
||
default:
|
||
log('Unhandled Dodo webhook event', { type: event.type });
|
||
}
|
||
|
||
sendJson(res, 200, { received: true });
|
||
} catch (error) {
|
||
log('Dodo webhook error', { error: String(error), stack: error.stack });
|
||
sendJson(res, 200, { received: true });
|
||
}
|
||
}
|
||
|
||
async function handlePaymentSucceeded(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
|
||
const paymentId = data.payment_id || data.paymentId || data.charge_id || data.chargeId || data.id || event.id || '';
|
||
const checkoutId = data.checkout_id || data.checkoutId || data.checkout_session_id || data.checkoutSessionId || '';
|
||
const subscriptionId = data.subscription_id || data.subscriptionId || metadata.subscriptionId || '';
|
||
const orderId = metadata.orderId || metadata.sessionId || metadata.checkoutSessionId || '';
|
||
|
||
const inferredType = String(metadata.type || '').toLowerCase()
|
||
|| (metadata.payg === 'true' ? 'payg' : '')
|
||
|| (String(event?.type || '').toLowerCase().includes('subscription') ? 'subscription' : '');
|
||
|
||
let userId = metadata.userId || '';
|
||
let user = userId ? findUserById(userId) : null;
|
||
|
||
if (!user && subscriptionId) {
|
||
user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
userId = user?.id || '';
|
||
}
|
||
|
||
if (!userId || !user) {
|
||
log('payment_succeeded: user not found', { userId, subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const findKeyByOrderId = (store, wanted) => {
|
||
if (!wanted) return '';
|
||
for (const [key, value] of Object.entries(store || {})) {
|
||
if (value?.orderId && value.orderId === wanted) return key;
|
||
if (value?.checkoutSessionId && value.checkoutSessionId === wanted) return key;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const parseAmount = (value) => {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? Math.max(0, Math.round(num)) : null;
|
||
};
|
||
|
||
const ensureInvoice = async (type, details) => {
|
||
try {
|
||
await createInvoiceIfMissing(user, type, details);
|
||
} catch (invoiceError) {
|
||
log('failed to create invoice', { userId: user.id, type, error: String(invoiceError) });
|
||
}
|
||
};
|
||
|
||
if (inferredType === 'topup') {
|
||
const pendingKey = (checkoutId && pendingTopups?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingTopups, orderId);
|
||
const pending = pendingKey ? pendingTopups[pendingKey] : null;
|
||
|
||
const tokens = Number(metadata.tokens || pending?.tokens || 0);
|
||
if (!tokens) {
|
||
log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId });
|
||
return;
|
||
}
|
||
|
||
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
|
||
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
|
||
const tier = metadata.tier || pending?.tier || null;
|
||
|
||
if (pendingKey && processedTopups[pendingKey]) {
|
||
await ensureInvoice('topup', {
|
||
tokens,
|
||
amount: processedTopups[pendingKey].amount ?? amount,
|
||
currency: processedTopups[pendingKey].currency ?? currency,
|
||
tier: processedTopups[pendingKey].tier ?? tier,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey,
|
||
orderId: processedTopups[pendingKey].orderId || orderId,
|
||
paymentId: processedTopups[pendingKey].paymentId || paymentId,
|
||
eventId: event.id,
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
const bucket = ensureTokenUsageBucket(userId);
|
||
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
|
||
await persistTokenUsage();
|
||
|
||
if (pendingKey) {
|
||
processedTopups[pendingKey] = {
|
||
userId: user.id,
|
||
orderId: (pending?.orderId || orderId) || null,
|
||
paymentId: paymentId || null,
|
||
tokens,
|
||
tier: tier || null,
|
||
amount,
|
||
currency,
|
||
completedAt: new Date().toISOString(),
|
||
};
|
||
delete pendingTopups[pendingKey];
|
||
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
|
||
}
|
||
|
||
await ensureInvoice('topup', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
tier,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey || checkoutId,
|
||
orderId,
|
||
paymentId,
|
||
eventId: event.id,
|
||
},
|
||
});
|
||
|
||
log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId });
|
||
return;
|
||
}
|
||
|
||
if (inferredType === 'payment_method_save') {
|
||
log('payment_method_save: payment method added via webhook', { userId, eventId: event.id, checkoutId });
|
||
return;
|
||
}
|
||
|
||
if (inferredType === 'subscription') {
|
||
const pendingKey = (checkoutId && pendingSubscriptions?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingSubscriptions, orderId);
|
||
const pending = pendingKey ? pendingSubscriptions[pendingKey] : null;
|
||
|
||
if (pendingKey && processedSubscriptions[pendingKey]) {
|
||
await ensureInvoice('subscription', {
|
||
plan: processedSubscriptions[pendingKey].plan,
|
||
billingCycle: processedSubscriptions[pendingKey].billingCycle,
|
||
currency: processedSubscriptions[pendingKey].currency,
|
||
amount: processedSubscriptions[pendingKey].amount,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey,
|
||
orderId: processedSubscriptions[pendingKey].orderId || orderId,
|
||
paymentId: processedSubscriptions[pendingKey].paymentId || paymentId,
|
||
subscriptionId: processedSubscriptions[pendingKey].subscriptionId || subscriptionId,
|
||
eventId: event.id,
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pending) {
|
||
user.plan = pending.plan || metadata.plan || user.plan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.billingCycle = pending.billingCycle || metadata.billingCycle || user.billingCycle;
|
||
user.subscriptionCurrency = pending.currency || metadata.currency || user.subscriptionCurrency;
|
||
if (user.billingCycle) {
|
||
user.subscriptionRenewsAt = computeRenewalDate(user.billingCycle);
|
||
}
|
||
|
||
if (subscriptionId) {
|
||
user.dodoSubscriptionId = subscriptionId;
|
||
}
|
||
|
||
await persistUsersDb();
|
||
|
||
processedSubscriptions[pendingKey] = {
|
||
userId: user.id,
|
||
orderId: pending.orderId || pending.checkoutSessionId || orderId || null,
|
||
checkoutSessionId: pending.checkoutSessionId || null,
|
||
subscriptionId: subscriptionId || null,
|
||
paymentId: paymentId || null,
|
||
plan: user.plan,
|
||
billingCycle: user.billingCycle,
|
||
currency: user.subscriptionCurrency,
|
||
amount: pending.price || parseAmount(metadata.amount || data.amount || data.amount_total || data.total_amount) || null,
|
||
completedAt: new Date().toISOString(),
|
||
};
|
||
|
||
delete pendingSubscriptions[pendingKey];
|
||
await Promise.all([persistPendingSubscriptions(), persistProcessedSubscriptions()]);
|
||
|
||
await ensureInvoice('subscription', {
|
||
plan: user.plan,
|
||
billingCycle: user.billingCycle,
|
||
currency: user.subscriptionCurrency,
|
||
amount: processedSubscriptions[pendingKey].amount,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey,
|
||
orderId: processedSubscriptions[pendingKey].orderId || orderId,
|
||
paymentId,
|
||
subscriptionId,
|
||
eventId: event.id,
|
||
},
|
||
});
|
||
|
||
log('payment_succeeded: subscription activated via webhook', { userId: user.id, eventId: event.id, subscriptionId });
|
||
return;
|
||
}
|
||
|
||
// Subscription renewal (or webhook delivered after pending record expired)
|
||
const amount = parseAmount(metadata.amount || data.amount || data.amount_total || data.total_amount);
|
||
const currency = String(metadata.currency || data.currency || user.subscriptionCurrency || '').toLowerCase() || null;
|
||
|
||
await ensureInvoice('subscription', {
|
||
plan: metadata.plan || user.plan,
|
||
billingCycle: metadata.billingCycle || user.billingCycle,
|
||
currency,
|
||
amount,
|
||
source: {
|
||
provider: 'dodo',
|
||
paymentId,
|
||
eventId: event.id,
|
||
subscriptionId: subscriptionId || user.dodoSubscriptionId,
|
||
},
|
||
});
|
||
|
||
log('payment_succeeded: subscription payment recorded via webhook', { userId: user.id, eventId: event.id, subscriptionId: subscriptionId || user.dodoSubscriptionId });
|
||
return;
|
||
}
|
||
|
||
if (inferredType === 'payg') {
|
||
const pendingKey = (checkoutId && pendingPayg?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingPayg, orderId);
|
||
const pending = pendingKey ? pendingPayg[pendingKey] : null;
|
||
|
||
const tokens = Number(metadata.tokens || pending?.tokens || 0);
|
||
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
|
||
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
|
||
|
||
await ensureInvoice('payg', {
|
||
tokens,
|
||
amount,
|
||
currency,
|
||
source: {
|
||
provider: 'dodo',
|
||
checkoutId: pendingKey || checkoutId,
|
||
orderId,
|
||
paymentId,
|
||
eventId: event.id,
|
||
},
|
||
});
|
||
|
||
log('payment_succeeded: payg invoice recorded via webhook', { userId: user.id, eventId: event.id, checkoutId: pendingKey || checkoutId });
|
||
return;
|
||
}
|
||
|
||
log('payment_succeeded: unsupported payment type', { userId, type: metadata.type, eventId: event.id });
|
||
}
|
||
|
||
async function handlePaymentFailed(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('payment_failed: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('payment_failed: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const paymentType = metadata.type;
|
||
|
||
if (paymentType === 'subscription' || user.dodoSubscriptionId) {
|
||
await cancelSubscriptionForUser(user, 'payment_failed');
|
||
log('payment_failed: subscription cancelled', { userId, email: user.email, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendPaymentFailedEmail(user, data);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handlePaymentDisputeCreated(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('payment_dispute_created: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('payment_dispute_created: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
await cancelSubscriptionForUser(user, 'dispute_created');
|
||
log('payment_dispute_created: subscription cancelled due to dispute', { userId, email: user.email, eventId: event.id, disputeId: data.dispute_id });
|
||
|
||
if (user.email) {
|
||
await sendPaymentDisputeCreatedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleChargeRefunded(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('charge_refunded: no userId in metadata', { eventId: event.id, amount: data.amount });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('charge_refunded: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const paymentType = metadata.type;
|
||
log('charge_refunded: refund processed', { userId, paymentType, amount: data.amount, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendChargeRefundedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionCanceled(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_canceled: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_canceled: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
user.plan = 'hobby';
|
||
user.billingStatus = 'cancelled';
|
||
user.dodoSubscriptionId = '';
|
||
user.subscriptionRenewsAt = '';
|
||
await persistUsersDb();
|
||
|
||
log('subscription_canceled: user downgraded to hobby', { userId: user.id, email: user.email, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionCancelledEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionPaymentFailed(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_payment_failed: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_payment_failed: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
await cancelSubscriptionForUser(user, 'subscription_payment_failed');
|
||
log('subscription_payment_failed: subscription cancelled due to payment failure', { userId: user.id, email: user.email, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionPaymentFailedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function cancelSubscriptionForUser(user, reason) {
|
||
if (user.dodoSubscriptionId) {
|
||
await cancelDodoSubscription(user, reason, { clearOnFailure: true });
|
||
}
|
||
|
||
user.plan = 'hobby';
|
||
user.billingStatus = 'cancelled';
|
||
user.subscriptionRenewsAt = '';
|
||
await persistUsersDb();
|
||
}
|
||
|
||
async function handlePaymentCancelled(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('payment_cancelled: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('payment_cancelled: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const paymentType = metadata.type;
|
||
log('payment_cancelled: payment cancelled', { userId, paymentType, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendPaymentCancelledEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handlePaymentProcessing(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('payment_processing: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('payment_processing: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('payment_processing: payment processing', { userId, eventId: event.id });
|
||
}
|
||
|
||
async function handleDisputeAccepted(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_accepted: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_accepted: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_accepted: dispute accepted by bank', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeAcceptedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleDisputeCancelled(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_cancelled: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_cancelled: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_cancelled: dispute cancelled', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeCancelledEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleDisputeChallenged(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_challenged: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_challenged: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_challenged: dispute challenged', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeChallengedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleDisputeExpired(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_expired: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_expired: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_expired: dispute expired', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeExpiredEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleDisputeLost(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_lost: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_lost: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_lost: dispute lost', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeLostEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleDisputeWon(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('dispute_won: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('dispute_won: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('dispute_won: dispute won', { userId, disputeId: data.dispute_id, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendDisputeWonEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleRefundFailed(event) {
|
||
const data = event?.data?.object || event?.data || {};
|
||
const metadata = data?.metadata || {};
|
||
const userId = metadata.userId;
|
||
|
||
if (!userId) {
|
||
log('refund_failed: no userId in metadata', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) {
|
||
log('refund_failed: user not found', { userId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
log('refund_failed: refund failed', { userId, amount: data.amount, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendRefundFailedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionActive(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_active: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_active: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
user.billingStatus = 'active';
|
||
if (data.renews_at || data.current_period_end) {
|
||
user.subscriptionRenewsAt = data.renews_at || data.current_period_end;
|
||
}
|
||
await persistUsersDb();
|
||
|
||
log('subscription_active: subscription activated', { userId: user.id, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionActiveEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionExpired(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_expired: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_expired: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
user.plan = 'hobby';
|
||
user.billingStatus = 'expired';
|
||
user.dodoSubscriptionId = '';
|
||
user.subscriptionRenewsAt = '';
|
||
await persistUsersDb();
|
||
|
||
log('subscription_expired: subscription expired', { userId: user.id, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionExpiredEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionOnHold(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_on_hold: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_on_hold: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
user.billingStatus = 'on_hold';
|
||
await persistUsersDb();
|
||
|
||
log('subscription_on_hold: subscription on hold', { userId: user.id, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionOnHoldEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionPlanChanged(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_plan_changed: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_plan_changed: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const newPlan = data.plan || data.metadata?.plan;
|
||
if (newPlan && USER_PLANS.includes(newPlan.toLowerCase())) {
|
||
user.plan = newPlan.toLowerCase();
|
||
}
|
||
await persistUsersDb();
|
||
|
||
log('subscription_plan_changed: plan changed', { userId: user.id, subscriptionId, newPlan: user.plan, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionPlanChangedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionRenewed(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_renewed: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_renewed: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
user.billingStatus = 'active';
|
||
if (data.renews_at || data.current_period_end) {
|
||
user.subscriptionRenewsAt = data.renews_at || data.current_period_end;
|
||
}
|
||
await persistUsersDb();
|
||
|
||
log('subscription_renewed: subscription renewed', { userId: user.id, subscriptionId, eventId: event.id });
|
||
|
||
if (user.email) {
|
||
await sendSubscriptionRenewedEmail(user, data);
|
||
}
|
||
}
|
||
|
||
async function handleSubscriptionUpdated(event) {
|
||
const { data } = event;
|
||
const subscriptionId = data.id || data.subscription_id;
|
||
|
||
if (!subscriptionId) {
|
||
log('subscription_updated: no subscription ID', { eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
const user = usersDb.find(u => u.dodoSubscriptionId === subscriptionId);
|
||
if (!user) {
|
||
log('subscription_updated: user not found for subscription', { subscriptionId, eventId: event.id });
|
||
return;
|
||
}
|
||
|
||
if (data.renews_at || data.current_period_end) {
|
||
user.subscriptionRenewsAt = data.renews_at || data.current_period_end;
|
||
}
|
||
await persistUsersDb();
|
||
|
||
log('subscription_updated: subscription updated', { userId: user.id, subscriptionId, eventId: event.id });
|
||
}
|
||
|
||
async function handleAccountBoostPurchase(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
const user = findUserById(session.userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const packSize = BOOST_PACK_SIZE;
|
||
const discount = user.plan === 'enterprise' ? 0.25 : user.plan === 'professional' ? 0.1 : user.plan === 'starter' ? 0.05 : 0;
|
||
const bucket = ensureTokenUsageBucket(user.id);
|
||
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + packSize);
|
||
await persistTokenUsage();
|
||
const price = BOOST_BASE_PRICE * (1 - discount);
|
||
|
||
// Track financial
|
||
trackFinancial(price, user.plan || DEFAULT_PLAN);
|
||
|
||
// Create invoice for boost purchase
|
||
try {
|
||
await createInvoiceIfMissing(user, 'boost', {
|
||
tokens: packSize,
|
||
amount: Math.round(price * 100),
|
||
currency: 'usd',
|
||
source: {
|
||
provider: 'internal',
|
||
type: 'boost_purchase',
|
||
},
|
||
});
|
||
} catch (invoiceError) {
|
||
log('failed to create boost invoice', { userId: user.id, error: String(invoiceError) });
|
||
}
|
||
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
message: `Added a power boost worth ${packSize.toLocaleString()} tokens`,
|
||
priceCharged: `$${price.toFixed(2)} (simulated)`,
|
||
summary: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN),
|
||
});
|
||
}
|
||
|
||
// Test endpoint to simulate token usage for testing the usage bar
|
||
async function handleSimulateTokenUsage(req, res) {
|
||
// Prefer authenticated user-session, fall back to legacy identity
|
||
const authed = getUserSession(req);
|
||
const resolvedUserId = authed?.userId || resolveUserId(req);
|
||
if (!resolvedUserId) {
|
||
return sendJson(res, 401, { error: 'User identity required' });
|
||
}
|
||
|
||
const body = await parseJsonBody(req).catch(() => ({}));
|
||
const tokensToAdd = Math.max(0, Math.ceil(Number(body.tokens || 1000)));
|
||
|
||
console.log(`[TEST] Simulating ${tokensToAdd} tokens for user ${resolvedUserId}`);
|
||
|
||
// Record the tokens
|
||
await recordUserTokens(resolvedUserId, tokensToAdd);
|
||
|
||
// Get updated summary
|
||
const user = findUserById(resolvedUserId);
|
||
const plan = user?.plan || DEFAULT_PLAN;
|
||
const summary = getTokenUsageSummary(resolvedUserId, plan);
|
||
|
||
console.log(`[TEST] Updated usage summary:`, summary);
|
||
|
||
return sendJson(res, 200, {
|
||
ok: true,
|
||
message: `Simulated ${tokensToAdd} tokens`,
|
||
tokensAdded: tokensToAdd,
|
||
summary,
|
||
});
|
||
}
|
||
|
||
async function handleVerifyEmailApi(req, res, url) {
|
||
try {
|
||
const tokenFromQuery = (url && url.searchParams && url.searchParams.get('token')) || '';
|
||
const body = req.method === 'POST' ? await parseJsonBody(req).catch(() => ({})) : {};
|
||
const token = (body.token || tokenFromQuery || '').trim();
|
||
if (!token) return sendJson(res, 400, { error: 'Verification token is required' });
|
||
|
||
const user = usersDb.find((u) => u.verificationToken === token);
|
||
if (!user) return sendJson(res, 400, { error: 'Verification link is invalid' });
|
||
|
||
if (user.verificationExpiresAt) {
|
||
const expires = new Date(user.verificationExpiresAt).getTime();
|
||
if (Number.isFinite(expires) && expires < Date.now()) {
|
||
return sendJson(res, 400, { error: 'Verification link has expired. Please request a new one.' });
|
||
}
|
||
}
|
||
|
||
user.emailVerified = true;
|
||
user.verificationToken = '';
|
||
user.verificationExpiresAt = null;
|
||
await persistUsersDb();
|
||
const tokenValue = startUserSession(res, user.id, true);
|
||
log('user email verified', { userId: user.id, email: user.email });
|
||
|
||
// Check if user has selected a plan
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
user: { id: user.id, email: user.email, emailVerified: true, hasPlan },
|
||
token: tokenValue,
|
||
expiresAt: Date.now() + USER_SESSION_TTL_MS,
|
||
message: 'Email verified successfully.',
|
||
redirect: hasPlan ? '/apps' : '/select-plan',
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to verify email' });
|
||
}
|
||
}
|
||
|
||
async function handlePasswordResetRequest(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const email = (body.email || '').trim().toLowerCase();
|
||
if (!email) return sendJson(res, 400, { error: 'Email is required' });
|
||
|
||
const user = findUserByEmail(email);
|
||
if (!user) {
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
return sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' });
|
||
}
|
||
|
||
assignPasswordResetToken(user);
|
||
await persistUsersDb();
|
||
|
||
// Send reset email in the background
|
||
sendPasswordResetEmail(user, resolveBaseUrl(req)).catch(err => {
|
||
log('background password reset email failed', { error: String(err), email: user.email });
|
||
});
|
||
|
||
log('password reset email queued', { userId: user.id, email: user.email });
|
||
sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to start password reset' });
|
||
}
|
||
}
|
||
|
||
async function handlePasswordReset(req, res) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const token = (body.token || '').trim();
|
||
const newPassword = (body.password || '').trim();
|
||
if (!token || !newPassword) return sendJson(res, 400, { error: 'Token and new password are required' });
|
||
if (newPassword.length < 6) return sendJson(res, 400, { error: 'Password must be at least 6 characters long' });
|
||
|
||
const user = usersDb.find((u) => u.resetToken === token);
|
||
if (!user) return sendJson(res, 400, { error: 'Reset link is invalid' });
|
||
|
||
if (user.resetExpiresAt) {
|
||
const expires = new Date(user.resetExpiresAt).getTime();
|
||
if (Number.isFinite(expires) && expires < Date.now()) {
|
||
return sendJson(res, 400, { error: 'Reset link has expired' });
|
||
}
|
||
}
|
||
|
||
user.password = await bcrypt.hash(newPassword, PASSWORD_SALT_ROUNDS);
|
||
user.resetToken = '';
|
||
user.resetExpiresAt = null;
|
||
if (!user.emailVerified) user.emailVerified = true;
|
||
await persistUsersDb();
|
||
|
||
const tokenValue = startUserSession(res, user.id, true);
|
||
log('password reset successful', { userId: user.id, email: user.email });
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
user: { id: user.id, email: user.email, emailVerified: !!user.emailVerified },
|
||
token: tokenValue,
|
||
expiresAt: Date.now() + USER_SESSION_TTL_MS,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to reset password' });
|
||
}
|
||
}
|
||
|
||
async function handleSelectPlan(req, res) {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const user = findUserById(session.userId);
|
||
if (!user) {
|
||
return sendJson(res, 404, { error: 'User not found' });
|
||
}
|
||
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const plan = normalizePlanSelection(body.plan || body.newPlan);
|
||
|
||
if (!plan) {
|
||
return sendJson(res, 400, { error: 'Invalid plan selection' });
|
||
}
|
||
|
||
// Hobby plan is free - activate immediately
|
||
if (plan === 'hobby') {
|
||
const previousPlan = user.plan || 'none';
|
||
|
||
// Cancel any existing Dodo subscription
|
||
if (user.dodoSubscriptionId) {
|
||
await cancelDodoSubscription(user, 'hobby_plan', { clearOnFailure: true });
|
||
}
|
||
|
||
user.plan = plan;
|
||
user.billingStatus = DEFAULT_BILLING_STATUS;
|
||
user.subscriptionRenewsAt = null;
|
||
user.billingCycle = null;
|
||
user.subscriptionCurrency = null;
|
||
await persistUsersDb();
|
||
|
||
// Track plan selection and conversion
|
||
trackConversionFunnel('plan_selection', 'hobby_selected', user.id, {
|
||
previousPlan: previousPlan,
|
||
newPlan: plan
|
||
});
|
||
trackPlanUpgrade(previousPlan, plan, user.id);
|
||
trackFeatureUsage('plan_selection', user.id, plan);
|
||
|
||
log('hobby plan selected (free)', { userId: user.id, email: user.email, plan });
|
||
return sendJson(res, 200, { ok: true, account: await serializeAccount(user) });
|
||
}
|
||
|
||
// For paid plans, redirect to subscription checkout
|
||
// Return a special response that frontend can handle to redirect to subscription flow
|
||
const previousPlan = user.plan || 'none';
|
||
trackConversionFunnel('plan_selection', 'paid_plan_selected', user.id, {
|
||
previousPlan: previousPlan,
|
||
newPlan: plan
|
||
});
|
||
trackPlanUpgrade(previousPlan, plan, user.id);
|
||
trackFeatureUsage('plan_selection', user.id, plan);
|
||
|
||
return sendJson(res, 200, {
|
||
requiresPayment: true,
|
||
plan: plan,
|
||
message: 'Paid plan selected - redirecting to payment'
|
||
});
|
||
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to select plan' });
|
||
}
|
||
}
|
||
|
||
function renderOAuthResultPage(res, { success, message, next, user, token, provider }) {
|
||
const safeNext = sanitizeRedirectPath(next, '/apps');
|
||
const safeMessage = escapeHtml(message || (success ? 'Signing you in...' : 'Authentication failed'));
|
||
let script = `window.location.href = '${safeNext}';`;
|
||
if (success && user) {
|
||
const payload = {
|
||
email: user.email,
|
||
accountId: user.id,
|
||
sessionToken: token || '',
|
||
provider: provider || 'oauth'
|
||
};
|
||
const payloadJson = JSON.stringify(payload).replace(/</g, '\\u003c');
|
||
script = `
|
||
(() => {
|
||
try {
|
||
const data = ${payloadJson};
|
||
const json = JSON.stringify(data);
|
||
localStorage.setItem('wordpress_plugin_ai_user', json);
|
||
localStorage.setItem('shopify_ai_user', json);
|
||
document.cookie = 'chat_user=' + encodeURIComponent(data.accountId) + '; path=/; SameSite=Lax';
|
||
} catch (_) { /* ignore */ }
|
||
window.location.href = '${safeNext}';
|
||
})();
|
||
`;
|
||
}
|
||
const statusCode = success ? 200 : 400;
|
||
res.writeHead(statusCode, { 'Content-Type': 'text/html' });
|
||
res.end(`<!doctype html><html><body><p>${safeMessage}</p><script>${script}</script></body></html>`);
|
||
}
|
||
|
||
function renderOAuthError(res, message, next) {
|
||
return renderOAuthResultPage(res, { success: false, message: message || 'Unable to sign you in', next: next || '/login' });
|
||
}
|
||
|
||
function renderOAuthSuccess(res, user, token, next, provider) {
|
||
// Check if user has selected a plan
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
const redirectNext = hasPlan ? next : '/select-plan';
|
||
return renderOAuthResultPage(res, { success: true, message: 'Signing you in...', next: redirectNext, user, token, provider });
|
||
}
|
||
|
||
async function handleGoogleAuthStart(req, res, url) {
|
||
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
||
return renderOAuthError(res, 'Google sign-in is not configured', url.searchParams.get('next') || '/login');
|
||
}
|
||
const next = url.searchParams.get('next') || '/apps';
|
||
const remember = url.searchParams.get('remember') === 'true' || url.searchParams.get('remember') === '1';
|
||
const state = createOAuthState(next, 'google', remember);
|
||
const redirectUri = buildRedirectUri(req, 'google');
|
||
const params = new URLSearchParams({
|
||
client_id: GOOGLE_CLIENT_ID,
|
||
redirect_uri: redirectUri,
|
||
response_type: 'code',
|
||
scope: 'openid email profile',
|
||
state,
|
||
access_type: 'offline',
|
||
include_granted_scopes: 'true',
|
||
prompt: 'consent',
|
||
});
|
||
res.writeHead(302, { Location: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` });
|
||
res.end();
|
||
}
|
||
|
||
async function handleGoogleAuthCallback(req, res, url) {
|
||
const stateParam = url.searchParams.get('state') || '';
|
||
const stateEntry = consumeOAuthState(stateParam, 'google');
|
||
const hintNext = sanitizeRedirectPath(url.searchParams.get('next') || '/apps');
|
||
const next = stateEntry?.next || hintNext;
|
||
|
||
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
||
return renderOAuthError(res, 'Google sign-in is not configured', next);
|
||
}
|
||
if (!stateEntry) {
|
||
return renderOAuthError(res, 'This Google login request has expired. Please try again.', next);
|
||
}
|
||
const code = url.searchParams.get('code');
|
||
const error = url.searchParams.get('error');
|
||
if (error) {
|
||
return renderOAuthError(res, `Google authentication was cancelled: ${escapeHtml(error)}`, next);
|
||
}
|
||
if (!code) {
|
||
return renderOAuthError(res, 'Missing Google authorization code', next);
|
||
}
|
||
|
||
try {
|
||
const redirectUri = buildRedirectUri(req, 'google');
|
||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({
|
||
code,
|
||
client_id: GOOGLE_CLIENT_ID,
|
||
client_secret: GOOGLE_CLIENT_SECRET,
|
||
redirect_uri: redirectUri,
|
||
grant_type: 'authorization_code'
|
||
})
|
||
});
|
||
const tokenJson = await tokenRes.json().catch(() => ({}));
|
||
const accessToken = tokenJson.access_token;
|
||
const idToken = tokenJson.id_token;
|
||
if (!tokenRes.ok || (!accessToken && !idToken)) {
|
||
log('google oauth token exchange failed', { status: tokenRes.status, body: tokenJson });
|
||
return renderOAuthError(res, 'Google authentication failed during token exchange', next);
|
||
}
|
||
|
||
let profile = {};
|
||
if (idToken) {
|
||
profile = decodeJwtPayload(idToken);
|
||
}
|
||
if (accessToken) {
|
||
try {
|
||
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||
headers: { Authorization: `Bearer ${accessToken}` }
|
||
});
|
||
if (userInfoRes.ok) {
|
||
profile = await userInfoRes.json();
|
||
}
|
||
} catch (err) {
|
||
log('google userinfo fetch failed', { err: String(err) });
|
||
}
|
||
}
|
||
|
||
const email = (profile?.email || '').toLowerCase();
|
||
const providerId = profile?.sub || profile?.id || null;
|
||
if (!providerId) {
|
||
return renderOAuthError(res, 'Google account did not include an id', next);
|
||
}
|
||
if (!email) {
|
||
return renderOAuthError(res, 'Google account did not return an email address', next);
|
||
}
|
||
|
||
const user = await upsertOAuthUser('google', providerId, email, profile || {});
|
||
const token = startUserSession(res, user.id, stateEntry.remember || false);
|
||
log('google oauth login success', { userId: user.id, email: user.email, remember: stateEntry.remember });
|
||
return renderOAuthSuccess(res, user, token, next, 'google');
|
||
} catch (err) {
|
||
log('google oauth callback error', { error: String(err) });
|
||
return renderOAuthError(res, 'Unexpected error during Google sign-in', next);
|
||
}
|
||
}
|
||
|
||
async function handleGithubAuthStart(req, res, url) {
|
||
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
|
||
return renderOAuthError(res, 'GitHub sign-in is not configured', url.searchParams.get('next') || '/login');
|
||
}
|
||
const next = url.searchParams.get('next') || '/apps';
|
||
const remember = url.searchParams.get('remember') === 'true' || url.searchParams.get('remember') === '1';
|
||
const state = createOAuthState(next, 'github', remember);
|
||
const redirectUri = buildRedirectUri(req, 'github');
|
||
const params = new URLSearchParams({
|
||
client_id: GITHUB_CLIENT_ID,
|
||
redirect_uri: redirectUri,
|
||
scope: 'read:user user:email',
|
||
state
|
||
});
|
||
res.writeHead(302, { Location: `https://github.com/login/oauth/authorize?${params.toString()}` });
|
||
res.end();
|
||
}
|
||
|
||
async function handleGithubAuthCallback(req, res, url) {
|
||
const stateParam = url.searchParams.get('state') || '';
|
||
const stateEntry = consumeOAuthState(stateParam, 'github');
|
||
const hintNext = sanitizeRedirectPath(url.searchParams.get('next') || '/apps');
|
||
const next = stateEntry?.next || hintNext;
|
||
|
||
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
|
||
return renderOAuthError(res, 'GitHub sign-in is not configured', next);
|
||
}
|
||
if (!stateEntry) {
|
||
return renderOAuthError(res, 'This GitHub login request has expired. Please try again.', next);
|
||
}
|
||
|
||
const code = url.searchParams.get('code');
|
||
const error = url.searchParams.get('error');
|
||
if (error) {
|
||
return renderOAuthError(res, `GitHub authentication was cancelled: ${escapeHtml(error)}`, next);
|
||
}
|
||
if (!code) {
|
||
return renderOAuthError(res, 'Missing GitHub authorization code', next);
|
||
}
|
||
|
||
try {
|
||
const redirectUri = buildRedirectUri(req, 'github');
|
||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
client_id: GITHUB_CLIENT_ID,
|
||
client_secret: GITHUB_CLIENT_SECRET,
|
||
code,
|
||
redirect_uri: redirectUri,
|
||
})
|
||
});
|
||
const tokenJson = await tokenRes.json().catch(() => ({}));
|
||
const accessToken = tokenJson.access_token;
|
||
if (!tokenRes.ok || !accessToken) {
|
||
log('github oauth token exchange failed', { status: tokenRes.status, body: tokenJson });
|
||
return renderOAuthError(res, 'GitHub authentication failed during token exchange', next);
|
||
}
|
||
|
||
const userRes = await fetch('https://api.github.com/user', {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'User-Agent': OAUTH_USER_AGENT
|
||
}
|
||
});
|
||
const userProfile = await userRes.json().catch(() => ({}));
|
||
if (!userRes.ok) {
|
||
log('github user profile fetch failed', { status: userRes.status, body: userProfile });
|
||
return renderOAuthError(res, 'Could not read your GitHub profile', next);
|
||
}
|
||
|
||
let email = (userProfile.email || '').toLowerCase();
|
||
if (!email) {
|
||
try {
|
||
const emailRes = await fetch('https://api.github.com/user/emails', {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'User-Agent': OAUTH_USER_AGENT
|
||
}
|
||
});
|
||
const emails = await emailRes.json().catch(() => []);
|
||
if (Array.isArray(emails)) {
|
||
const verifiedEmails = emails.filter((e) => e && e.verified);
|
||
const primary = verifiedEmails.find((e) => e.primary) || verifiedEmails[0];
|
||
if (primary?.email) {
|
||
email = primary.email.toLowerCase();
|
||
} else {
|
||
return renderOAuthError(res, 'GitHub account must have a verified email address', next);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
log('github email fetch failed', { err: String(err) });
|
||
}
|
||
}
|
||
|
||
const providerId = userProfile?.id ? String(userProfile.id) : null;
|
||
if (!providerId) {
|
||
return renderOAuthError(res, 'GitHub account did not include an id', next);
|
||
}
|
||
if (!email) {
|
||
return renderOAuthError(res, 'GitHub account did not return a verified email address', next);
|
||
}
|
||
|
||
const user = await upsertOAuthUser('github', providerId, email, userProfile || {});
|
||
const token = startUserSession(res, user.id, stateEntry.remember || false);
|
||
log('github oauth login success', { userId: user.id, email: user.email, remember: stateEntry.remember });
|
||
return renderOAuthSuccess(res, user, token, next, 'github');
|
||
} catch (err) {
|
||
log('github oauth callback error', { error: String(err) });
|
||
return renderOAuthError(res, 'Unexpected error during GitHub sign-in', next);
|
||
}
|
||
}
|
||
|
||
async function handleAdminLogin(req, res) {
|
||
if (!ADMIN_USER || !ADMIN_PASSWORD) return sendJson(res, 500, { error: 'Admin credentials not configured' });
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const user = (body.username || body.user || '').trim();
|
||
const pass = (body.password || body.pass || '').trim();
|
||
|
||
// Normalize username comparison to be case-insensitive and trim whitespace
|
||
const adminUserNormalized = (ADMIN_USER || '').trim().toLowerCase();
|
||
const userNormalized = (user || '').trim().toLowerCase();
|
||
const clientIp = req.socket?.remoteAddress || 'unknown';
|
||
|
||
// Check honeypot
|
||
if (checkHoneypot(body)) {
|
||
log('admin login honeypot triggered', { ip: clientIp });
|
||
return sendJson(res, 400, { error: 'Invalid request' });
|
||
}
|
||
|
||
// Check rate limit
|
||
const rateLimit = checkLoginRateLimit(clientIp, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts);
|
||
if (rateLimit.blocked) {
|
||
log('admin login rate limited', { ip: clientIp, retryAfter: rateLimit.retryAfter });
|
||
return sendJson(res, 429, {
|
||
error: 'Too many login attempts. Please try again later.',
|
||
retryAfter: rateLimit.retryAfter || 60
|
||
});
|
||
}
|
||
|
||
if (!adminUserNormalized || !ADMIN_PASSWORD) {
|
||
log('admin credentials not configured (runtime)');
|
||
return sendJson(res, 500, { error: 'Admin credentials not configured' });
|
||
}
|
||
|
||
// Validate username
|
||
if (userNormalized !== adminUserNormalized) {
|
||
log('failed admin login', { user: userNormalized, ip: clientIp, reason: 'invalid_username' });
|
||
return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||
}
|
||
|
||
// Validate password using bcrypt
|
||
let passwordValid = false;
|
||
if (adminPasswordHash) {
|
||
passwordValid = await bcrypt.compare(pass, adminPasswordHash);
|
||
} else {
|
||
// Fallback to plaintext comparison if hashing failed at startup
|
||
passwordValid = pass === ADMIN_PASSWORD.trim();
|
||
}
|
||
|
||
if (!passwordValid) {
|
||
log('failed admin login', { user: userNormalized, ip: clientIp, reason: 'invalid_password' });
|
||
return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||
}
|
||
|
||
// Clear failed attempts on success
|
||
adminLoginAttempts.delete(clientIp);
|
||
|
||
const token = startAdminSession(res);
|
||
sendJson(res, 200, { ok: true, token, user: ADMIN_USER, expiresAt: Date.now() + ADMIN_SESSION_TTL_MS });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to login' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminLogout(req, res) {
|
||
const session = getAdminSession(req);
|
||
if (session) adminSessions.delete(session.token);
|
||
clearAdminSession(res);
|
||
sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
async function handleAdminMe(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
sendJson(res, 200, { ok: true, user: ADMIN_USER, expiresAt: session.expiresAt });
|
||
}
|
||
|
||
async function handleAdminAvailableModels(req, res, cliParam = 'opencode') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const cli = normalizeCli(cliParam);
|
||
const models = await listModels(cli);
|
||
sendJson(res, 200, { models });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Failed to load available models' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminListIcons(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
const icons = await listAdminIcons();
|
||
sendJson(res, 200, { icons });
|
||
}
|
||
|
||
async function handleAdminModelsList(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
const models = (adminModels || []).map((m) => ({
|
||
id: m.id,
|
||
name: m.name,
|
||
label: m.label || m.name,
|
||
icon: m.icon || '',
|
||
cli: m.cli || 'opencode',
|
||
providers: Array.isArray(m.providers) ? m.providers : [],
|
||
primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
|
||
tier: m.tier || 'free',
|
||
multiplier: getTierMultiplier(m.tier || 'free'),
|
||
supportsMedia: m.supportsMedia ?? false,
|
||
})).sort((a, b) => (a.label || '').localeCompare(b.label || ''));
|
||
sendJson(res, 200, { models });
|
||
}
|
||
|
||
async function handleAdminModelUpsert(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const modelName = (body.name || body.model || '').trim();
|
||
const label = (body.label || body.displayName || modelName).trim();
|
||
const cli = normalizeCli(body.cli || 'opencode');
|
||
const tier = normalizeTier(body.tier);
|
||
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
|
||
const id = body.id || randomUUID();
|
||
const existingIndex = adminModels.findIndex((m) => m.id === id);
|
||
const existing = existingIndex >= 0 ? adminModels[existingIndex] : null;
|
||
let icon = existing?.icon || '';
|
||
if (typeof body.icon === 'string' && body.icon.trim()) {
|
||
icon = await normalizeIconPath(body.icon);
|
||
if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' });
|
||
}
|
||
let providers = [];
|
||
if (Array.isArray(body.providers)) {
|
||
providers = body.providers.map((p, idx) => ({
|
||
provider: normalizeProviderName(p.provider || p.name || p.id || 'opencode'),
|
||
model: (p.model || p.name || modelName || '').trim() || modelName,
|
||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
||
})).filter((p) => !!p.model);
|
||
} else if (typeof body.provider === 'string') {
|
||
const normalized = normalizeProviderName(body.provider);
|
||
providers = [{ provider: normalized, model: modelName, primary: true }];
|
||
}
|
||
if (!providers.length) providers = [{ provider: 'opencode', model: modelName, primary: true }];
|
||
const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0].provider;
|
||
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
|
||
|
||
const payload = { id, name: modelName, label: label || modelName, cli, icon, providers, primaryProvider, tier, multiplier: getTierMultiplier(tier), supportsMedia };
|
||
if (existingIndex >= 0) adminModels[existingIndex] = { ...adminModels[existingIndex], ...payload };
|
||
else adminModels.push(payload);
|
||
await persistAdminModels();
|
||
sendJson(res, 200, { model: payload, models: getConfiguredModels(cli) });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save model' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminModelDelete(req, res, id) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
const before = adminModels.length;
|
||
adminModels = adminModels.filter((m) => m.id !== id);
|
||
if (adminModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
|
||
await persistAdminModels();
|
||
sendJson(res, 200, { ok: true, models: getConfiguredModels('opencode') });
|
||
}
|
||
|
||
async function handleAdminOpenRouterSettingsGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
sendJson(res, 200, openrouterSettings);
|
||
}
|
||
|
||
async function handleAdminMistralSettingsGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
sendJson(res, 200, mistralSettings);
|
||
}
|
||
|
||
async function handleAdminMistralSettingsPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
mistralSettings = {
|
||
primaryModel: (body.primaryModel || '').trim(),
|
||
backupModel1: (body.backupModel1 || '').trim(),
|
||
backupModel2: (body.backupModel2 || '').trim(),
|
||
backupModel3: (body.backupModel3 || '').trim(),
|
||
};
|
||
await persistMistralSettings();
|
||
sendJson(res, 200, { ok: true, settings: mistralSettings });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save settings' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminPlanSettingsGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
sendJson(res, 200, planSettings);
|
||
}
|
||
|
||
async function handleAdminPlanSettingsPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
if (body.provider && PLANNING_PROVIDERS.includes(normalizeProviderName(body.provider))) {
|
||
planSettings.provider = normalizeProviderName(body.provider);
|
||
}
|
||
if (Array.isArray(body.planningChain)) {
|
||
planSettings.planningChain = normalizePlanningChain(body.planningChain);
|
||
}
|
||
if (typeof body.freePlanModel === 'string') {
|
||
planSettings.freePlanModel = body.freePlanModel.trim();
|
||
}
|
||
if (!planSettings.planningChain.length) {
|
||
planSettings.planningChain = defaultPlanningChainFromSettings(planSettings.provider);
|
||
}
|
||
await persistPlanSettings();
|
||
sendJson(res, 200, { ok: true, settings: planSettings });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save settings' });
|
||
}
|
||
}
|
||
|
||
// Get current plan token allocations
|
||
async function handleAdminPlanTokensGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
sendJson(res, 200, { limits: planTokenLimits });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Unable to fetch plan tokens' });
|
||
}
|
||
}
|
||
|
||
// Update plan token allocations
|
||
async function handleAdminPlanTokensPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
if (!body || typeof body !== 'object' || typeof body.limits !== 'object') return sendJson(res, 400, { error: 'Invalid payload' });
|
||
const incoming = body.limits || {};
|
||
const clean = JSON.parse(JSON.stringify(planTokenLimits || {}));
|
||
for (const plan of Object.keys(clean)) {
|
||
if (incoming[plan] && typeof incoming[plan] === 'number') {
|
||
clean[plan] = Math.max(0, Number(incoming[plan] || 0));
|
||
}
|
||
}
|
||
planTokenLimits = clean;
|
||
await persistPlanTokenLimits();
|
||
sendJson(res, 200, { ok: true, limits: planTokenLimits });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save plan tokens' });
|
||
}
|
||
}
|
||
|
||
// Get token rates
|
||
async function handleAdminTokenRatesGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
sendJson(res, 200, { rates: tokenRates });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Unable to fetch token rates' });
|
||
}
|
||
}
|
||
|
||
// Update token rates
|
||
async function handleAdminTokenRatesPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
if (!body || typeof body !== 'object' || typeof body.rates !== 'object') return sendJson(res, 400, { error: 'Invalid payload' });
|
||
const incoming = body.rates || {};
|
||
const clean = JSON.parse(JSON.stringify(tokenRates || {}));
|
||
for (const currency of Object.keys(clean)) {
|
||
if (incoming[currency] && typeof incoming[currency] === 'number') {
|
||
clean[currency] = Math.max(0, Number(incoming[currency] || 0));
|
||
}
|
||
}
|
||
tokenRates = clean;
|
||
await persistTokenRates();
|
||
sendJson(res, 200, { ok: true, rates: tokenRates });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save token rates' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminProviderLimitsGet(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const discovery = await discoverProviderModels();
|
||
const snapshot = getProviderUsageSnapshot(discovery.providers);
|
||
sendJson(res, 200, {
|
||
limits: providerLimits.limits,
|
||
usage: snapshot,
|
||
opencodeBackupModel: providerLimits.opencodeBackupModel || '',
|
||
providers: discovery.providers,
|
||
providerModels: discovery.providerModels,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error.message || 'Unable to load provider limits' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminProviderLimitsPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const provider = normalizeProviderName(body.provider);
|
||
if (!provider) return sendJson(res, 400, { error: 'Provider is required' });
|
||
const cfg = ensureProviderLimitDefaults(provider);
|
||
if (body.scope === 'model') cfg.scope = 'model';
|
||
else if (body.scope === 'provider') cfg.scope = 'provider';
|
||
|
||
const targetModel = (body.model || '').trim();
|
||
const target = cfg.scope === 'model' && targetModel ? (cfg.perModel[targetModel] = cfg.perModel[targetModel] || {}) : cfg;
|
||
['tokensPerMinute', 'tokensPerHour', 'tokensPerDay', 'requestsPerMinute', 'requestsPerHour', 'requestsPerDay'].forEach((field) => {
|
||
if (body[field] !== undefined) target[field] = sanitizeLimitNumber(body[field]);
|
||
});
|
||
|
||
if (typeof body.opencodeBackupModel === 'string') {
|
||
providerLimits.opencodeBackupModel = body.opencodeBackupModel.trim();
|
||
}
|
||
|
||
await persistProviderLimits();
|
||
const discovery = await discoverProviderModels();
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
limits: providerLimits.limits,
|
||
usage: getProviderUsageSnapshot(discovery.providers),
|
||
opencodeBackupModel: providerLimits.opencodeBackupModel || '',
|
||
providers: discovery.providers,
|
||
providerModels: discovery.providerModels,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save provider limits' });
|
||
}
|
||
}
|
||
|
||
// Admin-only environment diagnostics for debugging missing provider API keys
|
||
async function handleAdminEnvConfig(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const groqKey = process.env.GROQ_API_KEY || process.env.GROQ_API_TOKEN || '';
|
||
const mistralKey = process.env.MISTRAL_API_KEY || process.env.MISTRAL_API_TOKEN || '';
|
||
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_TOKEN || '';
|
||
const chutesKey = process.env.PLUGIN_COMPASS_CHUTES_API_KEY || process.env.CHUTES_API_KEY || process.env.CHUTES_API_TOKEN || '';
|
||
|
||
const payload = {
|
||
GROQ: {
|
||
configured: !!groqKey,
|
||
prefix: groqKey ? `${groqKey.substring(0, 8)}...` : null,
|
||
source: groqKey ? (process.env.GROQ_API_KEY ? 'GROQ_API_KEY' : 'GROQ_API_TOKEN') : null,
|
||
},
|
||
MISTRAL: {
|
||
configured: !!mistralKey,
|
||
prefix: mistralKey ? `${mistralKey.substring(0, 8)}...` : null,
|
||
source: mistralKey ? (process.env.MISTRAL_API_KEY ? 'MISTRAL_API_KEY' : 'MISTRAL_API_TOKEN') : null,
|
||
},
|
||
OPENROUTER: {
|
||
configured: !!openrouterKey,
|
||
prefix: openrouterKey ? `${openrouterKey.substring(0, 8)}...` : null,
|
||
source: openrouterKey ? (process.env.OPENROUTER_API_KEY ? 'OPENROUTER_API_KEY' : 'OPENROUTER_API_TOKEN') : null,
|
||
},
|
||
CHUTES: {
|
||
configured: !!chutesKey,
|
||
prefix: chutesKey ? `${chutesKey.substring(0, 8)}...` : null,
|
||
source: chutesKey ? (process.env.PLUGIN_COMPASS_CHUTES_API_KEY ? 'PLUGIN_COMPASS_CHUTES_API_KEY' : process.env.CHUTES_API_KEY ? 'CHUTES_API_KEY' : 'CHUTES_API_TOKEN') : null,
|
||
},
|
||
};
|
||
|
||
sendJson(res, 200, { ok: true, env: payload });
|
||
} catch (err) {
|
||
sendJson(res, 500, { error: err.message || String(err) });
|
||
}
|
||
}
|
||
|
||
async function handleAdminAccountsList(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
const accountPromises = usersDb.map((u) => serializeAccount(u));
|
||
const accounts = (await Promise.all(accountPromises)).filter(Boolean);
|
||
sendJson(res, 200, { accounts });
|
||
}
|
||
|
||
async function handleAdminAccountPlanUpdate(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const userId = body.userId;
|
||
const plan = normalizePlanSelection(body.plan);
|
||
|
||
if (!userId) return sendJson(res, 400, { error: 'User ID is required' });
|
||
if (!plan) return sendJson(res, 400, { error: 'Invalid plan selected' });
|
||
|
||
const user = findUserById(userId);
|
||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
user.plan = plan;
|
||
// Also update billing status and renewal date if it's a paid plan to make it look "active"
|
||
if (isPaidPlan(plan)) {
|
||
user.billingStatus = 'active';
|
||
user.subscriptionRenewsAt = computeRenewalDate('monthly');
|
||
} else {
|
||
user.billingStatus = 'active';
|
||
user.subscriptionRenewsAt = null;
|
||
}
|
||
|
||
await persistUsersDb();
|
||
const accountData = await serializeAccount(user);
|
||
log('Admin updated user plan', { userId, plan, admin: ADMIN_USER });
|
||
sendJson(res, 200, { ok: true, account: accountData });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to update plan' });
|
||
}
|
||
}
|
||
|
||
// Admin account delete endpoint
|
||
async function handleAdminAccountDelete(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const userId = body.userId;
|
||
|
||
if (!userId) return sendJson(res, 400, { error: 'User ID is required' });
|
||
|
||
const userIndex = usersDb.findIndex(u => u.id === userId);
|
||
if (userIndex === -1) return sendJson(res, 404, { error: 'User not found' });
|
||
|
||
const user = usersDb[userIndex];
|
||
|
||
// Permanently delete user data
|
||
usersDb.splice(userIndex, 1);
|
||
|
||
for (const [token, session] of userSessions.entries()) {
|
||
if (session.userId === userId) {
|
||
userSessions.delete(token);
|
||
}
|
||
}
|
||
|
||
// Remove user's sessions from the main state
|
||
state.sessions = state.sessions.filter(s => s.userId !== userId);
|
||
|
||
// Delete user's workspaces/sessions from file system
|
||
const userWorkspaceDir = path.join(WORKSPACES_ROOT, userId);
|
||
if (fsSync.existsSync(userWorkspaceDir)) {
|
||
try {
|
||
fsSync.rmSync(userWorkspaceDir, { recursive: true, force: true });
|
||
} catch (err) {
|
||
console.error('Failed to delete user workspace:', err);
|
||
// Continue with deletion even if workspace cleanup fails
|
||
}
|
||
}
|
||
|
||
// Delete user's session state
|
||
const userStateFile = path.join(STATE_DIR, `session-${userId}.json`);
|
||
if (fsSync.existsSync(userStateFile)) {
|
||
try {
|
||
fsSync.unlinkSync(userStateFile);
|
||
} catch (err) {
|
||
console.error('Failed to delete user state file:', err);
|
||
// Continue with deletion even if state cleanup fails
|
||
}
|
||
}
|
||
|
||
await persistUsersDb();
|
||
await persistState();
|
||
|
||
log('Admin permanently deleted user', {
|
||
userId,
|
||
email: user.email,
|
||
admin: ADMIN_USER
|
||
});
|
||
|
||
sendJson(res, 200, { ok: true, message: 'User permanently deleted' });
|
||
} catch (error) {
|
||
console.error('Error deleting user:', error);
|
||
sendJson(res, 500, { error: error.message || 'Unable to delete user' });
|
||
}
|
||
}
|
||
|
||
// Affiliate management API endpoints
|
||
async function handleAdminAffiliatesList(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
const affiliates = affiliatesDb.map((a) => summarizeAffiliate(a)).filter(Boolean);
|
||
sendJson(res, 200, { affiliates });
|
||
}
|
||
|
||
async function handleAdminAffiliateDelete(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const affiliateId = body.affiliateId;
|
||
|
||
if (!affiliateId) return sendJson(res, 400, { error: 'Affiliate ID is required' });
|
||
|
||
const affiliateIndex = affiliatesDb.findIndex(a => a.id === affiliateId);
|
||
if (affiliateIndex === -1) return sendJson(res, 404, { error: 'Affiliate not found' });
|
||
|
||
const affiliate = affiliatesDb[affiliateIndex];
|
||
|
||
// Remove from database
|
||
affiliatesDb.splice(affiliateIndex, 1);
|
||
|
||
// Clear any active sessions for this affiliate
|
||
for (const [token, sess] of affiliateSessions.entries()) {
|
||
if (sess.affiliateId === affiliateId) {
|
||
affiliateSessions.delete(token);
|
||
}
|
||
}
|
||
|
||
await persistAffiliatesDb();
|
||
|
||
log('Admin permanently deleted affiliate', {
|
||
affiliateId,
|
||
email: affiliate.email,
|
||
admin: ADMIN_USER
|
||
});
|
||
|
||
sendJson(res, 200, { ok: true, message: 'Affiliate permanently deleted' });
|
||
} catch (error) {
|
||
console.error('Error deleting affiliate:', error);
|
||
sendJson(res, 500, { error: error.message || 'Unable to delete affiliate' });
|
||
}
|
||
}
|
||
|
||
// Tracking API endpoints
|
||
async function handleAdminTrackingStats(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
|
||
try {
|
||
// Convert dailyVisits for serialization
|
||
const dailyVisits = {};
|
||
for (const [date, data] of Object.entries(trackingData.summary.dailyVisits)) {
|
||
dailyVisits[date] = {
|
||
count: data.count,
|
||
uniqueVisitors: data.uniqueVisitors instanceof Set
|
||
? data.uniqueVisitors.size
|
||
: (Array.isArray(data.uniqueVisitors) ? data.uniqueVisitors.length : 0)
|
||
};
|
||
}
|
||
|
||
// Get top referrers
|
||
const referrersList = Object.entries(trackingData.summary.referrers)
|
||
.map(([domain, count]) => ({ domain, count }))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
// Get top pages
|
||
const pagesList = Object.entries(trackingData.summary.pages)
|
||
.map(([path, count]) => ({ path, count }))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
// Get recent visits (last 100)
|
||
const recentVisits = trackingData.visits.slice(-100).reverse();
|
||
|
||
// Get comprehensive analytics summary
|
||
const analytics = getAnalyticsSummary();
|
||
|
||
const stats = {
|
||
// Legacy tracking data
|
||
totalVisits: trackingData.summary.totalVisits,
|
||
uniqueVisitors: trackingData.summary.uniqueVisitors.size,
|
||
topReferrers: referrersList.slice(0, 20),
|
||
topPages: pagesList.slice(0, 20),
|
||
dailyVisits: dailyVisits,
|
||
recentVisits: recentVisits,
|
||
referrersList: referrersList,
|
||
pagesList: pagesList,
|
||
conversions: trackingData.summary.conversions,
|
||
conversionSources: trackingData.summary.conversionSources,
|
||
financials: trackingData.summary.financials,
|
||
referrersToUpgrade: Object.entries(trackingData.summary.referrersToUpgrade || {})
|
||
.map(([domain, count]) => ({ domain, count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 10),
|
||
upgradeSources: trackingData.summary.upgradeSources || {},
|
||
retention: calculateRetention(),
|
||
|
||
// Enhanced Analytics
|
||
userEngagement: analytics.userEngagement,
|
||
featureUsage: analytics.featureUsage,
|
||
modelUsage: analytics.modelUsage,
|
||
exportUsage: analytics.exportUsage,
|
||
errorRates: analytics.errorRates,
|
||
retentionCohorts: analytics.retentionCohorts,
|
||
businessMetrics: analytics.businessMetrics,
|
||
technicalMetrics: analytics.technicalMetrics,
|
||
planUpgradePatterns: analytics.planUpgradePatterns,
|
||
conversionFunnels: analytics.conversionFunnels,
|
||
featureAdoptionByPlan: analytics.featureAdoptionByPlan,
|
||
|
||
// Additional Insights
|
||
sessionInsights: {
|
||
totalSessions: Object.keys(trackingData.userAnalytics.userSessions).length,
|
||
averageSessionDuration: analytics.userEngagement.averageSessionDuration,
|
||
totalProjectsCreated: Object.keys(trackingData.userAnalytics.projectData).length,
|
||
totalExports: Object.values(trackingData.userAnalytics.exportUsage).reduce((a, b) => a + b, 0),
|
||
totalErrors: Object.values(trackingData.userAnalytics.errorRates).reduce((a, b) => a + b, 0)
|
||
}
|
||
};
|
||
|
||
sendJson(res, 200, { ok: true, stats });
|
||
} catch (error) {
|
||
log('Failed to fetch tracking stats', { error: String(error) });
|
||
sendJson(res, 500, { error: error.message || 'Unable to fetch tracking stats' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminCancelMessages(req, res) {
|
||
const adminSession = requireAdminAuth(req, res);
|
||
if (!adminSession) return;
|
||
|
||
try {
|
||
let totalCancelled = 0;
|
||
let sessionsAffected = 0;
|
||
let runningCancelled = 0;
|
||
let queuedCancelled = 0;
|
||
|
||
for (const session of state.sessions) {
|
||
let sessionCancelled = 0;
|
||
for (const message of (session.messages || [])) {
|
||
if (message.status === 'running' || message.status === 'queued') {
|
||
message.status = 'cancelled';
|
||
message.finishedAt = new Date().toISOString();
|
||
message.cancelledAt = new Date().toISOString();
|
||
message.cancelledBy = 'admin';
|
||
if (!message.reply) message.reply = '';
|
||
if (!message.opencodeSummary) message.opencodeSummary = 'Cancelled by admin';
|
||
|
||
if (message.status === 'running') {
|
||
runningCancelled++;
|
||
runningProcesses.delete(message.id);
|
||
if (activeStreams.has(message.id)) {
|
||
const streams = activeStreams.get(message.id);
|
||
streams.forEach(streamRes => {
|
||
try {
|
||
streamRes.write(`data: ${JSON.stringify({ type: 'cancelled', reason: 'admin_cancelled', timestamp: new Date().toISOString() })}\n\n`);
|
||
streamRes.end();
|
||
} catch (e) { }
|
||
});
|
||
activeStreams.delete(message.id);
|
||
}
|
||
} else {
|
||
queuedCancelled++;
|
||
}
|
||
|
||
sessionCancelled++;
|
||
totalCancelled++;
|
||
}
|
||
}
|
||
|
||
if (sessionCancelled > 0) {
|
||
sessionsAffected++;
|
||
sessionQueues.delete(session.id);
|
||
session.updatedAt = new Date().toISOString();
|
||
}
|
||
}
|
||
|
||
if (totalCancelled > 0) {
|
||
await persistState();
|
||
}
|
||
|
||
log('Admin cancelled all messages', { totalCancelled, sessionsAffected, runningCancelled, queuedCancelled, adminId: adminSession.userId });
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
totalCancelled,
|
||
sessionsAffected,
|
||
runningCancelled,
|
||
queuedCancelled
|
||
});
|
||
} catch (error) {
|
||
log('Failed to cancel messages', { error: String(error) });
|
||
sendJson(res, 500, { error: error.message || 'Unable to cancel messages' });
|
||
}
|
||
}
|
||
|
||
// Test Ollama connection from admin panel
|
||
async function handleAdminOllamaTest(req, res) {
|
||
const adminSession = requireAdminAuth(req, res);
|
||
if (!adminSession) return;
|
||
|
||
try {
|
||
const startTime = Date.now();
|
||
|
||
// Log the configuration (without exposing the full API key)
|
||
const apiKeyConfigured = !!OLLAMA_API_KEY;
|
||
const apiKeyPreview = OLLAMA_API_KEY
|
||
? `${OLLAMA_API_KEY.substring(0, 4)}...${OLLAMA_API_KEY.substring(OLLAMA_API_KEY.length - 4)}`
|
||
: 'Not configured';
|
||
|
||
console.log('[ADMIN] Testing Ollama connection', {
|
||
url: OLLAMA_API_URL,
|
||
model: OLLAMA_DEFAULT_MODEL,
|
||
apiKeyConfigured,
|
||
apiKeyPreview
|
||
});
|
||
|
||
// Make a test request to Ollama
|
||
const testMessages = [
|
||
{ role: 'user', content: 'Say "Ollama test successful" and nothing else.' }
|
||
];
|
||
|
||
let result;
|
||
let error = null;
|
||
|
||
try {
|
||
result = await sendOllamaChat({ messages: testMessages, model: OLLAMA_DEFAULT_MODEL });
|
||
} catch (err) {
|
||
error = err;
|
||
console.log('[ADMIN] Ollama test failed', {
|
||
error: err.message,
|
||
status: err.status,
|
||
detail: err.detail
|
||
});
|
||
}
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
// Prepare the response
|
||
const response = {
|
||
ok: !error,
|
||
duration,
|
||
config: {
|
||
url: OLLAMA_API_URL,
|
||
model: OLLAMA_DEFAULT_MODEL,
|
||
apiKeyConfigured,
|
||
apiKeyPreview
|
||
},
|
||
result: error ? null : {
|
||
reply: result.reply,
|
||
model: result.model
|
||
},
|
||
error: error ? {
|
||
message: error.message,
|
||
status: error.status,
|
||
detail: error.detail,
|
||
isAuthError: error.isAuthError,
|
||
isModelMissing: error.isModelMissing,
|
||
code: error.code
|
||
} : null
|
||
};
|
||
|
||
console.log('[ADMIN] Ollama test completed', { ok: response.ok, duration });
|
||
sendJson(res, 200, response);
|
||
} catch (error) {
|
||
console.error('[ADMIN] Ollama test handler error', error);
|
||
sendJson(res, 500, {
|
||
ok: false,
|
||
error: error.message || 'Internal server error during Ollama test'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Get detailed resource usage breakdown by session for admin panel
|
||
async function handleAdminResources(req, res) {
|
||
const adminSession = requireAdminAuth(req, res);
|
||
if (!adminSession) return;
|
||
|
||
try {
|
||
const mem = process.memoryUsage();
|
||
const cpuUsage = process.cpuUsage();
|
||
const now = Date.now();
|
||
|
||
// Calculate per-session memory estimates and collect detailed info
|
||
const sessionsData = state.sessions.map((session) => {
|
||
const sessionAge = now - new Date(session.createdAt).getTime();
|
||
const messages = (session.messages || []).map((msg) => {
|
||
// Estimate message memory footprint
|
||
let messageSize = 0;
|
||
messageSize += (msg.content || '').length * 2; // UTF-16 characters
|
||
messageSize += (msg.reply || '').length * 2;
|
||
messageSize += (msg.partialOutput || '').length * 2;
|
||
messageSize += (msg.opencodeSummary || '').length * 2;
|
||
if (msg.attachments) {
|
||
messageSize += JSON.stringify(msg.attachments).length * 2;
|
||
}
|
||
return {
|
||
id: msg.id,
|
||
role: msg.role,
|
||
status: msg.status,
|
||
model: msg.model,
|
||
createdAt: msg.createdAt,
|
||
finishedAt: msg.finishedAt,
|
||
contentLength: (msg.content || '').length,
|
||
replyLength: (msg.reply || '').length,
|
||
estimatedMemoryBytes: messageSize,
|
||
estimatedMemoryKb: (messageSize / 1024).toFixed(2) + ' KB',
|
||
isRunning: msg.status === 'running',
|
||
isQueued: msg.status === 'queued',
|
||
isDone: msg.status === 'done',
|
||
isError: msg.status === 'error'
|
||
};
|
||
});
|
||
|
||
// Calculate session total
|
||
const totalMessageMemory = messages.reduce((sum, m) => sum + m.estimatedMemoryBytes, 0);
|
||
const runningMessages = messages.filter(m => m.isRunning);
|
||
const queuedMessages = messages.filter(m => m.isQueued);
|
||
const errorMessages = messages.filter(m => m.isError);
|
||
|
||
return {
|
||
id: session.id,
|
||
userId: session.userId,
|
||
title: session.title || 'Untitled',
|
||
appId: session.appId || null,
|
||
appName: session.appName || null,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
ageMs: sessionAge,
|
||
age: formatDuration(sessionAge),
|
||
messageCount: messages.length,
|
||
messages: messages,
|
||
runningMessages: runningMessages.length,
|
||
queuedMessages: queuedMessages.length,
|
||
errorMessages: errorMessages.length,
|
||
totalMessageMemoryBytes: totalMessageMemory,
|
||
totalMessageMemoryKb: (totalMessageMemory / 1024).toFixed(2) + ' KB',
|
||
estimatedSessionMemoryKb: ((totalMessageMemory + 10240) / 1024).toFixed(2) + ' KB', // Base overhead
|
||
pending: session.pending || 0,
|
||
workspaceDir: session.workspaceDir ? session.workspaceDir.replace(WORKSPACES_ROOT, '...') : null,
|
||
model: session.model || null,
|
||
cli: session.cli || 'opencode',
|
||
opencodeSessionId: session.opencodeSessionId || null,
|
||
hasOpencodeSession: !!session.opencodeSessionId
|
||
};
|
||
});
|
||
|
||
// Sort sessions by estimated memory usage (descending)
|
||
const sortedSessions = sessionsData.sort((a, b) => b.totalMessageMemoryBytes - a.totalMessageMemoryBytes);
|
||
|
||
// Get running processes info
|
||
const runningProcessInfo = [];
|
||
for (const [messageId, procInfo] of runningProcesses.entries()) {
|
||
// Find the session and message for this process
|
||
let sessionId = null;
|
||
let messageContent = null;
|
||
for (const session of state.sessions) {
|
||
const msg = session.messages?.find(m => m.id === messageId);
|
||
if (msg) {
|
||
sessionId = session.id;
|
||
messageContent = (msg.content || '').slice(0, 100);
|
||
break;
|
||
}
|
||
}
|
||
runningProcessInfo.push({
|
||
messageId,
|
||
sessionId,
|
||
startTime: procInfo.startTime,
|
||
age: formatDuration(now - procInfo.startTime),
|
||
messagePreview: messageContent
|
||
});
|
||
}
|
||
|
||
// Get child processes info
|
||
const childProcessInfo = [];
|
||
for (const [processId, info] of childProcesses.entries()) {
|
||
const age = now - info.startTime;
|
||
childProcessInfo.push({
|
||
processId,
|
||
pid: info.pid,
|
||
sessionId: info.sessionId,
|
||
messageId: info.messageId,
|
||
startTime: info.startTime,
|
||
age: formatDuration(age),
|
||
ageMs: age
|
||
});
|
||
}
|
||
|
||
// Get active streams info
|
||
const streamInfo = [];
|
||
for (const [messageId, streams] of activeStreams.entries()) {
|
||
// Find the session for this message
|
||
let sessionId = null;
|
||
for (const session of state.sessions) {
|
||
const msg = session.messages?.find(m => m.id === messageId);
|
||
if (msg) {
|
||
sessionId = session.id;
|
||
break;
|
||
}
|
||
}
|
||
streamInfo.push({
|
||
messageId,
|
||
sessionId,
|
||
streamCount: streams.size,
|
||
statuses: Array.from(streams).map(s => s.statusCode || 200)
|
||
});
|
||
}
|
||
|
||
// OpenCode process manager stats
|
||
const managerStats = opencodeManager.getStats ? opencodeManager.getStats() : { isRunning: false };
|
||
|
||
// Calculate totals
|
||
const totals = {
|
||
sessions: sessionsData.length,
|
||
totalMessages: sessionsData.reduce((sum, s) => sum + s.messageCount, 0),
|
||
runningMessages: sessionsData.reduce((sum, s) => sum + s.runningMessages, 0),
|
||
queuedMessages: sessionsData.reduce((sum, s) => sum + s.queuedMessages, 0),
|
||
errorMessages: sessionsData.reduce((sum, s) => sum + s.errorMessages, 0),
|
||
totalEstimatedMemoryMb: (sessionsData.reduce((sum, s) => sum + s.totalMessageMemoryBytes, 0) / (1024 * 1024)).toFixed(2) + ' MB'
|
||
};
|
||
|
||
// System load
|
||
const loadAvg = os.loadavg();
|
||
|
||
sendJson(res, 200, {
|
||
system: {
|
||
memory: {
|
||
rss: `${(mem.rss / 1024 / 1024).toFixed(2)} MB`,
|
||
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(2)} MB`,
|
||
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB`,
|
||
external: `${(mem.external / 1024 / 1024).toFixed(2)} MB`,
|
||
arrayBuffers: `${((mem.arrayBuffers || 0) / 1024 / 1024).toFixed(2)} MB`,
|
||
raw: {
|
||
rss: mem.rss,
|
||
heapTotal: mem.heapTotal,
|
||
heapUsed: mem.heapUsed,
|
||
external: mem.external,
|
||
arrayBuffers: mem.arrayBuffers || 0
|
||
}
|
||
},
|
||
cpu: {
|
||
user: cpuUsage.user,
|
||
system: cpuUsage.system,
|
||
userPercent: ((cpuUsage.user / 1000000 / process.uptime()) * 100).toFixed(1) + '%',
|
||
systemPercent: ((cpuUsage.system / 1000000 / process.uptime()) * 100).toFixed(1) + '%',
|
||
loadAvg1m: loadAvg[0].toFixed(2),
|
||
loadAvg5m: loadAvg[1].toFixed(2),
|
||
loadAvg15m: loadAvg[2].toFixed(2)
|
||
},
|
||
limits: {
|
||
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
||
memoryMb: `${(RESOURCE_LIMITS.memoryBytes / 1024 / 1024).toFixed(2)} MB`,
|
||
cpuCores: RESOURCE_LIMITS.cpuCores,
|
||
memoryPercentUsed: ((mem.rss / RESOURCE_LIMITS.memoryBytes) * 100).toFixed(1) + '%'
|
||
},
|
||
process: {
|
||
uptime: process.uptime(),
|
||
uptimeFormatted: formatDuration(process.uptime() * 1000),
|
||
pid: process.pid,
|
||
nodeVersion: process.version,
|
||
platform: process.platform,
|
||
arch: process.arch
|
||
}
|
||
},
|
||
totals,
|
||
sessions: sortedSessions,
|
||
runningProcesses: runningProcessInfo,
|
||
childProcesses: childProcessInfo,
|
||
activeStreams: streamInfo,
|
||
opencode: {
|
||
mode: managerStats.isRunning ? 'singleton' : 'per-session',
|
||
isReady: managerStats.isReady || false,
|
||
pendingRequests: managerStats.pendingRequests || 0,
|
||
sessionWorkspaces: managerStats.activeSessions || 0,
|
||
runningInstances: managerStats.activeSessions || 0
|
||
},
|
||
maps: {
|
||
sessionQueues: sessionQueues.size,
|
||
activeStreams: activeStreams.size,
|
||
runningProcesses: runningProcesses.size,
|
||
childProcesses: childProcesses.size,
|
||
oauthStates: typeof oauthStateStore !== 'undefined' ? oauthStateStore.size : 0,
|
||
loginAttempts: typeof loginAttempts !== 'undefined' ? loginAttempts.size : 0,
|
||
adminLoginAttempts: typeof adminLoginAttempts !== 'undefined' ? adminLoginAttempts.size : 0,
|
||
apiRateLimit: typeof apiRateLimit !== 'undefined' ? apiRateLimit.size : 0
|
||
},
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
} catch (error) {
|
||
log('Failed to get resource usage', { error: String(error) });
|
||
sendJson(res, 500, { error: error.message || 'Unable to fetch resource usage' });
|
||
}
|
||
}
|
||
|
||
async function handleAdminExternalTestingStatus(req, res) {
|
||
const adminSession = requireAdminAuth(req, res);
|
||
if (!adminSession) return;
|
||
const config = getExternalTestingConfig();
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
config: {
|
||
wpHost: config.wpHost || '',
|
||
wpPath: config.wpPath || '',
|
||
wpBaseUrl: config.wpBaseUrl || '',
|
||
enableMultisite: !!config.enableMultisite,
|
||
subsiteMode: config.subsiteMode,
|
||
subsiteDomain: config.subsiteDomain || '',
|
||
maxConcurrentTests: config.maxConcurrentTests,
|
||
autoCleanup: !!config.autoCleanup,
|
||
cleanupDelayMs: config.cleanupDelayMs,
|
||
sshKeyConfigured: Boolean(config.wpSshKey),
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleAdminExternalTestingSelfTest(req, res) {
|
||
const adminSession = requireAdminAuth(req, res);
|
||
if (!adminSession) return;
|
||
|
||
let tempDir;
|
||
try {
|
||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pc-external-test-'));
|
||
const pluginSlug = 'pc-external-test';
|
||
const pluginDir = path.join(tempDir, pluginSlug);
|
||
await fs.mkdir(pluginDir, { recursive: true });
|
||
const pluginFile = path.join(pluginDir, `${pluginSlug}.php`);
|
||
const pluginContents = `<?php\n/**\n * Plugin Name: External Testing Self Check\n * Plugin URI: https://plugincompass.com\n * Author: Plugin Compass\n * Version: 0.1.0\n */\n\nfunction pc_external_test_activate() {\n update_option('pc_external_test_flag', 'ok');\n}\nregister_activation_hook(__FILE__, 'pc_external_test_activate');\n`;
|
||
await fs.writeFile(pluginFile, pluginContents, 'utf8');
|
||
|
||
const testInput = {
|
||
plugin_path: pluginDir,
|
||
plugin_slug: pluginSlug,
|
||
test_mode: 'cli',
|
||
test_scenarios: [
|
||
{
|
||
name: 'Activate self-test plugin',
|
||
type: 'custom',
|
||
wp_cli_command: `plugin activate ${pluginSlug}`,
|
||
assertions: { wp_cli_success: true, not_contains: ['Fatal error', 'Parse error'] }
|
||
},
|
||
{
|
||
name: 'Activation hook wrote option',
|
||
type: 'custom',
|
||
wp_cli_command: 'eval "echo get_option(\'pc_external_test_flag\') ? get_option(\'pc_external_test_flag\') : \"missing\";"',
|
||
assertions: { contains: ['ok'] }
|
||
}
|
||
]
|
||
};
|
||
|
||
const result = await externalWpTester.runTest(testInput, { configOverrides: {} });
|
||
sendJson(res, 200, { ok: true, result });
|
||
} catch (error) {
|
||
sendJson(res, 500, { ok: false, error: error.message || 'Self-test failed' });
|
||
} finally {
|
||
if (tempDir) {
|
||
fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleAdminOpenRouterSettingsPost(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
openrouterSettings = {
|
||
primaryModel: (body.primaryModel || '').trim(),
|
||
backupModel1: (body.backupModel1 || '').trim(),
|
||
backupModel2: (body.backupModel2 || '').trim(),
|
||
backupModel3: (body.backupModel3 || '').trim(),
|
||
};
|
||
await persistOpenRouterSettings();
|
||
sendJson(res, 200, { ok: true, settings: openrouterSettings });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to save settings' });
|
||
}
|
||
}
|
||
|
||
async function handleNewSession(req, res, userId) {
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
const session = await createSession(
|
||
{
|
||
title: body.title,
|
||
model: body.model,
|
||
cli: body.cli,
|
||
appId: body.appId || body.app || body.appSlug,
|
||
reuseAppId: body.reuseAppId === true
|
||
},
|
||
userId
|
||
);
|
||
|
||
// Handle template loading
|
||
if (body.templateId) {
|
||
try {
|
||
const templatePath = path.join(__dirname, 'templates', sanitizeSegment(body.templateId));
|
||
// recursive copy function - explicitly defined here to avoid reference issues
|
||
const copyDirRecursive = async (src, dest) => {
|
||
await fs.mkdir(dest, { recursive: true });
|
||
const entries = await fs.readdir(src, { withFileTypes: true });
|
||
for (let entry of entries) {
|
||
const srcPath = path.join(src, entry.name);
|
||
const destPath = path.join(dest, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await copyDirRecursive(srcPath, destPath);
|
||
} else {
|
||
await fs.copyFile(srcPath, destPath);
|
||
}
|
||
}
|
||
};
|
||
|
||
if (session.workspaceDir) {
|
||
await copyDirRecursive(templatePath, session.workspaceDir);
|
||
session.planSummary = `Started from template: ${body.templateId}`;
|
||
session.planApproved = true; // Skip plan phase
|
||
session.entryMode = 'opencode'; // Force builder to build mode
|
||
log('Template loaded into session', { sessionId: session.id, templateId: body.templateId });
|
||
}
|
||
} catch (err) {
|
||
log('Failed to copy template', { error: String(err) });
|
||
}
|
||
}
|
||
|
||
await persistState();
|
||
sendJson(res, 201, { session: serializeSession(session) });
|
||
} catch (error) {
|
||
const status = error.statusCode && Number.isInteger(error.statusCode) ? error.statusCode : 400;
|
||
sendJson(res, status, { error: error.message || 'Unable to create session' });
|
||
}
|
||
}
|
||
|
||
async function handleListSessions(req, res, userId) {
|
||
// Allow optional filtering by appId query param to support client-side lookups
|
||
try {
|
||
const url = new URL(req.url || '', 'http://localhost');
|
||
const rawAppId = url.searchParams.get('appId') || '';
|
||
const appId = sanitizeSegment(rawAppId, '');
|
||
const sessions = state.sessions.filter((s) => s.userId === userId && (!appId || s.appId === appId));
|
||
sendJson(res, 200, { sessions: sessions.map(serializeSession) });
|
||
} catch (err) {
|
||
// Fallback: return all sessions if parsing fails
|
||
const sessions = state.sessions.filter((s) => s.userId === userId);
|
||
sendJson(res, 200, { sessions: sessions.map(serializeSession) });
|
||
}
|
||
}
|
||
|
||
async function handleGetSession(_req, res, sessionId, userId) { const session = getSession(sessionId, userId); if (!session) return sendJson(res, 404, { error: 'Session not found' }); sendJson(res, 200, { session: serializeSession(session) }); }
|
||
|
||
async function handleGetSessionTodos(_req, res, sessionId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
// Get all todos from messages in the session
|
||
const allTodos = [];
|
||
if (session.messages) {
|
||
for (const msg of session.messages) {
|
||
if (msg.todos && Array.isArray(msg.todos)) {
|
||
// Add message ID to each todo for reference
|
||
const todosWithMeta = msg.todos.map(todo => ({
|
||
...todo,
|
||
messageId: msg.id,
|
||
messageStatus: msg.status
|
||
}));
|
||
allTodos.push(...todosWithMeta);
|
||
}
|
||
}
|
||
}
|
||
|
||
sendJson(res, 200, { todos: allTodos });
|
||
}
|
||
|
||
async function handleNewMessage(req, res, sessionId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
try {
|
||
await ensureSessionPaths(session);
|
||
const body = await parseJsonBody(req);
|
||
|
||
// Security: Check for prompt injection attacks
|
||
const rawContent = body.content || '';
|
||
const securityCheck = shouldBlockUserInput(rawContent);
|
||
if (securityCheck.blocked) {
|
||
return sendJson(res, 400, {
|
||
error: 'Message blocked',
|
||
blocked: true,
|
||
reason: securityCheck.reason,
|
||
message: 'This message was blocked due to potential security concerns. If you believe this is an error, please contact support.'
|
||
});
|
||
}
|
||
|
||
const content = sanitizeMessage(rawContent);
|
||
const displayContent = typeof body.displayContent === 'string' && body.displayContent.trim()
|
||
? body.displayContent.trim()
|
||
: content;
|
||
// Allow empty content if there are attachments
|
||
const hasAttachments = Array.isArray(body.attachments) && body.attachments.length;
|
||
if (!content && !hasAttachments) return sendJson(res, 400, { error: 'Message is required' });
|
||
const userPlan = resolveUserPlan(session.userId);
|
||
|
||
// Paid plans only: image attachments.
|
||
if (hasAttachments) {
|
||
const hasImage = body.attachments.some((a) => a && isImageMime(a.type));
|
||
if (hasImage && !isPaidPlan(userPlan)) {
|
||
return sendJson(res, 402, { error: 'Image uploads are available on Business and Enterprise plans only. Please upgrade to attach images.' });
|
||
}
|
||
}
|
||
const model = resolvePlanModel(userPlan, body.model || session.model);
|
||
const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER; // include headroom for reply
|
||
const allowance = canConsumeTokens(session.userId, userPlan, estimatedTokens);
|
||
if (!allowance.allowed) {
|
||
const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance';
|
||
return sendJson(res, 402, { error: `You have reached your token allowance (${friendlyRemaining}). Upgrade or add a boost.`, allowance });
|
||
}
|
||
|
||
// If this request will trigger an external WP test, enforce plan limits immediately and return a friendly upgrade prompt
|
||
if (body.isProceedWithBuild && body.externalTestingEnabled === true) {
|
||
const user = findUserById(session.userId);
|
||
const limitCheck = canUseExternalTesting(session.userId, userPlan, user?.unlimitedUsage === true);
|
||
if (!limitCheck.allowed) {
|
||
return sendJson(res, 402, { error: 'External WP CLI testing limit reached for your plan. Upgrade to run more tests.', externalTesting: limitCheck.summary, upgradeUrl: '/topup' });
|
||
}
|
||
}
|
||
const cli = normalizeCli(body.cli || session.cli);
|
||
const now = new Date().toISOString();
|
||
const message = { id: randomUUID(), role: 'user', content, originalContent: content, displayContent, model, cli, status: 'queued', createdAt: now, updatedAt: now, opencodeTokensUsed: null };
|
||
if (body.isProceedWithBuild) message.isProceedWithBuild = true;
|
||
if (body.externalTestingEnabled !== undefined) {
|
||
message.externalTestingEnabled = body.externalTestingEnabled === true;
|
||
}
|
||
// Copy continuation-related fields for background continuations
|
||
if (body.isContinuation) message.isContinuation = true;
|
||
if (body.isBackgroundContinuation) message.isBackgroundContinuation = true;
|
||
if (body.originalMessageId) message.originalMessageId = body.originalMessageId;
|
||
// Preserve opencodeSessionId for session continuity in retries/continuations
|
||
// Also prioritize body.opencodeSessionId over session.opencodeSessionId for explicit continuation
|
||
if (body.opencodeSessionId) {
|
||
message.opencodeSessionId = body.opencodeSessionId;
|
||
log('Using explicit opencodeSessionId from request body', { opencodeSessionId: body.opencodeSessionId });
|
||
} else if (session.opencodeSessionId) {
|
||
message.opencodeSessionId = session.opencodeSessionId;
|
||
log('Inheriting opencodeSessionId from session', { opencodeSessionId: session.opencodeSessionId });
|
||
}
|
||
// Process attachments if provided as base64 JSON entries
|
||
if (hasAttachments) {
|
||
message.attachments = [];
|
||
for (const att of body.attachments) {
|
||
try {
|
||
if (!att.data || !att.type) continue;
|
||
|
||
// Validate file size
|
||
const buffer = Buffer.from(att.data, 'base64');
|
||
if (buffer.length > MAX_ATTACHMENT_SIZE) { log('attachment too large, skipping', { size: buffer.length, max: MAX_ATTACHMENT_SIZE, name: att.name }); continue; }
|
||
|
||
// Validate MIME type
|
||
const clientMimeType = att.type.toLowerCase();
|
||
const allowedMimeTypes = [
|
||
'image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp',
|
||
'application/pdf', 'text/plain', 'text/css', 'application/javascript',
|
||
'text/html', 'text/markdown', 'text/csv', 'application/json',
|
||
'application/xml', 'text/xml'
|
||
];
|
||
|
||
if (!allowedMimeTypes.includes(clientMimeType)) {
|
||
log('attachment rejected - invalid mime type', { mimeType: clientMimeType, name: att.name });
|
||
continue;
|
||
}
|
||
|
||
// Magic byte verification for images
|
||
if (clientMimeType.startsWith('image/')) {
|
||
if (!validateImageSignature(clientMimeType, buffer)) {
|
||
log('attachment rejected - invalid image signature', { mimeType: clientMimeType, name: att.name });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Compress images before storing.
|
||
let storedBuffer = buffer;
|
||
let storedMimeType = clientMimeType;
|
||
let forcedExt = null;
|
||
if (clientMimeType.startsWith('image/')) {
|
||
const compressed = await compressImageBuffer(buffer, clientMimeType);
|
||
storedBuffer = compressed.buffer;
|
||
storedMimeType = compressed.mimeType;
|
||
forcedExt = compressed.ext;
|
||
}
|
||
|
||
const id = `${randomUUID()}-${safeFileNamePart(att.name)}`;
|
||
const ext = forcedExt || extensionForMime(storedMimeType);
|
||
const safeExt = String(ext || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10) || 'bin';
|
||
const filename = `${id}.${safeExt}`;
|
||
const outPath = path.join(session.uploadsDir, filename);
|
||
await fs.writeFile(outPath, storedBuffer);
|
||
const attachmentUrl = `/uploads/${session.id}/${session.attachmentKey}/${filename}`;
|
||
message.attachments.push({ name: att.name || filename, type: storedMimeType, url: attachmentUrl, size: storedBuffer.length, originalType: clientMimeType, originalSize: buffer.length });
|
||
} catch (err) {
|
||
log('attachment save failed', { err: String(err), attName: att.name });
|
||
}
|
||
}
|
||
}
|
||
session.messages.push(message);
|
||
session.model = model;
|
||
session.cli = cli;
|
||
session.updatedAt = now;
|
||
updatePending(sessionId, 1, session.userId);
|
||
await persistState();
|
||
await queueMessage(sessionId, message);
|
||
sendJson(res, 202, { message });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to queue message' });
|
||
}
|
||
}
|
||
|
||
// SSE endpoint for real-time streaming
|
||
async function handleMessageStream(req, res, sessionId, messageId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
const message = session.messages.find(m => m.id === messageId);
|
||
if (!message) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
// Set up SSE headers
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Connection': 'keep-alive',
|
||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||
});
|
||
|
||
try { req.socket.setTimeout(0); } catch (_) { }
|
||
try { req.socket.setKeepAlive(true); } catch (_) { }
|
||
try { res.flushHeaders(); } catch (_) { }
|
||
|
||
// Register this stream
|
||
if (!activeStreams.has(messageId)) {
|
||
activeStreams.set(messageId, new Set());
|
||
}
|
||
activeStreams.get(messageId).add(res);
|
||
|
||
log('SSE stream opened', { sessionId, messageId, activeStreams: activeStreams.size });
|
||
|
||
// Declare timers before cleanupStream to avoid hoisting issues
|
||
let heartbeat = null;
|
||
let streamTimeout = null;
|
||
|
||
// Helper to cleanup this specific stream
|
||
const cleanupStream = () => {
|
||
clearInterval(heartbeat);
|
||
clearTimeout(streamTimeout);
|
||
if (activeStreams.has(messageId)) {
|
||
activeStreams.get(messageId).delete(res);
|
||
if (activeStreams.get(messageId).size === 0) {
|
||
activeStreams.delete(messageId);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Send initial status
|
||
const initialData = JSON.stringify({
|
||
type: 'start',
|
||
messageId,
|
||
status: message.status,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
try {
|
||
res.write(`data: ${initialData}\n\n`);
|
||
} catch (err) {
|
||
cleanupStream();
|
||
return;
|
||
}
|
||
|
||
// If message already has output, send it
|
||
if (message.partialOutput) {
|
||
const catchupData = JSON.stringify({
|
||
type: 'chunk',
|
||
content: message.partialOutput,
|
||
filtered: message.partialOutput,
|
||
outputType: message.outputType,
|
||
partialOutput: message.partialOutput,
|
||
timestamp: message.partialUpdatedAt || new Date().toISOString()
|
||
});
|
||
try {
|
||
res.write(`data: ${catchupData}\n\n`);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// If message is already done, send completion
|
||
if (message.status === 'done' || message.status === 'error' || message.status === 'cancelled' || message.status === 'skipped') {
|
||
const completeData = JSON.stringify({
|
||
type: message.status === 'error' ? 'error' : 'complete',
|
||
content: message.reply || message.partialOutput,
|
||
error: message.error,
|
||
outputType: message.outputType,
|
||
exitCode: message.opencodeExitCode,
|
||
timestamp: message.finishedAt || new Date().toISOString()
|
||
});
|
||
try {
|
||
res.write(`data: ${completeData}\n\n`);
|
||
res.end();
|
||
} catch (_) {}
|
||
cleanupStream();
|
||
return;
|
||
}
|
||
|
||
// Stream timeout - close streams that have been open too long (30 minutes max)
|
||
const STREAM_MAX_DURATION_MS = 30 * 60 * 1000;
|
||
streamTimeout = setTimeout(() => {
|
||
try {
|
||
const timeoutData = JSON.stringify({
|
||
type: 'timeout',
|
||
message: 'Stream timeout - please refresh to reconnect',
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
res.write(`data: ${timeoutData}\n\n`);
|
||
res.end();
|
||
} catch (_) {}
|
||
cleanupStream();
|
||
log('SSE stream timeout', { sessionId, messageId });
|
||
}, STREAM_MAX_DURATION_MS);
|
||
|
||
// Keep connection alive with heartbeat/pings.
|
||
// Send a small data event periodically so proxies/load balancers don't treat the stream as idle.
|
||
let heartbeatCount = 0;
|
||
heartbeat = setInterval(() => {
|
||
try {
|
||
heartbeatCount++;
|
||
|
||
// Check if message has completed while stream was open
|
||
const currentMsg = session.messages.find(m => m.id === messageId);
|
||
if (currentMsg && (currentMsg.status === 'done' || currentMsg.status === 'error' || currentMsg.status === 'cancelled')) {
|
||
// Message completed - send final status and close
|
||
const completeData = JSON.stringify({
|
||
type: currentMsg.status === 'error' ? 'error' : 'complete',
|
||
content: currentMsg.reply || currentMsg.partialOutput,
|
||
error: currentMsg.error,
|
||
outputType: currentMsg.outputType,
|
||
exitCode: currentMsg.opencodeExitCode,
|
||
timestamp: currentMsg.finishedAt || new Date().toISOString()
|
||
});
|
||
res.write(`data: ${completeData}\n\n`);
|
||
res.end();
|
||
cleanupStream();
|
||
return;
|
||
}
|
||
|
||
res.write(`: heartbeat ${heartbeatCount} ${Date.now()}\n\n`);
|
||
const healthData = JSON.stringify({
|
||
type: 'health',
|
||
timestamp: new Date().toISOString(),
|
||
heartbeatCount,
|
||
status: currentMsg?.status || message.status
|
||
});
|
||
res.write(`data: ${healthData}\n\n`);
|
||
} catch (err) {
|
||
cleanupStream();
|
||
}
|
||
}, 15000);
|
||
|
||
// Clean up on client disconnect
|
||
req.on('close', () => {
|
||
cleanupStream();
|
||
log('SSE stream closed by client', { sessionId, messageId });
|
||
});
|
||
|
||
// Also handle errors
|
||
req.on('error', (err) => {
|
||
cleanupStream();
|
||
log('SSE stream error', { sessionId, messageId, error: String(err) });
|
||
});
|
||
|
||
res.on('error', (err) => {
|
||
cleanupStream();
|
||
log('SSE response error', { sessionId, messageId, error: String(err) });
|
||
});
|
||
}
|
||
|
||
// Check if opencode is currently running for a message
|
||
async function handleRunningStatus(req, res, sessionId, messageId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
const message = session.messages.find(m => m.id === messageId);
|
||
if (!message) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
const isRunning = runningProcesses.has(messageId);
|
||
const processInfo = runningProcesses.get(messageId);
|
||
|
||
sendJson(res, 200, {
|
||
running: isRunning,
|
||
status: message.status,
|
||
process: processInfo ? {
|
||
started: processInfo.started,
|
||
duration: Date.now() - processInfo.started,
|
||
cli: processInfo.cli,
|
||
model: processInfo.model
|
||
} : null,
|
||
hasOutput: !!(message.partialOutput || message.reply),
|
||
outputType: message.outputType
|
||
});
|
||
}
|
||
|
||
// Get current status of opencode CLI
|
||
async function handleOpencodeStatus(req, res) {
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
|
||
// Get OpenCode process manager stats
|
||
const managerStats = opencodeManager.getStats();
|
||
|
||
try {
|
||
const { stdout } = await runCommand(cliCommand, ['--version'], { timeout: 3000 });
|
||
sendJson(res, 200, {
|
||
available: true,
|
||
version: stdout.trim(),
|
||
command: cliCommand,
|
||
runningProcesses: runningProcesses.size,
|
||
activeStreams: activeStreams.size,
|
||
processManager: {
|
||
...managerStats,
|
||
mode: managerStats.isRunning ? 'singleton' : 'per-session',
|
||
description: managerStats.isRunning
|
||
? 'All sessions sharing single OpenCode instance'
|
||
: 'Each message spawns separate OpenCode process'
|
||
}
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 200, {
|
||
available: false,
|
||
error: error.message,
|
||
command: cliCommand,
|
||
runningProcesses: runningProcesses.size,
|
||
activeStreams: activeStreams.size,
|
||
processManager: {
|
||
...managerStats,
|
||
mode: managerStats.isRunning ? 'singleton' : 'per-session'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Get memory and resource statistics for monitoring
|
||
async function handleMemoryStats(req, res) {
|
||
const mem = process.memoryUsage();
|
||
const now = Date.now();
|
||
|
||
// Count sessions and messages
|
||
let totalMessages = 0;
|
||
let runningMessages = 0;
|
||
let queuedMessages = 0;
|
||
let oldSessions = 0;
|
||
|
||
for (const session of state.sessions) {
|
||
if (session.messages) {
|
||
totalMessages += session.messages.length;
|
||
for (const msg of session.messages) {
|
||
if (msg.status === 'running') runningMessages++;
|
||
if (msg.status === 'queued') queuedMessages++;
|
||
}
|
||
}
|
||
// Check for old sessions
|
||
const sessionAge = now - new Date(session.createdAt).getTime();
|
||
if (sessionAge > SESSION_MAX_AGE_MS) oldSessions++;
|
||
}
|
||
|
||
sendJson(res, 200, {
|
||
memory: {
|
||
rss: `${(mem.rss / 1024 / 1024).toFixed(2)} MB`,
|
||
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(2)} MB`,
|
||
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB`,
|
||
external: `${(mem.external / 1024 / 1024).toFixed(2)} MB`,
|
||
arrayBuffers: `${((mem.arrayBuffers || 0) / 1024 / 1024).toFixed(2)} MB`,
|
||
raw: {
|
||
rss: mem.rss,
|
||
heapTotal: mem.heapTotal,
|
||
heapUsed: mem.heapUsed,
|
||
external: mem.external,
|
||
arrayBuffers: mem.arrayBuffers || 0
|
||
}
|
||
},
|
||
limits: {
|
||
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
||
memoryMb: `${(RESOURCE_LIMITS.memoryBytes / 1024 / 1024).toFixed(2)} MB`,
|
||
cpuCores: RESOURCE_LIMITS.cpuCores,
|
||
softMemoryRatio: RESOURCE_MEMORY_SOFT_RATIO,
|
||
softMemoryBytes: RESOURCE_LIMITS.memoryBytes * RESOURCE_MEMORY_SOFT_RATIO
|
||
},
|
||
processes: {
|
||
uptime: process.uptime(),
|
||
pid: process.pid,
|
||
cpuUsage: process.cpuUsage(),
|
||
resourceReservations: resourceReservations
|
||
},
|
||
maps: {
|
||
sessions: state.sessions.length,
|
||
sessionQueues: sessionQueues.size,
|
||
activeStreams: activeStreams.size,
|
||
runningProcesses: runningProcesses.size,
|
||
childProcesses: childProcesses.size,
|
||
oauthStates: typeof oauthStateStore !== 'undefined' ? oauthStateStore.size : 0,
|
||
loginAttempts: typeof loginAttempts !== 'undefined' ? loginAttempts.size : 0,
|
||
adminLoginAttempts: typeof adminLoginAttempts !== 'undefined' ? adminLoginAttempts.size : 0,
|
||
apiRateLimit: typeof apiRateLimit !== 'undefined' ? apiRateLimit.size : 0
|
||
},
|
||
messages: {
|
||
total: totalMessages,
|
||
running: runningMessages,
|
||
queued: queuedMessages
|
||
},
|
||
sessions: {
|
||
total: state.sessions.length,
|
||
old: oldSessions
|
||
},
|
||
cleanup: {
|
||
lastCleanup: lastMemoryCleanup,
|
||
lastCleanupAgo: `${Math.round((now - lastMemoryCleanup) / 1000)}s ago`,
|
||
cleanupIntervalMs: MEMORY_CLEANUP_INTERVAL_MS
|
||
},
|
||
load: {
|
||
loadAvg: os.loadavg(),
|
||
freeMem: `${(os.freemem() / 1024 / 1024).toFixed(2)} MB`,
|
||
totalMem: `${(os.totalmem() / 1024 / 1024).toFixed(2)} MB`
|
||
}
|
||
});
|
||
}
|
||
|
||
// Trigger manual memory cleanup (admin only)
|
||
async function handleForceMemoryCleanup(req, res) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const beforeMem = process.memoryUsage();
|
||
triggerMemoryCleanup('admin_manual');
|
||
const afterMem = process.memoryUsage();
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
before: {
|
||
rss: `${(beforeMem.rss / 1024 / 1024).toFixed(2)} MB`,
|
||
heapUsed: `${(beforeMem.heapUsed / 1024 / 1024).toFixed(2)} MB`
|
||
},
|
||
after: {
|
||
rss: `${(afterMem.rss / 1024 / 1024).toFixed(2)} MB`,
|
||
heapUsed: `${(afterMem.heapUsed / 1024 / 1024).toFixed(2)} MB`
|
||
},
|
||
freed: {
|
||
rss: `${((beforeMem.rss - afterMem.rss) / 1024 / 1024).toFixed(2)} MB`,
|
||
heapUsed: `${((beforeMem.heapUsed - afterMem.heapUsed) / 1024 / 1024).toFixed(2)} MB`
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle undo request - sends /undo command to opencode to revert file changes
|
||
async function handleUndoMessage(req, res, sessionId, messageId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
const message = session.messages.find(m => m.id === messageId);
|
||
if (!message) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
// Only allow undo for opencode messages
|
||
if (message.cli !== 'opencode') {
|
||
return sendJson(res, 400, { error: 'Undo only available for opencode messages' });
|
||
}
|
||
|
||
try {
|
||
log('Sending undo command to opencode', { sessionId, messageId, opencodeSessionId: session.opencodeSessionId });
|
||
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
const args = ['--message', '/undo'];
|
||
|
||
// Add session if we have one
|
||
if (session.opencodeSessionId) {
|
||
args.push('--session', session.opencodeSessionId);
|
||
}
|
||
|
||
await runCommand(cliCommand, args, {
|
||
cwd: session.workspaceDir || REPO_ROOT,
|
||
timeout: 30000
|
||
});
|
||
|
||
// Clear todos from the message when undone
|
||
if (message.todos) {
|
||
const todoCount = message.todos.length;
|
||
message.todos = [];
|
||
log('Cleared todos from undone message', { sessionId, messageId, clearedCount: todoCount });
|
||
}
|
||
|
||
// Remove the message from session history
|
||
const messageIndex = session.messages.findIndex(m => m.id === messageId);
|
||
if (messageIndex !== -1) {
|
||
session.messages.splice(messageIndex, 1);
|
||
log('Removed message from history', { sessionId, messageId, remainingMessages: session.messages.length });
|
||
}
|
||
|
||
// Persist the updated state
|
||
await persistState();
|
||
|
||
log('Undo command completed', { sessionId, messageId });
|
||
sendJson(res, 200, { ok: true, message: 'Undo command sent successfully' });
|
||
} catch (error) {
|
||
log('Undo command failed', { sessionId, messageId, error: String(error) });
|
||
sendJson(res, 500, { error: `Undo failed: ${error.message}` });
|
||
}
|
||
}
|
||
|
||
async function handleRedoMessage(req, res, sessionId, messageId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
|
||
const message = session.messages.find(m => m.id === messageId);
|
||
if (!message) return sendJson(res, 404, { error: 'Message not found' });
|
||
|
||
// Only allow redo for opencode messages
|
||
if (message.cli !== 'opencode') {
|
||
return sendJson(res, 400, { error: 'Redo only available for opencode messages' });
|
||
}
|
||
|
||
// Ensure we have an opencode session
|
||
if (!session.opencodeSessionId) {
|
||
return sendJson(res, 400, { error: 'No opencode session found. Cannot redo without an active session.' });
|
||
}
|
||
|
||
try {
|
||
log('Redoing message', { sessionId, messageId, opencodeSessionId: session.opencodeSessionId });
|
||
|
||
// Clear the message state to trigger reprocessing
|
||
// Clear content/response
|
||
message.content = message.originalContent || message.content;
|
||
message.response = null;
|
||
message.error = null;
|
||
message.status = 'queued';
|
||
message.completedAt = null;
|
||
message.toolCalls = null;
|
||
message.toolResults = null;
|
||
message.streamBuffer = '';
|
||
message.fullResponse = null;
|
||
|
||
// Clear todos from the message when redone (they will be regenerated during the new execution)
|
||
if (message.todos) {
|
||
const todoCount = message.todos.length;
|
||
message.todos = [];
|
||
log('Cleared todos from redone message', { sessionId, messageId, clearedCount: todoCount });
|
||
}
|
||
|
||
// Persist the state change
|
||
await persistState();
|
||
|
||
// Trigger message processing (non-blocking)
|
||
processMessage(sessionId, message).catch(err => {
|
||
log('Redo message processing failed', { sessionId, messageId, error: String(err) });
|
||
});
|
||
|
||
log('Redo initiated successfully', { sessionId, messageId });
|
||
sendJson(res, 200, { ok: true, message: 'Redo initiated successfully' });
|
||
} catch (error) {
|
||
log('Redo failed', { sessionId, messageId, error: String(error) });
|
||
sendJson(res, 500, { error: `Redo failed: ${error.message}` });
|
||
}
|
||
}
|
||
|
||
// Add small debug logging for message lifecycle
|
||
|
||
function traceMessageLifecycle(stage, sessionId, message) {
|
||
try {
|
||
const snippet = (message && message.content) ? (String(message.content).slice(0, 200)) : '';
|
||
log(`message ${stage}`, { sessionId, messageId: message && message.id, model: message && message.model, snippet });
|
||
} catch (err) {
|
||
log('traceMessageLifecycle error', { err: String(err) });
|
||
}
|
||
}
|
||
|
||
async function handleUpdateSession(req, res, sessionId, userId) {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
try {
|
||
const body = await parseJsonBody(req);
|
||
let changed = false;
|
||
if (body.model && typeof body.model === 'string' && body.model.trim()) {
|
||
const plan = resolveUserPlan(session.userId);
|
||
session.model = resolvePlanModel(plan, body.model.trim());
|
||
session.updatedAt = new Date().toISOString();
|
||
changed = true;
|
||
}
|
||
if (body.title && typeof body.title === 'string' && body.title.trim()) {
|
||
session.title = body.title.trim();
|
||
session.updatedAt = new Date().toISOString();
|
||
changed = true;
|
||
}
|
||
if (body.cli && typeof body.cli === 'string') {
|
||
session.cli = normalizeCli(body.cli);
|
||
session.updatedAt = new Date().toISOString();
|
||
changed = true;
|
||
}
|
||
if (changed) await persistState();
|
||
sendJson(res, 200, { session: serializeSession(session) });
|
||
} catch (error) {
|
||
sendJson(res, 400, { error: error.message || 'Unable to update session' });
|
||
}
|
||
}
|
||
|
||
async function handleDeleteSession(req, res, sessionId, userId) {
|
||
const idx = state.sessions.findIndex((s) => s.id === sessionId && s.userId === userId);
|
||
if (idx === -1) return sendJson(res, 404, { error: 'Session not found' });
|
||
const removed = state.sessions.splice(idx, 1)[0];
|
||
// Remove any queue for the session
|
||
if (sessionQueues.has(sessionId)) sessionQueues.delete(sessionId);
|
||
// Delete workspace directory from disk
|
||
if (removed.workspaceDir) {
|
||
const workspaceRoot = path.resolve(WORKSPACES_ROOT);
|
||
const workspacePath = path.resolve(removed.workspaceDir);
|
||
if (workspacePath.startsWith(workspaceRoot)) {
|
||
try {
|
||
await fs.rm(workspacePath, { recursive: true, force: true });
|
||
log('workspace directory deleted', { sessionId, workspacePath });
|
||
} catch (err) {
|
||
log('workspace directory cleanup failed', { sessionId, workspacePath, err: String(err) });
|
||
}
|
||
}
|
||
}
|
||
try { await persistState(); } catch (err) { log('failed to persist state after delete', { err: String(err) }); }
|
||
log('session deleted', { id: sessionId });
|
||
sendJson(res, 200, { ok: true, session: serializeSession(removed) });
|
||
}
|
||
|
||
async function handleDiagnostics(_req, res) {
|
||
try {
|
||
const versionRes = await runCommand('opencode', ['--version'], { timeout: 5000 }).catch((e) => ({ stdout: e.stdout, stderr: e.stderr, code: e.code }));
|
||
const modelRes = await runCommand('opencode', ['models'], { timeout: 10000 }).catch((e) => ({ stdout: e.stdout, stderr: e.stderr, code: e.code }));
|
||
sendJson(res, 200, { version: versionRes.stdout || versionRes.stderr, modelsOutput: modelRes.stdout || modelRes.stderr });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: String(error.message || error) });
|
||
}
|
||
}
|
||
|
||
async function handleAccountClaim(req, res, userId) {
|
||
try {
|
||
// Require proper user authentication for account claiming
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
|
||
const body = await parseJsonBody(req);
|
||
const previousUserId = sanitizeSegment(body.previousUserId || body.deviceUserId || body.fromUserId || '', '');
|
||
const targetUserId = session.userId; // Use the authenticated user ID
|
||
|
||
if (!previousUserId || previousUserId === targetUserId) {
|
||
return sendJson(res, 200, { ok: true, moved: 0, skipped: 0 });
|
||
}
|
||
|
||
const result = await migrateUserSessions(previousUserId, targetUserId);
|
||
log('account claim completed', { previousUserId, targetUserId, moved: result.moved, skipped: result.skipped });
|
||
return sendJson(res, 200, { ok: true, moved: result.moved, skipped: result.skipped });
|
||
} catch (error) {
|
||
return sendJson(res, 400, { error: error.message || 'Unable to claim account' });
|
||
}
|
||
}
|
||
|
||
async function handleUploadApp(req, res, userId) {
|
||
const plan = resolveUserPlan(userId);
|
||
if (!isPaidPlan(plan)) {
|
||
return sendJson(res, 403, { error: 'Uploading existing apps is available on Business and Enterprise plans.' });
|
||
}
|
||
|
||
// Base64 adds ~33% overhead (4/3 ratio); allow a small buffer over that.
|
||
const uploadLimit = Math.max(MAX_JSON_BODY_SIZE, Math.ceil(MAX_UPLOAD_ZIP_SIZE * BASE64_OVERHEAD_MULTIPLIER));
|
||
let body;
|
||
try {
|
||
body = await parseJsonBody(req, uploadLimit);
|
||
} catch (err) {
|
||
return sendJson(res, 400, { error: err.message || 'Invalid upload payload' });
|
||
}
|
||
|
||
const rawData = (body.data || body.fileData || '').toString();
|
||
if (!rawData) return sendJson(res, 400, { error: 'ZIP file is required' });
|
||
|
||
let zipBuffer;
|
||
try {
|
||
zipBuffer = decodeBase64Payload(rawData);
|
||
} catch (err) {
|
||
return sendJson(res, 400, { error: 'Could not decode uploaded file' });
|
||
}
|
||
|
||
if (!zipBuffer || !zipBuffer.length) return sendJson(res, 400, { error: 'ZIP file is empty' });
|
||
if (zipBuffer.length > MAX_UPLOAD_ZIP_SIZE) {
|
||
const mb = Math.ceil(MAX_UPLOAD_ZIP_SIZE / (1024 * 1024));
|
||
return sendJson(res, 413, { error: `ZIP too large. Maximum size is ${mb} MB.` });
|
||
}
|
||
if (!isLikelyZip(zipBuffer)) return sendJson(res, 400, { error: 'Only ZIP archives are supported.' });
|
||
|
||
const displayName = (body.title || body.name || body.fileName || 'Uploaded App').toString().trim() || 'Uploaded App';
|
||
const baseName = path.parse(body.fileName || displayName).name || displayName;
|
||
const desiredAppId = sanitizeSegment(body.appId || baseName, '');
|
||
|
||
let session;
|
||
try {
|
||
session = await createSession({
|
||
title: displayName,
|
||
appId: desiredAppId,
|
||
cli: 'opencode',
|
||
model: 'default',
|
||
entryMode: 'opencode',
|
||
source: 'upload',
|
||
planApproved: true,
|
||
}, userId, desiredAppId);
|
||
|
||
// Ensure workspace exists and is clean
|
||
if (session.workspaceDir) {
|
||
const workspaceRoot = path.resolve(WORKSPACES_ROOT);
|
||
const workspacePath = path.resolve(session.workspaceDir);
|
||
if (!workspacePath.startsWith(workspaceRoot)) throw new Error('Invalid workspace path');
|
||
await fs.rm(workspacePath, { recursive: true, force: true }).catch((err) => {
|
||
log('workspace cleanup failed', { path: workspacePath, err: String(err) });
|
||
});
|
||
await fs.mkdir(workspacePath, { recursive: true });
|
||
}
|
||
if (session.uploadsDir) {
|
||
await fs.mkdir(session.uploadsDir, { recursive: true }).catch(() => { });
|
||
}
|
||
|
||
const files = await extractZipToWorkspace(zipBuffer, session.workspaceDir);
|
||
session.planSummary = session.planSummary || 'Imported from ZIP upload';
|
||
session.planUserRequest = session.planUserRequest || displayName;
|
||
|
||
// Try to detect and preserve version from imported plugin
|
||
try {
|
||
// Collect PHP files to find main plugin file
|
||
const validFiles = [];
|
||
await collectValidFiles(session.workspaceDir, session.workspaceDir, validFiles, [
|
||
'node_modules',
|
||
'.git',
|
||
'.data',
|
||
'uploads',
|
||
'*.log',
|
||
'*.zip'
|
||
]);
|
||
|
||
// Find main plugin file and extract version
|
||
for (const fileInfo of validFiles) {
|
||
if (fileInfo.fullPath.endsWith('.php')) {
|
||
try {
|
||
const content = await fs.readFile(fileInfo.fullPath, 'utf-8');
|
||
if (content.includes('Plugin Name:')) {
|
||
const versions = versionManager.extractAllVersions(content);
|
||
if (versions.detectedVersion) {
|
||
session.pluginVersion = versions.detectedVersion;
|
||
session.pluginVersionHistory = [{
|
||
version: versions.detectedVersion,
|
||
previousVersion: null,
|
||
bumpType: 'import',
|
||
timestamp: new Date().toISOString(),
|
||
fileUpdated: fileInfo.relativePath,
|
||
note: 'Imported from uploaded ZIP'
|
||
}];
|
||
log('Detected version from imported plugin', {
|
||
sessionId: session.id,
|
||
version: versions.detectedVersion,
|
||
file: fileInfo.relativePath
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Continue to next file
|
||
}
|
||
}
|
||
}
|
||
} catch (versionError) {
|
||
log('Version detection failed during import', { error: String(versionError) });
|
||
// Don't fail the import if version detection fails
|
||
}
|
||
|
||
await persistState();
|
||
return sendJson(res, 201, { session: serializeSession(session), files });
|
||
} catch (error) {
|
||
// Clean up the failed session if it exists
|
||
if (session) {
|
||
const idx = state.sessions.findIndex((s) => s.id === session.id);
|
||
if (idx !== -1) state.sessions.splice(idx, 1);
|
||
try { await persistState(); } catch (_) { }
|
||
if (session.workspaceDir) {
|
||
try { await fs.rm(session.workspaceDir, { recursive: true, force: true }); } catch (_) { }
|
||
}
|
||
}
|
||
return sendJson(res, 500, { error: `Unable to import ZIP: ${error.message}` });
|
||
}
|
||
}
|
||
|
||
// Export app as ZIP file
|
||
async function handleExportZip(_req, res, sessionId, userId) {
|
||
try {
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'Session not found' });
|
||
await ensureSessionPaths(session);
|
||
|
||
// Track export start
|
||
const userPlan = resolveUserPlan(session.userId);
|
||
trackUserSession(session.userId, 'export', {
|
||
exportType: 'zip',
|
||
sessionId: sessionId,
|
||
appId: session.appId,
|
||
plan: userPlan
|
||
});
|
||
trackFeatureUsage('app_export', session.userId, userPlan);
|
||
trackConversionFunnel('app_export', 'export_started', session.userId, {
|
||
sessionId: sessionId,
|
||
appId: session.appId,
|
||
plan: userPlan
|
||
});
|
||
|
||
log('Starting export to ZIP...', { sessionId, userId: session.userId, workspace: session.workspaceDir });
|
||
|
||
// Check if workspace directory exists and has files
|
||
let validFiles = [];
|
||
try {
|
||
await fs.access(session.workspaceDir);
|
||
// Collect all valid files with their relative paths
|
||
await collectValidFiles(session.workspaceDir, session.workspaceDir, validFiles, [
|
||
'node_modules',
|
||
'.git',
|
||
'.data',
|
||
'uploads',
|
||
'*.log',
|
||
'*.zip'
|
||
]);
|
||
|
||
if (validFiles.length === 0) {
|
||
log('Workspace is empty', { workspace: session.workspaceDir });
|
||
return sendJson(res, 400, {
|
||
error: 'No app content found to export. Please create or build your app first before exporting.'
|
||
});
|
||
}
|
||
|
||
// Check file count limit
|
||
if (validFiles.length > MAX_EXPORT_FILE_COUNT) {
|
||
log('Workspace exceeds file count limit', { workspace: session.workspaceDir, fileCount: validFiles.length, limit: MAX_EXPORT_FILE_COUNT });
|
||
return sendJson(res, 400, {
|
||
error: `Export contains too many files (${validFiles.length}). Maximum allowed is ${MAX_EXPORT_FILE_COUNT} files.`
|
||
});
|
||
}
|
||
} catch (accessError) {
|
||
log('Workspace directory not accessible', { workspace: session.workspaceDir, error: String(accessError) });
|
||
return sendJson(res, 400, {
|
||
error: 'App workspace not found. Please create your app first before exporting.'
|
||
});
|
||
}
|
||
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
|
||
// Use pluginSlug for ZIP filename if available to ensure WordPress recognizes same plugin across versions
|
||
const exportBaseName = session.pluginSlug || sanitizeSegment(session.appId || session.id || 'app-export', 'app-export');
|
||
const zipFilename = `${exportBaseName}-${timestamp}.zip`;
|
||
const zipPath = path.join('/tmp', zipFilename);
|
||
|
||
// Create ZIP using archiver
|
||
const output = fsSync.createWriteStream(zipPath);
|
||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||
|
||
let fileCount = 0;
|
||
let totalUncompressedSize = 0;
|
||
|
||
// Set up size limit enforcement
|
||
archive.on('entry', (data) => {
|
||
fileCount++;
|
||
totalUncompressedSize += data.size;
|
||
if (totalUncompressedSize > MAX_EXPORT_ZIP_SIZE) {
|
||
archive.emit('error', new Error(`Export size exceeds ${Math.ceil(MAX_EXPORT_ZIP_SIZE / (1024 * 1024))}MB limit`));
|
||
}
|
||
});
|
||
|
||
await new Promise(async (resolve, reject) => {
|
||
output.on('close', resolve);
|
||
archive.on('error', (err) => {
|
||
// Clean up temp file on error
|
||
fs.unlink(zipPath).catch(() => { });
|
||
reject(err);
|
||
});
|
||
archive.pipe(output);
|
||
|
||
// Find the main plugin PHP file by looking for WordPress plugin header
|
||
let mainPluginFile = null;
|
||
let pluginContent = null;
|
||
|
||
for (const fileInfo of validFiles) {
|
||
if (fileInfo.fullPath.endsWith('.php')) {
|
||
try {
|
||
const content = await fs.readFile(fileInfo.fullPath, 'utf-8');
|
||
// Check for WordPress plugin header
|
||
if (content.includes('Plugin Name:') || content.includes('Plugin URI:')) {
|
||
mainPluginFile = fileInfo;
|
||
pluginContent = content;
|
||
break;
|
||
}
|
||
} catch (err) {
|
||
// Continue if we can't read the file
|
||
}
|
||
}
|
||
}
|
||
|
||
// Version management for WordPress plugins
|
||
let versionUpdatedContent = null;
|
||
let oldVersion = null;
|
||
let newVersion = null;
|
||
|
||
if (mainPluginFile && pluginContent) {
|
||
try {
|
||
// Extract current version from plugin file
|
||
const versions = versionManager.extractAllVersions(pluginContent);
|
||
const detectedVersion = versions.detectedVersion;
|
||
|
||
// Determine which version to use:
|
||
// 1. Session version if available and valid
|
||
// 2. Detected version from file
|
||
// 3. Default to 1.0.0 if nothing found
|
||
let currentVersion = session.pluginVersion || detectedVersion || '1.0.0';
|
||
|
||
// If we detected a version from file but session doesn't have one, use detected
|
||
if (detectedVersion && !session.pluginVersion) {
|
||
currentVersion = detectedVersion;
|
||
log('Detected version from imported plugin file', {
|
||
file: mainPluginFile.relativePath,
|
||
version: detectedVersion
|
||
});
|
||
}
|
||
|
||
// Bump the version (default to patch bump)
|
||
// Note: In the future, this could be configurable via query parameter
|
||
newVersion = versionManager.bumpVersion(currentVersion, 'patch');
|
||
|
||
if (newVersion && newVersion !== currentVersion) {
|
||
// Update the plugin content with new version
|
||
versionUpdatedContent = versionManager.updateVersionInContent(
|
||
pluginContent,
|
||
currentVersion,
|
||
newVersion
|
||
);
|
||
|
||
if (versionUpdatedContent) {
|
||
oldVersion = currentVersion;
|
||
|
||
// Update session tracking
|
||
session.pluginVersion = newVersion;
|
||
session.lastVersionBumpType = 'patch';
|
||
session.pluginVersionHistory.push({
|
||
version: newVersion,
|
||
previousVersion: currentVersion,
|
||
bumpType: 'patch',
|
||
timestamp: new Date().toISOString(),
|
||
fileUpdated: mainPluginFile.relativePath
|
||
});
|
||
|
||
log('Bumped plugin version', {
|
||
from: currentVersion,
|
||
to: newVersion,
|
||
file: mainPluginFile.relativePath,
|
||
sessionId: session.id
|
||
});
|
||
}
|
||
} else {
|
||
// Keep current version if bump failed
|
||
session.pluginVersion = currentVersion;
|
||
}
|
||
} catch (versionError) {
|
||
log('Version detection/update failed, continuing with original files', {
|
||
error: String(versionError),
|
||
file: mainPluginFile?.relativePath
|
||
});
|
||
// Don't fail the export if version management fails
|
||
}
|
||
}
|
||
|
||
// Determine the plugin folder name
|
||
// WordPress identifies plugins by folder name + main file name
|
||
let pluginFolderName = session.pluginSlug;
|
||
|
||
if (mainPluginFile && !pluginFolderName) {
|
||
// If we don't have a pluginSlug, derive it from the main plugin file
|
||
const pathParts = mainPluginFile.fullPath.split(path.sep);
|
||
const mainPluginFolderIndex = pathParts.length - 2;
|
||
if (mainPluginFolderIndex >= 0) {
|
||
const existingFolder = pathParts[mainPluginFolderIndex];
|
||
pluginFolderName = existingFolder;
|
||
log('Using folder from main plugin file', { file: mainPluginFile.relativePath, folder: existingFolder });
|
||
}
|
||
} else if (mainPluginFile && pluginFolderName) {
|
||
const pathParts = mainPluginFile.fullPath.split(path.sep);
|
||
const mainPluginFolderIndex = pathParts.length - 2;
|
||
if (mainPluginFolderIndex >= 0) {
|
||
const existingFolder = pathParts[mainPluginFolderIndex];
|
||
log('Plugin folder will be normalized', { file: mainPluginFile.relativePath, existingFolder, pluginSlug: pluginFolderName });
|
||
}
|
||
}
|
||
|
||
// Determine the optimal root to avoid nested wrappers
|
||
const optimalRoot = findOptimalExportRoot(validFiles, session.workspaceDir);
|
||
|
||
// Add files to archive with corrected paths
|
||
// For WordPress plugins, we MUST ensure the folder name is consistent across exports
|
||
// WordPress identifies plugins by: folder-name/main-file.php
|
||
const wrapInPluginFolder = pluginFolderName ? pluginFolderName : null;
|
||
|
||
// Check if we should rename the existing folder to match pluginSlug
|
||
// This is critical for WordPress to recognize it as the same plugin across exports
|
||
let renameFirstLevelDir = null;
|
||
|
||
if (wrapInPluginFolder) {
|
||
// Analyze first-level directory structure (only actual directories, not files)
|
||
const firstLevelDirs = new Set();
|
||
for (const fileInfo of validFiles) {
|
||
const relativePath = path.relative(optimalRoot, fileInfo.fullPath);
|
||
const parts = relativePath.split(path.sep);
|
||
if (parts.length > 1 && parts[0]) {
|
||
firstLevelDirs.add(parts[0]);
|
||
}
|
||
}
|
||
|
||
// If there's exactly one first-level directory and it doesn't match our target,
|
||
// we'll rename it in the archive
|
||
if (firstLevelDirs.size === 1) {
|
||
const existingDir = Array.from(firstLevelDirs)[0];
|
||
if (existingDir !== wrapInPluginFolder) {
|
||
renameFirstLevelDir = existingDir;
|
||
log('Renaming plugin folder for consistency', { from: existingDir, to: wrapInPluginFolder });
|
||
}
|
||
} else if (firstLevelDirs.size === 0) {
|
||
// No subdirectories, files are at root level of optimalRoot
|
||
// We'll wrap them in the pluginSlug folder
|
||
log('Files at root level, wrapping in plugin folder', { folder: wrapInPluginFolder });
|
||
}
|
||
}
|
||
|
||
for (const fileInfo of validFiles) {
|
||
const relativePath = path.relative(optimalRoot, fileInfo.fullPath);
|
||
|
||
// Check if this is the main plugin file with updated content
|
||
const isMainPluginFile = mainPluginFile && fileInfo.fullPath === mainPluginFile.fullPath;
|
||
|
||
if (wrapInPluginFolder) {
|
||
let archivePath;
|
||
|
||
if (renameFirstLevelDir) {
|
||
// Rename the first-level directory to match pluginSlug
|
||
const pathParts = relativePath.split(path.sep);
|
||
if (pathParts[0] === renameFirstLevelDir) {
|
||
pathParts[0] = wrapInPluginFolder;
|
||
archivePath = pathParts.join(path.sep);
|
||
} else {
|
||
archivePath = path.join(wrapInPluginFolder, relativePath);
|
||
}
|
||
} else {
|
||
// Wrap in pluginSlug folder
|
||
archivePath = path.join(wrapInPluginFolder, relativePath);
|
||
}
|
||
|
||
// If this is the main plugin file and we have updated content, use it
|
||
if (isMainPluginFile && versionUpdatedContent) {
|
||
archive.append(Buffer.from(versionUpdatedContent, 'utf-8'), { name: archivePath });
|
||
} else {
|
||
archive.file(fileInfo.fullPath, { name: archivePath });
|
||
}
|
||
} else {
|
||
// If this is the main plugin file and we have updated content, use it
|
||
if (isMainPluginFile && versionUpdatedContent) {
|
||
archive.append(Buffer.from(versionUpdatedContent, 'utf-8'), { name: relativePath });
|
||
} else {
|
||
archive.file(fileInfo.fullPath, { name: relativePath });
|
||
}
|
||
}
|
||
}
|
||
|
||
archive.finalize();
|
||
});
|
||
|
||
// Check if any files were added to the archive
|
||
if (fileCount === 0) {
|
||
await fs.unlink(zipPath).catch(() => { });
|
||
log('No files added to archive', { workspace: session.workspaceDir });
|
||
return sendJson(res, 400, {
|
||
error: 'No files found to export. Your app workspace appears to be empty.'
|
||
});
|
||
}
|
||
|
||
// Read and send the ZIP file
|
||
const zipContent = await fs.readFile(zipPath);
|
||
|
||
// Check final size limit
|
||
if (zipContent.length > MAX_EXPORT_ZIP_SIZE) {
|
||
await fs.unlink(zipPath).catch(() => { });
|
||
log('Export exceeds size limit', { size: zipContent.length, limit: MAX_EXPORT_ZIP_SIZE });
|
||
return sendJson(res, 400, {
|
||
error: `Export size (${Math.ceil(zipContent.length / (1024 * 1024))}MB) exceeds the maximum allowed size (${Math.ceil(MAX_EXPORT_ZIP_SIZE / (1024 * 1024))}MB). Try excluding large files like images or binaries.`
|
||
});
|
||
}
|
||
|
||
// Clean up temp file
|
||
await fs.unlink(zipPath).catch(() => { });
|
||
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/zip',
|
||
'Content-Disposition': `attachment; filename="${zipFilename}"`,
|
||
'Content-Length': zipContent.length
|
||
});
|
||
res.end(zipContent);
|
||
log('Export completed successfully', {
|
||
filename: zipFilename,
|
||
size: zipContent.length,
|
||
fileCount,
|
||
version: newVersion || session.pluginVersion || 'unknown',
|
||
previousVersion: oldVersion || 'initial'
|
||
});
|
||
} catch (error) {
|
||
if (error.message && error.message.includes('size exceeds')) {
|
||
return sendJson(res, 400, { error: error.message });
|
||
}
|
||
log('Export failed', { error: String(error) });
|
||
sendJson(res, 500, { error: `Export failed: ${error.message}` });
|
||
}
|
||
}
|
||
|
||
// Helper function to collect all valid files recursively
|
||
async function collectValidFiles(rootDir, currentDir, validFiles, excludePatterns) {
|
||
let entries;
|
||
try {
|
||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(currentDir, entry.name);
|
||
const relativePath = path.relative(rootDir, fullPath);
|
||
const parts = relativePath.split(path.sep);
|
||
|
||
// Check if any part matches exclusion patterns
|
||
const isExcluded = parts.some(part => {
|
||
if (part.startsWith('.')) return true;
|
||
if (excludePatterns.includes(part)) return true;
|
||
// Check wildcard patterns like *.log, *.zip
|
||
return excludePatterns.some(pattern => {
|
||
if (pattern.startsWith('*.')) {
|
||
const ext = pattern.slice(1);
|
||
return entry.name.endsWith(ext);
|
||
}
|
||
return false;
|
||
});
|
||
});
|
||
|
||
if (isExcluded) continue;
|
||
|
||
if (entry.isDirectory()) {
|
||
await collectValidFiles(rootDir, fullPath, validFiles, excludePatterns);
|
||
} else if (entry.isFile()) {
|
||
// Exclude files named opencode.json specifically
|
||
if (entry.name !== 'opencode.json') {
|
||
validFiles.push({ fullPath, relativePath });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper function to find the optimal root for export
|
||
// Avoids single-level wrapper folders that contain everything
|
||
function findOptimalExportRoot(files, workspaceRoot) {
|
||
if (files.length === 0) return workspaceRoot;
|
||
|
||
// Get all unique first-level directories
|
||
const firstLevelDirs = new Set();
|
||
const firstLevelFiles = new Set();
|
||
|
||
for (const fileInfo of files) {
|
||
const relativePath = fileInfo.relativePath;
|
||
const parts = relativePath.split(path.sep);
|
||
if (parts.length > 1) {
|
||
firstLevelDirs.add(parts[0]);
|
||
} else {
|
||
firstLevelFiles.add(parts[0]);
|
||
}
|
||
}
|
||
|
||
// If there are files directly at the root level, that's the optimal root
|
||
if (firstLevelFiles.size > 0) {
|
||
return workspaceRoot;
|
||
}
|
||
|
||
// If there's only one first-level directory, check if it's a wrapper
|
||
if (firstLevelDirs.size === 1) {
|
||
const singleDir = Array.from(firstLevelDirs)[0];
|
||
const allFilesInSingleDir = files.every(f => {
|
||
const parts = f.relativePath.split(path.sep);
|
||
return parts[0] === singleDir;
|
||
});
|
||
|
||
// Check if this single directory looks like a wrapper (has no direct files, only subdirs)
|
||
const filesInSingleDir = files.filter(f => {
|
||
const parts = f.relativePath.split(path.sep);
|
||
return parts[0] === singleDir;
|
||
});
|
||
|
||
const hasDirectFilesInSingleDir = filesInSingleDir.some(f => {
|
||
const parts = f.relativePath.split(path.sep);
|
||
return parts.length === 2; // Only one level deep in the single dir
|
||
});
|
||
|
||
// If all files are in a single subdirectory and there are direct files there,
|
||
// we should use that subdirectory as the root
|
||
if (allFilesInSingleDir && hasDirectFilesInSingleDir) {
|
||
return path.join(workspaceRoot, singleDir);
|
||
}
|
||
}
|
||
|
||
// Default to workspace root
|
||
return workspaceRoot;
|
||
}
|
||
|
||
// Helper function to get all files recursively (alternative approach)
|
||
async function getAllFilesRecursively(dir) {
|
||
const files = [];
|
||
try {
|
||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
files.push(...await getAllFilesRecursively(fullPath));
|
||
} else if (entry.isFile()) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
} catch {
|
||
// Directory might not exist or be accessible
|
||
}
|
||
return files;
|
||
}
|
||
|
||
async function handleListTemplates(req, res) {
|
||
try {
|
||
const templatesPath = path.join(__dirname, 'templates', 'templates.json');
|
||
const content = await fs.readFile(templatesPath, 'utf-8');
|
||
const templates = JSON.parse(content);
|
||
sendJson(res, 200, { templates });
|
||
} catch (error) {
|
||
if (error.code === 'ENOENT') {
|
||
return sendJson(res, 200, { templates: [] });
|
||
}
|
||
log('Failed to list templates', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to list templates' });
|
||
}
|
||
}
|
||
|
||
async function runGit(commands, opts = {}) {
|
||
// Ensure git operations are serialized to avoid concurrent fetch/pulls producing
|
||
// "cannot lock ref" errors when remote refs change during a simultaneous fetch.
|
||
// We also retry on transient ref lock errors ("cannot lock ref", "unable to update local ref",
|
||
// "index.lock") a few times with backoff to reduce races.
|
||
if (!global.__opencode_git_queue) global.__opencode_git_queue = Promise.resolve();
|
||
const enqueue = (workFn) => {
|
||
const next = global.__opencode_git_queue.then(() => workFn());
|
||
// Prevent the queue from getting rejected - keep it alive
|
||
global.__opencode_git_queue = next.catch(() => { });
|
||
return next;
|
||
};
|
||
|
||
const worker = async () => {
|
||
let last = '';
|
||
let combinedStdout = '';
|
||
let combinedStderr = '';
|
||
const credentials = (opts && opts.credentials) ? opts.credentials : null;
|
||
const os = require('os');
|
||
const tmpdir = require('path');
|
||
const fsSync = require('fs');
|
||
const tmp = require('fs/promises');
|
||
let tempHomeDir;
|
||
let envOverride = null;
|
||
if (credentials && credentials.username && credentials.pat) {
|
||
const prefix = 'web-chat-netrc-';
|
||
tempHomeDir = await tmp.mkdtemp(tmpdir.join(os.tmpdir(), prefix));
|
||
const netrcPath = tmpdir.join(tempHomeDir, '.netrc');
|
||
const netrcContent = `machine github.com\nlogin ${credentials.username}\npassword ${credentials.pat}\n`;
|
||
await tmp.writeFile(netrcPath, netrcContent, { mode: 0o600, encoding: 'utf8' });
|
||
envOverride = { ...process.env, HOME: tempHomeDir, GIT_TERMINAL_PROMPT: '0' };
|
||
}
|
||
try {
|
||
for (const step of commands) {
|
||
const shouldRetryOnRefLock = step.cmd === 'git' && Array.isArray(step.args) && (step.args.includes('pull') || step.args.includes('fetch'));
|
||
let attempt = 0;
|
||
while (true) {
|
||
attempt += 1;
|
||
try {
|
||
const spawnOpts = { cwd: REPO_ROOT };
|
||
if (envOverride) spawnOpts.env = envOverride;
|
||
const { stdout, stderr } = await runCommand(step.cmd, step.args, spawnOpts);
|
||
combinedStdout += stdout || '';
|
||
combinedStderr += stderr || '';
|
||
last = stdout || last;
|
||
break; // step succeeded
|
||
} catch (error) {
|
||
// If this looks like a remote ref lock/update race, retry a few times
|
||
const stderrData = String(error.stderr || '').toLowerCase() + ' ' + String(error.stdout || '').toLowerCase();
|
||
if (shouldRetryOnRefLock && attempt < 4 && (stderrData.includes('cannot lock ref') || stderrData.includes('unable to update local ref') || stderrData.includes('index.lock'))) {
|
||
const backoff = Math.min(2000, 100 * Math.pow(2, attempt));
|
||
log('Git operation had a ref-lock problem, retrying', { cmd: step.cmd, args: step.args, attempt, backoff, stderr: error.stderr || error.stdout || '' });
|
||
await new Promise((r) => setTimeout(r, backoff));
|
||
continue; // try again
|
||
}
|
||
const err = new Error(String(error.message || error));
|
||
err.stdout = (combinedStdout || '') + (error.stdout || '');
|
||
err.stderr = (combinedStderr || '') + (error.stderr || '');
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
if (tempHomeDir) {
|
||
try { await tmp.rm(tempHomeDir, { recursive: true, force: true }); } catch (ignore) { }
|
||
}
|
||
}
|
||
const result = { stdout: (combinedStdout || '').trim(), stderr: (combinedStderr || '').trim(), last: (last || '').trim() };
|
||
return result;
|
||
}
|
||
|
||
// Run a serialized git worker so commands don't run concurrently.
|
||
return enqueue(worker);
|
||
}
|
||
|
||
// Attempt to create an opencode session on the CLI side. Returns the id if created or null.
|
||
async function createOpencodeSession(wantedId, model, cwd) {
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
const candidates = [
|
||
['session', 'create', '--id', wantedId, '--model', model, '--json'],
|
||
['session', 'create', '--id', wantedId, '--json'],
|
||
['sessions', 'create', '--id', wantedId, '--model', model, '--json'],
|
||
['sessions', 'create', '--id', wantedId, '--json'],
|
||
['session', 'create', '--model', model, '--json'],
|
||
['sessions', 'create', '--model', model, '--json'],
|
||
['session', 'create', '--json'],
|
||
];
|
||
for (const args of candidates) {
|
||
try {
|
||
log('creating opencode session (candidate)', { args });
|
||
const { stdout } = await runCommand(cliCommand, args, { timeout: 15000, cwd: cwd || REPO_ROOT });
|
||
try {
|
||
const parsed = JSON.parse(stdout);
|
||
const id = parsed.id || (parsed.session && parsed.session.id) || parsed.sessionId || parsed.session_id || null;
|
||
if (id) return id;
|
||
if (typeof parsed === 'string' && parsed.startsWith('ses-')) return parsed;
|
||
} catch (_) {
|
||
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||
for (const line of lines) {
|
||
const m = line.match(/(ses-[a-f0-9\-]+)/i);
|
||
if (m) return m[1];
|
||
}
|
||
}
|
||
} catch (err) {
|
||
log('session creation candidate failed', { args, stderr: err.stderr || err.stdout || String(err) });
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function listOpencodeSessions(cwd) {
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
const candidates = [
|
||
['session', '--list', '--json'],
|
||
['sessions', '--list', '--json'],
|
||
['session', 'list', '--json'],
|
||
['sessions', 'list', '--json'],
|
||
];
|
||
|
||
for (const args of candidates) {
|
||
try {
|
||
const { stdout } = await runCommand(cliCommand, args, { timeout: 7000, cwd });
|
||
if (!stdout || !stdout.trim()) continue;
|
||
|
||
try {
|
||
const parsed = JSON.parse(stdout);
|
||
const items = Array.isArray(parsed)
|
||
? parsed
|
||
: (Array.isArray(parsed.sessions) ? parsed.sessions
|
||
: (Array.isArray(parsed.data) ? parsed.data
|
||
: (Array.isArray(parsed.items) ? parsed.items : [])));
|
||
|
||
return items.map((it) => ({
|
||
id: it?.id || it?.sessionId || it?.session_id || null,
|
||
createdAt: it?.createdAt || it?.created_at || it?.startedAt || it?.startTime || null,
|
||
updatedAt: it?.updatedAt || it?.updated_at || it?.lastUpdatedAt || it?.last_updated_at || null,
|
||
})).filter((it) => it.id);
|
||
} catch (_) {
|
||
const matches = stdout.match(/ses[-_][a-zA-Z0-9-]+/g) || [];
|
||
return matches.map((id) => ({ id }));
|
||
}
|
||
} catch (_) {
|
||
// try next candidate
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
async function getOpencodeSessionTokenUsage(sessionId, cwd) {
|
||
if (!sessionId || !cwd) {
|
||
log('⚠️ getOpencodeSessionTokenUsage: Missing required parameters', { hasSessionId: !!sessionId, hasCwd: !!cwd });
|
||
return 0;
|
||
}
|
||
|
||
const cliCommand = resolveCliCommand('opencode');
|
||
const candidates = [
|
||
['session', 'info', '--id', sessionId, '--json'],
|
||
['sessions', 'info', '--id', sessionId, '--json'],
|
||
['session', 'info', sessionId, '--json'],
|
||
['session', 'usage', '--id', sessionId, '--json'],
|
||
['session', 'show', '--id', sessionId, '--json'],
|
||
];
|
||
|
||
log('🔍 getOpencodeSessionTokenUsage: Starting session token query', {
|
||
sessionId,
|
||
cwd,
|
||
cliCommand,
|
||
candidateCount: candidates.length,
|
||
candidates: candidates.map(c => c.join(' '))
|
||
});
|
||
|
||
const attemptResults = [];
|
||
|
||
for (const args of candidates) {
|
||
const cmdStr = args.join(' ');
|
||
try {
|
||
log(` → Trying: ${cliCommand} ${cmdStr}`, { sessionId });
|
||
const { stdout, stderr } = await runCommand(cliCommand, args, { timeout: 10000, cwd });
|
||
|
||
const hasStdout = stdout && stdout.trim();
|
||
const hasStderr = stderr && stderr.trim();
|
||
|
||
log(` ← Response received`, {
|
||
args: cmdStr,
|
||
hasStdout,
|
||
hasStderr,
|
||
stdoutLength: stdout?.length || 0,
|
||
stderrLength: stderr?.length || 0,
|
||
stdoutSample: stdout?.substring(0, 300),
|
||
stderrSample: stderr?.substring(0, 200)
|
||
});
|
||
|
||
if (hasStdout) {
|
||
// Try JSON parsing first
|
||
try {
|
||
const parsed = JSON.parse(stdout);
|
||
log(' ✓ JSON parse successful', {
|
||
args: cmdStr,
|
||
parsedKeys: Object.keys(parsed),
|
||
hasUsage: !!parsed.usage,
|
||
hasTokens: !!parsed.tokens,
|
||
hasTokensUsed: !!parsed.tokensUsed,
|
||
hasSession: !!parsed.session
|
||
});
|
||
|
||
const extracted = extractTokenUsage(parsed) || extractTokenUsage(parsed.session) || null;
|
||
const tokens = extracted?.tokens || 0;
|
||
|
||
if (typeof tokens === 'number' && tokens > 0) {
|
||
log('✅ getOpencodeSessionTokenUsage: Successfully extracted tokens from JSON', {
|
||
sessionId,
|
||
tokens,
|
||
command: cmdStr,
|
||
extractionPath: extracted?.source || 'unknown'
|
||
});
|
||
attemptResults.push({ command: cmdStr, success: true, tokens, source: 'json' });
|
||
return tokens;
|
||
} else {
|
||
const reason = typeof tokens !== 'number' ? `tokens is ${typeof tokens}, not number` : 'tokens is 0 or negative';
|
||
log(' ✗ JSON parsed but no valid token count', { args: cmdStr, tokens, reason });
|
||
attemptResults.push({ command: cmdStr, success: false, reason, parsedTokens: tokens, source: 'json' });
|
||
}
|
||
} catch (jsonErr) {
|
||
log(' ✗ JSON parse failed, trying text parse', {
|
||
args: cmdStr,
|
||
error: jsonErr.message,
|
||
stdoutSample: stdout.substring(0, 200)
|
||
});
|
||
|
||
// Try to parse token count from text output
|
||
const tokenMatch = stdout.match(/total[_\s-]?tokens?\s*[:=]?\s*(\d+)/i) ||
|
||
stdout.match(/tokens?\s*[:=]?\s*(\d+)/i) ||
|
||
stdout.match(/token\s*count\s*[:=]?\s*(\d+)/i);
|
||
if (tokenMatch) {
|
||
const tokens = parseInt(tokenMatch[1], 10);
|
||
if (tokens > 0) {
|
||
log('✅ getOpencodeSessionTokenUsage: Successfully extracted tokens from text', {
|
||
sessionId,
|
||
tokens,
|
||
command: cmdStr,
|
||
pattern: tokenMatch[0]
|
||
});
|
||
attemptResults.push({ command: cmdStr, success: true, tokens, source: 'text', pattern: tokenMatch[0] });
|
||
return tokens;
|
||
} else {
|
||
log(' ✗ Text pattern matched but tokens <= 0', { args: cmdStr, tokens, pattern: tokenMatch[0] });
|
||
attemptResults.push({ command: cmdStr, success: false, reason: 'matched text pattern but tokens <= 0', parsedTokens: tokens, source: 'text' });
|
||
}
|
||
} else {
|
||
log(' ✗ No text patterns matched', { args: cmdStr, stdoutSample: stdout.substring(0, 200) });
|
||
attemptResults.push({ command: cmdStr, success: false, reason: 'no text patterns matched', source: 'text' });
|
||
}
|
||
}
|
||
} else {
|
||
const reason = !stdout ? 'no stdout' : 'stdout is empty';
|
||
log(' ✗ No stdout to parse', { args: cmdStr, reason, hasStderr });
|
||
attemptResults.push({ command: cmdStr, success: false, reason, stderr: stderr?.substring(0, 200) });
|
||
}
|
||
} catch (err) {
|
||
const errorDetails = {
|
||
message: err.message,
|
||
stderr: err.stderr?.substring(0, 200),
|
||
stdout: err.stdout?.substring(0, 200),
|
||
code: err.code
|
||
};
|
||
log(' ✗ Command execution failed', { args: cmdStr, error: errorDetails });
|
||
attemptResults.push({ command: cmdStr, success: false, error: errorDetails });
|
||
}
|
||
}
|
||
|
||
log('❌ getOpencodeSessionTokenUsage: All commands failed', {
|
||
sessionId,
|
||
totalAttempts: attemptResults.length,
|
||
attemptResults
|
||
});
|
||
return 0;
|
||
}
|
||
|
||
async function handleGit(req, res, action) {
|
||
// Validate git action
|
||
if (!validateGitAction(action)) {
|
||
return sendJson(res, 400, { error: 'Invalid git action' });
|
||
}
|
||
|
||
try {
|
||
const body = req.method === 'POST' ? await parseJsonBody(req) : {};
|
||
let output = '';
|
||
let _stdout = '';
|
||
let _stderr = '';
|
||
if (action === 'pull') {
|
||
// Use plain git pull (no rebase)
|
||
const result = await runGit([{ cmd: 'git', args: ['pull'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = result.stdout || result.last || '';
|
||
_stdout = result.stdout;
|
||
_stderr = result.stderr;
|
||
} else if (action === 'push') {
|
||
const message = sanitizeGitMessage(body.message) || 'Update from chat UI';
|
||
// Add all changes (use `git add .` rather than `-A`)
|
||
await runGit([{ cmd: 'git', args: ['add', '.'] }]);
|
||
// Check if there are staged changes
|
||
const statusResult = await runCommand('git', ['status', '--porcelain'], { cwd: REPO_ROOT, timeout: 60000 }).catch((e) => ({ stdout: e.stdout || '', stderr: e.stderr || '' }));
|
||
if ((statusResult.stdout || '').trim().length === 0) {
|
||
// Nothing to commit; just push
|
||
const pushResult = await runGit([{ cmd: 'git', args: ['push', 'origin', 'main'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = pushResult.stdout || pushResult.last || '';
|
||
_stdout = pushResult.stdout;
|
||
_stderr = pushResult.stderr;
|
||
} else {
|
||
const commitResult = await runGit([{ cmd: 'git', args: ['commit', '-m', message] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
const pushResult = await runGit([{ cmd: 'git', args: ['push', 'origin', 'main'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = `${commitResult.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stdout = `${commitResult.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stderr = `${commitResult.stderr || ''}\n${pushResult.stderr || ''}`.trim();
|
||
}
|
||
} else if (action === 'sync') {
|
||
const message = sanitizeGitMessage(body.message) || 'Update from chat UI';
|
||
// Sync: pull; add; commit if needed; push
|
||
const resultPull = await runGit([{ cmd: 'git', args: ['pull'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
await runGit([{ cmd: 'git', args: ['add', '.'] }]);
|
||
const statusResult2 = await runCommand('git', ['status', '--porcelain'], { cwd: REPO_ROOT, timeout: 60000 }).catch((e) => ({ stdout: e.stdout || '', stderr: e.stderr || '' }));
|
||
if ((statusResult2.stdout || '').trim().length === 0) {
|
||
const pushResult = await runGit([{ cmd: 'git', args: ['push', 'origin', 'main'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = `${resultPull.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stdout = `${resultPull.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stderr = `${resultPull.stderr || ''}\n${pushResult.stderr || ''}`.trim();
|
||
} else {
|
||
const commitResult = await runGit([{ cmd: 'git', args: ['commit', '-m', message] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
const pushResult = await runGit([{ cmd: 'git', args: ['push', 'origin', 'main'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = `${resultPull.stdout || ''}\n${commitResult.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stdout = `${resultPull.stdout || ''}\n${commitResult.stdout || ''}\n${pushResult.stdout || ''}`.trim();
|
||
_stderr = `${resultPull.stderr || ''}\n${commitResult.stderr || ''}\n${pushResult.stderr || ''}`.trim();
|
||
}
|
||
} else if (action === 'status') {
|
||
const { stdout, stderr } = await runCommand('git', ['status', '--short'], { cwd: REPO_ROOT, timeout: 20000 });
|
||
output = stdout;
|
||
_stdout = stdout;
|
||
_stderr = stderr;
|
||
} else if (action === 'log') {
|
||
const { stdout, stderr } = await runCommand('git', ['log', '--oneline', '-n', '20'], { cwd: REPO_ROOT, timeout: 20000 });
|
||
output = stdout;
|
||
_stdout = stdout;
|
||
_stderr = stderr;
|
||
} else if (action === 'fetch') {
|
||
const result = await runGit([{ cmd: 'git', args: ['fetch', '--all'] }], { credentials: { username: process.env.GITHUB_USERNAME, pat: process.env.GITHUB_PAT } });
|
||
output = result.stdout || result.last || '';
|
||
_stdout = result.stdout;
|
||
_stderr = result.stderr;
|
||
} else {
|
||
return sendJson(res, 400, { error: 'Unknown git action' });
|
||
}
|
||
sendJson(res, 200, { output, stdout: _stdout, stderr: _stderr });
|
||
} catch (error) {
|
||
const payload = { error: error.message || 'Git command failed' };
|
||
if (error.stdout) payload.stdout = error.stdout;
|
||
if (error.stderr) payload.stderr = error.stderr;
|
||
sendJson(res, 500, payload);
|
||
}
|
||
}
|
||
|
||
async function route(req, res) {
|
||
try {
|
||
// Validate and sanitize the URL to handle malformed requests
|
||
const urlString = sanitizeUrl(req.url);
|
||
const url = new URL(urlString, `http://${req.headers.host}`);
|
||
const pathname = url.pathname;
|
||
|
||
// Track visitor
|
||
trackVisit(req, res);
|
||
|
||
// Add rate limit headers to all responses
|
||
const userId = resolveUserId(req, url);
|
||
if (userId) {
|
||
const rateLimit = checkApiRateLimit(userId);
|
||
res.setHeader('X-RateLimit-Limit', rateLimit.limit);
|
||
res.setHeader('X-RateLimit-Remaining', rateLimit.remaining);
|
||
res.setHeader('X-RateLimit-Reset', rateLimit.resetIn);
|
||
|
||
if (rateLimit.limited) {
|
||
return sendRateLimitExceeded(res, rateLimit.resetIn, rateLimit.limit);
|
||
}
|
||
}
|
||
|
||
// Add timing header for bot detection
|
||
res.setHeader('X-Request-Time', Date.now());
|
||
|
||
const affiliateParam = sanitizeAffiliateCode(url.searchParams.get('aff') || url.searchParams.get('affiliate'));
|
||
if (affiliateParam && findAffiliateByCode(affiliateParam)) {
|
||
setAffiliateReferralCookie(res, affiliateParam);
|
||
}
|
||
|
||
await routeInternal(req, res, url, pathname);
|
||
} catch (error) {
|
||
// Log all route errors to ensure they're visible in container logs
|
||
log('Route error', {
|
||
url: req.url,
|
||
method: req.method,
|
||
error: String(error),
|
||
stack: error.stack
|
||
});
|
||
|
||
// If response hasn't been sent yet, send a 500 error
|
||
if (!res.headersSent) {
|
||
try {
|
||
sendJson(res, 500, { error: 'Internal server error' });
|
||
} catch (sendError) {
|
||
// If even sending the error response fails, just end the response
|
||
log('Failed to send error response', { sendError: String(sendError) });
|
||
res.end();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function routeInternal(req, res, url, pathname) {
|
||
if (req.method === 'GET' && pathname === '/api/health') return sendJson(res, 200, { ok: true });
|
||
if (req.method === 'GET' && pathname === '/api/opencode/status') return handleOpencodeStatus(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/memory/stats') return handleMemoryStats(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/memory/cleanup') return handleForceMemoryCleanup(req, res);
|
||
if (req.method === 'GET' && pathname === '/auth/google') return handleGoogleAuthStart(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/auth/google/callback') return handleGoogleAuthCallback(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/auth/github') return handleGithubAuthStart(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/auth/github/callback') return handleGithubAuthCallback(req, res, url);
|
||
if (req.method === 'POST' && pathname === '/api/account/claim') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleAccountClaim(req, res, userId);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/signup') return handleAffiliateSignup(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/login') return handleAffiliateLogin(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/logout') return handleAffiliateLogout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/affiliates/me') return handleAffiliateMe(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/api/affiliates/verify-email') return handleAffiliateVerifyEmailApi(req, res, url);
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/verify-email') return handleAffiliateVerifyEmailApi(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/api/affiliates/transactions') return handleAffiliateTransactions(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/links') return handleAffiliateCreateLink(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/affiliates/withdrawals') return handleAffiliateCreateWithdrawal(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/feature-requests') return handleFeatureRequestsList(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/feature-requests') return handleFeatureRequestCreate(req, res);
|
||
const featureUpvoteMatch = pathname.match(/^\/api\/feature-requests\/([a-f0-9\-]+)\/upvote$/i);
|
||
if (req.method === 'POST' && featureUpvoteMatch) return handleFeatureRequestUpvote(req, res, featureUpvoteMatch[1]);
|
||
// Admin feature request endpoints
|
||
if (req.method === 'GET' && pathname === '/api/admin/feature-requests') return handleAdminFeatureRequestsList(req, res);
|
||
const featureRequestReplyMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)\/reply$/i);
|
||
if (req.method === 'POST' && featureRequestReplyMatch) return handleFeatureRequestReply(req, res, featureRequestReplyMatch[1]);
|
||
const featureRequestStatusMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)\/status$/i);
|
||
if (req.method === 'POST' && featureRequestStatusMatch) return handleFeatureRequestUpdateStatus(req, res, featureRequestStatusMatch[1]);
|
||
const featureRequestDeleteMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)$/i);
|
||
if (req.method === 'DELETE' && featureRequestDeleteMatch) return handleFeatureRequestDelete(req, res, featureRequestDeleteMatch[1]);
|
||
if (req.method === 'POST' && pathname === '/api/contact') return handleContactMessageCreate(req, res);
|
||
const contactMessagesMatch = pathname.match(/^\/api\/contact\/messages$/i);
|
||
if (req.method === 'GET' && contactMessagesMatch) return handleContactMessagesList(req, res);
|
||
const contactMessageReadMatch = pathname.match(/^\/api\/contact\/messages\/([a-f0-9\-]+)\/read$/i);
|
||
if (req.method === 'POST' && contactMessageReadMatch) return handleContactMessageMarkRead(req, res, contactMessageReadMatch[1]);
|
||
const contactMessageDeleteMatch = pathname.match(/^\/api\/contact\/messages\/([a-f0-9\-]+)$/i);
|
||
if (req.method === 'DELETE' && contactMessageDeleteMatch) return handleContactMessageDelete(req, res, contactMessageDeleteMatch[1]);
|
||
|
||
// Blog API Routes
|
||
if (req.method === 'GET' && pathname === '/api/blogs') return handleBlogsList(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/api/blogs/categories') return handleBlogCategoriesList(req, res);
|
||
const blogDetailMatch = pathname.match(/^\/api\/blogs\/([^\/]+)$/i);
|
||
if (req.method === 'GET' && blogDetailMatch) return handleBlogGet(req, res, blogDetailMatch[1]);
|
||
if (req.method === 'GET' && pathname === '/api/admin/blogs') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogsList(req, res, url);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/admin/blogs') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogCreate(req, res);
|
||
}
|
||
const adminBlogUpdateMatch = pathname.match(/^\/api\/admin\/blogs\/([^\/]+)$/i);
|
||
if (req.method === 'PUT' && adminBlogUpdateMatch) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogUpdate(req, res, adminBlogUpdateMatch[1]);
|
||
}
|
||
const adminBlogDeleteMatch = pathname.match(/^\/api\/admin\/blogs\/([^\/]+)$/i);
|
||
if (req.method === 'DELETE' && adminBlogDeleteMatch) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogDelete(req, res, adminBlogDeleteMatch[1]);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/admin/blogs/categories') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogCategoryCreate(req, res);
|
||
}
|
||
const adminBlogCategoryUpdateMatch = pathname.match(/^\/api\/admin\/blogs\/categories\/([^\/]+)$/i);
|
||
if (req.method === 'PUT' && adminBlogCategoryUpdateMatch) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogCategoryUpdate(req, res, adminBlogCategoryUpdateMatch[1]);
|
||
}
|
||
const adminBlogCategoryDeleteMatch = pathname.match(/^\/api\/admin\/blogs\/categories\/([^\/]+)$/i);
|
||
if (req.method === 'DELETE' && adminBlogCategoryDeleteMatch) {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogCategoryDelete(req, res, adminBlogCategoryDeleteMatch[1]);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/admin/blogs/upload-image') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminBlogImageUpload(req, res);
|
||
}
|
||
|
||
if (req.method === 'POST' && pathname === '/api/login') return handleUserLogin(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/register') return handleUserRegister(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/logout') return handleUserLogout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/me') return handleUserMe(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/me') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminMe(req, res);
|
||
}
|
||
if (req.method === 'GET' && pathname === '/api/csrf') return handleCsrfToken(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/account') return handleAccountSettingsGet(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/api/account/usage') return handleAccountUsage(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/provider-limits') return handleProviderLimitsGet(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/account/plans') return handleAccountPlans(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/invoices') return handleInvoicesList(req, res);
|
||
const invoiceDownloadMatch = pathname.match(/^\/api\/invoices\/([a-f0-9\-]+)\/download$/i);
|
||
if (req.method === 'GET' && invoiceDownloadMatch) return handleInvoiceDownload(req, res, url, invoiceDownloadMatch[1]);
|
||
if (req.method === 'GET' && pathname === '/api/topups/options') {
|
||
const session = requireUserAuth(req, res);
|
||
if (!session) return;
|
||
return handleTopupOptions(req, res, session.userId);
|
||
}
|
||
// Admin-only test endpoints for Dodo top-ups
|
||
if (req.method === 'GET' && pathname === '/api/admin/topups/options') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminTopupOptions(req, res);
|
||
}
|
||
if (req.method === 'GET' && pathname === '/api/admin/topups/confirm') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminTopupConfirm(req, res, url);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/admin/topups/checkout') {
|
||
const session = requireAdminAuth(req, res);
|
||
if (!session) return;
|
||
return handleAdminTopupCheckout(req, res);
|
||
}
|
||
|
||
if (req.method === 'GET' && pathname === '/api/topups/confirm') return handleTopupConfirm(req, res, url);
|
||
if (req.method === 'POST' && pathname === '/api/topups/checkout') return handleTopupCheckout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/payg/status') return handlePaygStatus(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/payg/checkout') return handlePaygCheckout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/payg/confirm') return handlePaygConfirm(req, res, url);
|
||
if (req.method === 'POST' && pathname === '/api/account/boost') return handleAccountBoostPurchase(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/test/simulate-tokens') return handleSimulateTokenUsage(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/onboarding') return handleOnboardingGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/onboarding') return handleOnboardingPost(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/account') return handleAccountSettingsUpdate(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/account/payment-methods') return handlePaymentMethodsList(req, res, url);
|
||
const paymentMethodCreateMatch = pathname.match(/^\/api\/account\/payment-methods\/create$/i);
|
||
if (req.method === 'POST' && paymentMethodCreateMatch) return handlePaymentMethodCreate(req, res);
|
||
const paymentMethodSetDefaultMatch = pathname.match(/^\/api\/account\/payment-methods\/([^\/]+)\/default$/i);
|
||
if (req.method === 'POST' && paymentMethodSetDefaultMatch) return handlePaymentMethodSetDefault(req, res, paymentMethodSetDefaultMatch[1]);
|
||
const paymentMethodDeleteMatch = pathname.match(/^\/api\/account\/payment-methods\/([^\/]+)$/i);
|
||
if (req.method === 'DELETE' && paymentMethodDeleteMatch) return handlePaymentMethodDelete(req, res, paymentMethodDeleteMatch[1]);
|
||
if (req.method === 'POST' && pathname === '/api/account/balance/add') return handleAccountBalanceAdd(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/select-plan') return handleSelectPlan(req, res);
|
||
|
||
// Subscription API endpoints
|
||
if (req.method === 'POST' && pathname === '/api/subscription/checkout') return handleSubscriptionCheckout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/subscription/confirm') return handleSubscriptionConfirm(req, res, url);
|
||
if (req.method === 'GET' && pathname === '/api/subscription/status') return handleSubscriptionStatus(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/subscription/cancel') return handleSubscriptionCancel(req, res);
|
||
|
||
// Dodo webhooks
|
||
if (req.method === 'POST' && pathname === '/webhooks/dodo') return handleDodoWebhook(req, res);
|
||
|
||
if (req.method === 'GET' && pathname === '/api/verify-email') return handleVerifyEmailApi(req, res, url);
|
||
if (req.method === 'POST' && pathname === '/api/password/forgot') return handlePasswordResetRequest(req, res);
|
||
|
||
// Dev helper: preview branded email templates without sending
|
||
if (req.method === 'GET' && pathname === '/debug/email/preview') {
|
||
const type = url.searchParams.get('type') || 'verification';
|
||
const email = url.searchParams.get('email') || 'user@example.com';
|
||
const token = url.searchParams.get('token') || 'sample-token';
|
||
if (type === 'verification') {
|
||
const link = `${resolveBaseUrl(req)}/verify-email?token=${encodeURIComponent(token)}`;
|
||
const bodyHtml = `<p style="margin-top:0;">Welcome!</p><p>Please verify your email address by clicking the button below — or copy and paste the link into your browser:</p><p style="word-break:break-all;"><a href="${escapeHtml(link)}">${escapeHtml(link)}</a></p>`;
|
||
const html = renderBrandedEmail({ title: 'Verify your email address', preheader: 'Confirm your email to get started', bodyHtml, buttonText: 'Verify email', buttonLink: link });
|
||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
return res.end(html);
|
||
}
|
||
if (type === 'reset') {
|
||
const link = `${resolveBaseUrl(req)}/reset-password?token=${encodeURIComponent(token)}`;
|
||
const bodyHtml = `<p style="margin-top:0;">You requested a password reset.</p><p>Reset your password by clicking the button below — or copy and paste the link into your browser:</p><p style="word-break:break-all;"><a href="${escapeHtml(link)}">${escapeHtml(link)}</a></p>`;
|
||
const html = renderBrandedEmail({ title: 'Reset your password', preheader: 'Reset access to your Plugin Compass account', bodyHtml, buttonText: 'Reset password', buttonLink: link });
|
||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
return res.end(html);
|
||
}
|
||
return sendJson(res, 400, { error: 'type must be verification or reset' });
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/password/reset') return handlePasswordReset(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/apps/upload') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleUploadApp(req, res, userId);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/admin/login') return handleAdminLogin(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/logout') return handleAdminLogout(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/me') return handleAdminMe(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/available-models') {
|
||
const cliParam = url.searchParams.get('cli');
|
||
return handleAdminAvailableModels(req, res, cliParam);
|
||
}
|
||
if (req.method === 'GET' && pathname === '/api/admin/icons') return handleAdminListIcons(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/models') return handleAdminModelsList(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/models') return handleAdminModelUpsert(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsPost(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsPost(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/plan-settings') return handleAdminPlanSettingsGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/plan-settings') return handleAdminPlanSettingsPost(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/plan-tokens') return handleAdminPlanTokensGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/plan-tokens') return handleAdminPlanTokensPost(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/token-rates') return handleAdminTokenRatesGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/token-rates') return handleAdminTokenRatesPost(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/provider-limits') return handleAdminProviderLimitsGet(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/provider-limits') return handleAdminProviderLimitsPost(req, res);
|
||
// Admin-only env debug endpoint
|
||
if (req.method === 'GET' && pathname === '/api/admin/env-config') return handleAdminEnvConfig(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/accounts') return handleAdminAccountsList(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/accounts/plan') return handleAdminAccountPlanUpdate(req, res);
|
||
if (req.method === 'DELETE' && pathname === '/api/admin/accounts') return handleAdminAccountDelete(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/affiliates') return handleAdminAffiliatesList(req, res);
|
||
if (req.method === 'DELETE' && pathname === '/api/admin/affiliates') return handleAdminAffiliateDelete(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalsList(req, res);
|
||
if (req.method === 'PUT' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalUpdate(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/tracking') return handleAdminTrackingStats(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/resources') return handleAdminResources(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/admin/external-testing-status') return handleAdminExternalTestingStatus(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/external-testing-self-test') return handleAdminExternalTestingSelfTest(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/upgrade-popup-tracking') return handleUpgradePopupTracking(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/cancel-messages') return handleAdminCancelMessages(req, res);
|
||
if (req.method === 'POST' && pathname === '/api/admin/ollama-test') return handleAdminOllamaTest(req, res);
|
||
const adminDeleteMatch = pathname.match(/^\/api\/admin\/models\/([a-f0-9\-]+)$/i);
|
||
if (req.method === 'DELETE' && adminDeleteMatch) return handleAdminModelDelete(req, res, adminDeleteMatch[1]);
|
||
if (req.method === 'GET' && pathname === '/api/models') {
|
||
const cliParam = url.searchParams.get('cli');
|
||
return handleModels(req, res, cliParam);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/plan') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handlePlanMessage(req, res, userId);
|
||
}
|
||
if (req.method === 'GET' && pathname === '/api/sessions') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleListSessions(req, res, userId);
|
||
}
|
||
if (req.method === 'POST' && pathname === '/api/sessions') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleNewSession(req, res, userId);
|
||
}
|
||
const sessionMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)$/i);
|
||
if (req.method === 'DELETE' && sessionMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleDeleteSession(req, res, sessionMatch[1], userId);
|
||
}
|
||
if (req.method === 'PATCH' && sessionMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleUpdateSession(req, res, sessionMatch[1], userId);
|
||
}
|
||
if (req.method === 'GET' && sessionMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleGetSession(req, res, sessionMatch[1], userId);
|
||
}
|
||
|
||
// GET todos for a session
|
||
const todosMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/todos$/i);
|
||
if (req.method === 'GET' && todosMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleGetSessionTodos(req, res, todosMatch[1], userId);
|
||
}
|
||
|
||
const messageMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages$/i);
|
||
if (req.method === 'POST' && messageMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleNewMessage(req, res, messageMatch[1], userId);
|
||
}
|
||
const streamMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages\/([a-f0-9\-]+)\/stream$/i);
|
||
if (req.method === 'GET' && streamMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleMessageStream(req, res, streamMatch[1], streamMatch[2], userId);
|
||
}
|
||
const statusMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages\/([a-f0-9\-]+)\/status$/i);
|
||
if (req.method === 'GET' && statusMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleRunningStatus(req, res, statusMatch[1], statusMatch[2], userId);
|
||
}
|
||
// Undo route for opencode messages
|
||
const undoMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages\/([a-f0-9\-]+)\/undo$/i);
|
||
if (req.method === 'POST' && undoMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleUndoMessage(req, res, undoMatch[1], undoMatch[2], userId);
|
||
}
|
||
// Redo route for opencode messages
|
||
const redoMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages\/([a-f0-9\-]+)\/redo$/i);
|
||
if (req.method === 'POST' && redoMatch) {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
return handleRedoMessage(req, res, redoMatch[1], redoMatch[2], userId);
|
||
}
|
||
const gitMatch = pathname.match(/^\/api\/git\/([a-z]+)$/i);
|
||
if (gitMatch) return handleGit(req, res, gitMatch[1]);
|
||
if (req.method === 'GET' && pathname === '/api/templates') return handleListTemplates(req, res);
|
||
|
||
if (req.method === 'GET' && pathname === '/api/diagnostics') return handleDiagnostics(req, res);
|
||
if (req.method === 'GET' && pathname === '/api/posthog-config.js') {
|
||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||
res.end(`window.posthogConfig = ${JSON.stringify({ apiKey: POSTHOG_API_KEY, apiHost: POSTHOG_API_HOST })};`);
|
||
return;
|
||
}
|
||
if (req.method === 'GET' && pathname === '/api/export/zip') {
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
const sessionId = url.searchParams.get('sessionId');
|
||
if (!sessionId) return sendJson(res, 400, { error: 'sessionId is required for export' });
|
||
return handleExportZip(req, res, sessionId, userId);
|
||
}
|
||
|
||
// Serve apps list UI - check if user has selected a plan
|
||
if (pathname === '/apps' || pathname === '/apps/') {
|
||
const session = getUserSession(req);
|
||
if (session) {
|
||
const user = findUserById(session.userId);
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
if (!hasPlan) {
|
||
res.writeHead(302, { Location: '/select-plan' });
|
||
res.end();
|
||
return;
|
||
}
|
||
}
|
||
return serveFile(res, safeStaticPath('apps.html'), 'text/html');
|
||
}
|
||
|
||
// Serve builder UI
|
||
if (pathname === '/builder' || pathname === '/builder/') return serveFile(res, safeStaticPath('builder.html'), 'text/html');
|
||
if (pathname === '/chat' || pathname === '/chat/') return serveFile(res, safeStaticPath('builder.html'), 'text/html');
|
||
if (pathname.startsWith('/chat/')) {
|
||
try {
|
||
const filePath = safeStaticPath(pathname.replace('/chat/', ''));
|
||
return serveFile(res, filePath, guessContentType(filePath));
|
||
} catch (_) { }
|
||
}
|
||
if (pathname.startsWith('/uploads/')) {
|
||
try {
|
||
const uploadMatch = pathname.match(/^\/uploads\/([a-f0-9\-]+)\/([a-f0-9\-]+)\/(.+)$/i);
|
||
if (!uploadMatch) throw new Error('Invalid upload path');
|
||
const userId = requireUserId(req, res, url);
|
||
if (!userId) return;
|
||
const sessionId = uploadMatch[1];
|
||
const attachmentKey = uploadMatch[2];
|
||
const requestedName = uploadMatch[3].replace(/\.\.+/g, '');
|
||
const session = getSession(sessionId, userId);
|
||
if (!session) return sendJson(res, 404, { error: 'File not found' });
|
||
await ensureSessionPaths(session);
|
||
if (session.attachmentKey && session.attachmentKey !== attachmentKey) return sendJson(res, 403, { error: 'Invalid attachment token' });
|
||
const filePath = path.join(session.uploadsDir, requestedName);
|
||
const resolvedPath = path.resolve(filePath);
|
||
const uploadsRoot = path.resolve(session.uploadsDir);
|
||
if (!resolvedPath.startsWith(uploadsRoot)) throw new Error('Invalid path');
|
||
const stat = await fs.stat(resolvedPath);
|
||
if (!stat.isFile()) throw new Error('Not a file');
|
||
const content = await fs.readFile(resolvedPath);
|
||
const contentType = guessContentTypeFromExt(path.extname(resolvedPath));
|
||
res.writeHead(200, { 'Content-Type': contentType });
|
||
res.end(content);
|
||
return;
|
||
} catch (_) { }
|
||
}
|
||
if (pathname === '/admin/login') return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
if (pathname === '/admin' || pathname === '/admin/') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/build') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/plan') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-plan.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/plans') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-plans.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/accounts') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-accounts.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/affiliates') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-affiliates.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/withdrawals') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-withdrawals.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/tracking') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-tracking.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/resources') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-resources.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/external-testing') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-external-testing.html'), 'text/html');
|
||
}
|
||
if (pathname === '/admin/contact-messages') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-contact-messages.html'), 'text/html');
|
||
}
|
||
// Homepage serves landing page, /index.html serves homepage
|
||
if (pathname === '/' || pathname === '/index.html') return serveFile(res, safeStaticPath('home.html'), 'text/html');
|
||
if (pathname === '/login') {
|
||
const session = getUserSession(req);
|
||
if (session) {
|
||
const user = findUserById(session.userId);
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
if (hasPlan) {
|
||
res.writeHead(302, { Location: '/apps' });
|
||
res.end();
|
||
return;
|
||
}
|
||
}
|
||
return serveFile(res, safeStaticPath('login.html'), 'text/html');
|
||
}
|
||
if (pathname === '/signup') {
|
||
const session = getUserSession(req);
|
||
if (session) {
|
||
const user = findUserById(session.userId);
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
if (hasPlan) {
|
||
res.writeHead(302, { Location: '/apps' });
|
||
res.end();
|
||
return;
|
||
}
|
||
}
|
||
return serveFile(res, safeStaticPath('signup.html'), 'text/html');
|
||
}
|
||
if (pathname === '/signup-success') return serveFile(res, safeStaticPath('signup-success.html'), 'text/html');
|
||
if (pathname === '/select-plan') {
|
||
const session = getUserSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('login.html'), 'text/html');
|
||
const user = findUserById(session.userId);
|
||
const hasPlan = normalizePlanSelection(user?.plan);
|
||
if (hasPlan) {
|
||
res.writeHead(302, { Location: '/apps' });
|
||
res.end();
|
||
return;
|
||
}
|
||
return serveFile(res, safeStaticPath('select-plan.html'), 'text/html');
|
||
}
|
||
if (pathname === '/verify-email') return serveFile(res, safeStaticPath('verify-email.html'), 'text/html');
|
||
if (pathname === '/reset-password') return serveFile(res, safeStaticPath('reset-password.html'), 'text/html');
|
||
if (pathname === '/topup' || pathname === '/topup/') return serveFile(res, safeStaticPath('topup.html'), 'text/html');
|
||
if (pathname === '/test-checkout' || pathname === '/test-checkout/') {
|
||
const adminSession = getAdminSession(req);
|
||
if (!adminSession) {
|
||
res.writeHead(302, { Location: '/admin/login?next=' + encodeURIComponent('/test-checkout') });
|
||
res.end();
|
||
return;
|
||
}
|
||
return serveFile(res, safeStaticPath('test-checkout.html'), 'text/html');
|
||
}
|
||
if (pathname === '/upgrade' || pathname === '/upgrade/') return serveFile(res, safeStaticPath('upgrade.html'), 'text/html');
|
||
if (pathname === '/pricing') return serveFile(res, safeStaticPath('pricing.html'), 'text/html');
|
||
if (pathname === '/credits') return serveFile(res, safeStaticPath('credits.html'), 'text/html');
|
||
if (pathname === '/features') return serveFile(res, safeStaticPath('features.html'), 'text/html');
|
||
if (pathname === '/subscription-success') return serveFile(res, safeStaticPath('subscription-success.html'), 'text/html');
|
||
if (pathname === '/affiliate' || pathname === '/affiliates') return serveFile(res, safeStaticPath('affiliate.html'), 'text/html');
|
||
if (pathname === '/affiliate-login') return serveFile(res, safeStaticPath('affiliate-login.html'), 'text/html');
|
||
if (pathname === '/affiliate-signup') return serveFile(res, safeStaticPath('affiliate-signup.html'), 'text/html');
|
||
if (pathname === '/affiliate-dashboard') return serveFile(res, safeStaticPath('affiliate-dashboard.html'), 'text/html');
|
||
if (pathname === '/affiliate-withdrawal') return serveFile(res, safeStaticPath('affiliate-withdrawal.html'), 'text/html');
|
||
if (pathname === '/affiliate-transactions') return serveFile(res, safeStaticPath('affiliate-transactions.html'), 'text/html');
|
||
if (pathname === '/docs' || pathname === '/documentation') return serveFile(res, safeStaticPath('docs.html'), 'text/html');
|
||
if (pathname === '/faq' || pathname === '/faqs') return serveFile(res, safeStaticPath('faq.html'), 'text/html');
|
||
if (pathname === '/settings') return serveFile(res, safeStaticPath('settings.html'), 'text/html');
|
||
if (pathname === '/feature-requests') return serveFile(res, safeStaticPath('feature-requests.html'), 'text/html');
|
||
// Blog pages
|
||
if (pathname === '/news' || pathname === '/news/') return serveFile(res, safeStaticPath('news.html'), 'text/html');
|
||
const blogPostMatch = pathname.match(/^\/blog\/(.+)$/i);
|
||
if (blogPostMatch) return serveFile(res, safeStaticPath('blog.html'), 'text/html');
|
||
if (pathname === '/admin/blogs') {
|
||
const session = getAdminSession(req);
|
||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||
return serveFile(res, safeStaticPath('admin-blogs.html'), 'text/html');
|
||
}
|
||
// Serve legal pages with proper caching headers
|
||
if (pathname === '/terms') return serveFile(res, safeStaticPath('terms.html'), 'text/html');
|
||
if (pathname === '/privacy') return serveFile(res, safeStaticPath('privacy.html'), 'text/html');
|
||
// Redirect legacy /contact.html to canonical /contact
|
||
if (pathname === '/contact.html' || pathname === '/contact.html/') {
|
||
res.writeHead(301, { Location: '/contact' });
|
||
res.end();
|
||
return;
|
||
}
|
||
// Contact page (serve contact.html at /contact)
|
||
if (pathname === '/contact' || pathname === '/contact/') return serveFile(res, safeStaticPath('contact.html'), 'text/html');
|
||
// Serve sitemap.xml with proper caching headers for SEO
|
||
if (pathname === '/sitemap.xml') {
|
||
try {
|
||
const content = await fs.readFile(safeStaticPath('sitemap.xml'));
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/xml',
|
||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||
});
|
||
res.end(content);
|
||
return;
|
||
} catch (_) { }
|
||
}
|
||
// Serve robots.txt with proper caching headers for SEO
|
||
if (pathname === '/robots.txt') {
|
||
try {
|
||
const content = await fs.readFile(safeStaticPath('robots.txt'));
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/plain',
|
||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||
});
|
||
res.end(content);
|
||
return;
|
||
} catch (_) { }
|
||
}
|
||
try {
|
||
const staticFile = safeStaticPath(pathname.replace(/^\//, ''));
|
||
const stat = await fs.stat(staticFile);
|
||
if (stat.isFile()) return serveFile(res, staticFile, guessContentType(staticFile));
|
||
} catch (_) { }
|
||
try {
|
||
const content = await fs.readFile(safeStaticPath('404.html'));
|
||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||
res.end(content);
|
||
} catch (_) {
|
||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||
res.end('<h1>404 Not Found</h1><p>The page you are looking for does not exist.</p>');
|
||
}
|
||
}
|
||
|
||
async function bootstrap() {
|
||
process.on('uncaughtException', async (error) => {
|
||
log('Uncaught Exception, saving state before exit', { error: String(error), stack: error.stack });
|
||
try {
|
||
await persistAllState();
|
||
} catch (saveError) {
|
||
log('Failed to save state during uncaughtException', { error: String(saveError) });
|
||
}
|
||
process.exit(1);
|
||
});
|
||
|
||
process.on('unhandledRejection', (reason, promise) => {
|
||
log('Unhandled Rejection', { reason: String(reason), promise: String(promise) });
|
||
});
|
||
|
||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||
|
||
// Load admin models FIRST (before sessions) so ensureOpencodeConfig can use them
|
||
await loadAdminModelStore();
|
||
await loadOpenRouterSettings();
|
||
await loadMistralSettings();
|
||
await loadPlanSettings();
|
||
await loadPlanTokenLimits();
|
||
await loadProviderLimits();
|
||
await loadProviderUsage();
|
||
await loadTokenUsage();
|
||
await loadExternalTestUsage();
|
||
await loadTopupSessions();
|
||
await loadPendingTopups();
|
||
await loadPaygSessions();
|
||
await loadState(); // Load sessions LAST so adminModels is ready
|
||
await loadPendingPayg();
|
||
await loadSubscriptionSessions();
|
||
await loadPendingSubscriptions();
|
||
await loadInvoicesDb();
|
||
await loadUsersDb(); // Load user authentication database
|
||
await loadUserSessions(); // Load user sessions
|
||
await loadAffiliatesDb();
|
||
await loadWithdrawalsDb();
|
||
await loadTrackingData();
|
||
await loadFeatureRequestsDb();
|
||
contactMessagesDb = await loadContactMessagesDb();
|
||
await ensureAssetsDir();
|
||
|
||
// Clean up orphaned workspace directories from deleted sessions
|
||
await cleanupOrphanedWorkspaces();
|
||
|
||
// Initialize admin password hashing
|
||
if (ADMIN_USER && ADMIN_PASSWORD) {
|
||
try {
|
||
adminPasswordHash = await bcrypt.hash(ADMIN_PASSWORD, PASSWORD_SALT_ROUNDS);
|
||
log('admin password hashed successfully');
|
||
} catch (error) {
|
||
log('failed to hash admin password', { error: String(error) });
|
||
}
|
||
}
|
||
|
||
log('Resource limits detected', {
|
||
memoryBytes: RESOURCE_LIMITS.memoryBytes,
|
||
memoryMb: Math.round((RESOURCE_LIMITS.memoryBytes || 0) / (1024 * 1024)),
|
||
cpuCores: RESOURCE_LIMITS.cpuCores
|
||
});
|
||
|
||
// Log provider configuration
|
||
console.log('=== PROVIDER CONFIGURATION ===');
|
||
console.log('[CONFIG] OpenRouter:', {
|
||
configured: !!OPENROUTER_API_KEY,
|
||
apiUrl: OPENROUTER_API_URL,
|
||
primaryModel: openrouterSettings.primaryModel || 'not set',
|
||
hasApiKey: !!OPENROUTER_API_KEY
|
||
});
|
||
console.log('[CONFIG] Mistral:', {
|
||
configured: !!MISTRAL_API_KEY,
|
||
apiUrl: MISTRAL_API_URL,
|
||
primaryModel: mistralSettings.primaryModel || 'not set',
|
||
backupModel1: mistralSettings.backupModel1 || 'not set',
|
||
backupModel2: mistralSettings.backupModel2 || 'not set',
|
||
backupModel3: mistralSettings.backupModel3 || 'not set',
|
||
hasApiKey: !!MISTRAL_API_KEY,
|
||
apiKeyPrefix: MISTRAL_API_KEY ? MISTRAL_API_KEY.substring(0, 8) + '...' : 'none',
|
||
defaultModel: MISTRAL_DEFAULT_MODEL
|
||
});
|
||
console.log('[CONFIG] Groq:', {
|
||
configured: !!GROQ_API_KEY,
|
||
apiUrl: GROQ_API_URL,
|
||
hasApiKey: !!GROQ_API_KEY,
|
||
apiKeyPrefix: GROQ_API_KEY ? GROQ_API_KEY.substring(0, 8) + '...' : 'none'
|
||
});
|
||
console.log('[CONFIG] Planning Settings:', {
|
||
provider: planSettings.provider,
|
||
freePlanModel: planSettings.freePlanModel || 'not set',
|
||
planningChainLength: planSettings.planningChain?.length || 0,
|
||
planningChain: planSettings.planningChain
|
||
});
|
||
|
||
// Log email/SMTP configuration
|
||
const smtpConfig = summarizeMailConfig();
|
||
console.log('[CONFIG] Email / SMTP:');
|
||
console.log(' - SMTP Configured:', smtpConfig.hostConfigured ? 'YES ✓' : 'NO ✗');
|
||
console.log(' - SMTP Host:', smtpConfig.hostConfigured ? SMTP_HOST : 'not configured');
|
||
console.log(' - SMTP Port:', smtpConfig.portConfigured ? SMTP_PORT : 'not configured');
|
||
console.log(' - SMTP Secure:', smtpConfig.secure ? 'YES (TLS/SSL)' : 'NO (STARTTLS)');
|
||
console.log(' - From Address:', smtpConfig.fromConfigured ? SMTP_FROM : 'not configured');
|
||
console.log('');
|
||
if (!smtpConfig.hostConfigured) {
|
||
console.log(' ⚠️ WARNING: Email is NOT configured. Password reset and verification');
|
||
console.log(' emails will be logged to console only. To enable real emails:');
|
||
console.log(' 1. Edit the .env file');
|
||
console.log(' 2. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and SMTP_FROM');
|
||
console.log(' 3. Restart the server');
|
||
console.log(' 💡 Tip: Use /debug/email/preview?type=reset to preview the email template');
|
||
} else {
|
||
console.log(' ✓ Email/SMTP is configured and ready to send emails');
|
||
}
|
||
console.log('');
|
||
console.log('==============================');
|
||
|
||
log('Environment PATH', { PATH: process.env.PATH });
|
||
try {
|
||
const versionCheck = await runCommand('opencode', ['--version'], { timeout: 5000 }).catch((e) => ({ stdout: e.stdout, stderr: e.stderr, code: e.code }));
|
||
log('opencode version', { version: versionCheck.stdout || versionCheck.stderr });
|
||
} catch (_) {
|
||
log('opencode version check failed');
|
||
}
|
||
|
||
// Initialize OpenCode process manager
|
||
log('Initializing OpenCode process manager...');
|
||
try {
|
||
await opencodeManager.start();
|
||
log('OpenCode process manager initialized', opencodeManager.getStats());
|
||
} catch (err) {
|
||
log('OpenCode process manager initialization failed (will fall back to per-message spawning)', { error: String(err) });
|
||
}
|
||
|
||
// Initialize blog system
|
||
log('Initializing blog system...');
|
||
try {
|
||
await blogSystem.initBlogStorage();
|
||
log('Blog system initialized successfully');
|
||
} catch (err) {
|
||
log('Blog system initialization failed', { error: String(err) });
|
||
}
|
||
|
||
// Restore interrupted sessions after restart
|
||
await restoreInterruptedSessions();
|
||
|
||
startAutoSave();
|
||
|
||
// Start memory cleanup scheduler
|
||
startMemoryCleanup();
|
||
|
||
// Start periodic resource monitoring for analytics
|
||
startPeriodicMonitoring();
|
||
|
||
server = http.createServer((req, res) => { route(req, res); });
|
||
|
||
// Disable Node's built-in request timeouts so long-running SSE streams / long LLM "thinking" phases
|
||
// don't get cut off (Node defaults can be ~5 minutes depending on version).
|
||
server.requestTimeout = 0;
|
||
server.headersTimeout = 0;
|
||
server.timeout = 0;
|
||
|
||
server.listen(PORT, HOST, () => { log(`${OPENROUTER_APP_NAME} listening on http://${HOST}:${PORT}`); });
|
||
}
|
||
|
||
function startPeriodicMonitoring() {
|
||
// Track system health metrics every 5 minutes
|
||
setInterval(() => {
|
||
try {
|
||
trackResourceUtilization();
|
||
|
||
// Update system health
|
||
trackingData.technicalMetrics.systemHealth.uptime = process.uptime();
|
||
|
||
// Calculate queue wait times (simplified)
|
||
const activeSessions = state.sessions.filter(s => s.pending > 0).length;
|
||
if (activeSessions > 0) {
|
||
const estimatedWaitTime = activeSessions * 1000; // 1 second per active session
|
||
trackQueueMetrics(estimatedWaitTime, activeSessions);
|
||
}
|
||
|
||
} catch (error) {
|
||
log('Periodic monitoring error', { error: String(error) });
|
||
}
|
||
}, 5 * 60 * 1000); // 5 minutes
|
||
|
||
// Clean up old data daily
|
||
setInterval(() => {
|
||
try {
|
||
// Clean up old resource utilization data (keep last 7 days)
|
||
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
||
const recentResourceData = {};
|
||
for (const [timestamp, data] of Object.entries(trackingData.userAnalytics.resourceUtilization)) {
|
||
if (parseInt(timestamp) > sevenDaysAgo) {
|
||
recentResourceData[timestamp] = data;
|
||
}
|
||
}
|
||
trackingData.userAnalytics.resourceUtilization = recentResourceData;
|
||
|
||
// Clean up old queue metrics (keep last 3 days)
|
||
const threeDaysAgo = Date.now() - (3 * 24 * 60 * 60 * 1000);
|
||
const recentQueueData = {};
|
||
for (const [timestamp, data] of Object.entries(trackingData.userAnalytics.queueMetrics)) {
|
||
if (parseInt(timestamp) > threeDaysAgo) {
|
||
recentQueueData[timestamp] = data;
|
||
}
|
||
}
|
||
trackingData.userAnalytics.queueMetrics = recentQueueData;
|
||
|
||
// Clean up old active user data (keep last 30 days)
|
||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||
const recentDAU = {};
|
||
for (const [date, users] of Object.entries(trackingData.userAnalytics.dailyActiveUsers)) {
|
||
const dateTime = new Date(date).getTime();
|
||
if (dateTime > thirtyDaysAgo) {
|
||
recentDAU[date] = users;
|
||
}
|
||
}
|
||
trackingData.userAnalytics.dailyActiveUsers = recentDAU;
|
||
|
||
scheduleTrackingPersist();
|
||
|
||
} catch (error) {
|
||
log('Data cleanup error', { error: String(error) });
|
||
}
|
||
}, 24 * 60 * 60 * 1000); // 24 hours
|
||
}
|
||
|
||
|
||
async function handleListTemplates(req, res) {
|
||
try {
|
||
const templatesPath = path.join(__dirname, 'templates', 'templates.json');
|
||
const content = await fs.readFile(templatesPath, 'utf-8');
|
||
const templates = JSON.parse(content);
|
||
sendJson(res, 200, { templates });
|
||
} catch (error) {
|
||
if (error.code === 'ENOENT') {
|
||
return sendJson(res, 200, { templates: [] });
|
||
}
|
||
log('Failed to list templates', { error: String(error) });
|
||
sendJson(res, 500, { error: 'Failed to list templates' });
|
||
}
|
||
}
|
||
|
||
bootstrap().catch((error) => { log(`Failed to start ${OPENROUTER_APP_NAME} service`, { error: String(error) }); process.exit(1); });
|