// 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'); 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 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 || ''; 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 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 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 DODO_PRODUCTS_CACHE_TTL_MS = Math.max(30_000, Number(process.env.DODO_PRODUCTS_CACHE_TTL_MS || 5 * MINUTE_MS)); const DAY_MS = 86_400_000; 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: 10_000_000, enterprise: 50_000_000, }; // 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', DEFAULT_PROVIDER_FALLBACK]; 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(' 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(); 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']; 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 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, '/'); } // 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 function sanitizePromptInput(input) { if (!input || typeof input !== 'string') return ''; const patterns = [ /ignore\s+previous\s+instructions/gi, /system\s*:/gi, /assistant\s*:/gi, /role\s*=\s*["']?system["']?/gi, /{{[^}]*}}/g, /```\s*ignore/gi, /\0/g, /eval\s*\(/gi, /exec\s*\(/gi, /process\./gi, ]; let result = input; for (const pattern of patterns) { result = result.replace(pattern, '[FILTERED]'); } return result.slice(0, MAX_PROMPT_LENGTH).trim(); } // 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 ? ` Plugin Compass

${escapeHtml(heroTitle || safeTitle)}

${heroSubtitle ? `

${escapeHtml(heroSubtitle)}

` : ''} ` : ` Plugin Compass
${safeTitle}
`; const html = ` ${safeTitle} ${safePre}
${heroSection} ${buttonText ? ` ` : ''}
${bodyHtml}
${escapeHtml(buttonText)}
`; 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 = `

Welcome to Plugin Compass!

Thanks for signing up! Please verify your email address to get started with AI-powered development.

Verify Email Address

Or copy and paste this link into your browser:

${safeLink}

This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.
If you didn't sign up for Plugin Compass, you can safely ignore this email.

`; 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 = `

Welcome to the Affiliate Program!

Thanks for joining our Affiliate Program! Please verify your email address to start earning 7.5% recurring commissions.

Verify Email Address

Or copy and paste this link into your browser:

${safeLink}

This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.
If you didn't sign up for the Affiliate Program, you can safely ignore this email.

`; 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 = `

Reset your password

We received a request to reset your password. Click the button below to create a new password for your account.

Reset Password

Or copy and paste this link into your browser:

${safeLink}

This link expires in ${Math.round(PASSWORD_RESET_TTL_MS / (60 * 1000))} minutes.
If you didn't request a password reset, you can safely ignore this email.

`; 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 = `

Thank you for your purchase!

Description Token Top-up
Tokens Added ${tokens.toLocaleString()}
Amount Paid ${formattedAmount}
Status Completed

Your tokens are ready to use! You can now use them for AI-powered development and building.

`; 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 = `

Your pay-as-you-go billing has been processed

Type Pay-as-you-go Usage
Tokens Billed ${tokens.toLocaleString()}
Amount Charged ${formattedAmount}
Status Completed
`; 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 = `

Welcome to the ${plan.charAt(0).toUpperCase() + plan.slice(1)} Plan!

Your subscription is now active! You have full access to all ${plan} features and benefits.

Plan ${plan.charAt(0).toUpperCase() + plan.slice(1)}
Billing Cycle ${billingCycle.charAt(0).toUpperCase() + billingCycle.slice(1)}
Amount ${formattedAmount}/${billingCycle === 'yearly' ? 'year' : 'month'}
Next Billing Date ${nextBillingDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
Status Active
`; 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 = `

Payment Cancelled

Your payment has been cancelled. This could be due to a timeout, customer cancellation, or payment method issue.

No charges were made. You can try making the payment again if you wish.

`; 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 = `

Dispute Accepted

A dispute related to your payment has been accepted by the bank. The funds have been returned to the customer.

Your subscription has been cancelled. Please contact support if you have any questions.

`; 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 = `

Dispute Cancelled

Good news! The dispute related to your payment has been cancelled and your account is in good standing.

Your subscription remains active. Thank you for your continued support!

`; 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 = `

Dispute Challenged

A dispute related to your payment has been challenged. The bank is reviewing the case.

Your subscription may be temporarily affected. We'll notify you once the review is complete.

`; 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 = `

Dispute Expired

Good news! The dispute related to your payment has expired. The dispute period has ended and the original charge stands.

Your account remains in good standing. Thank you for your continued support!

`; 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 = `

Dispute Lost

We regret to inform you that the dispute has been decided in favor of the customer. The funds have been refunded.

Your subscription has been cancelled. If you believe this is an error, please contact our support team.

`; 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 = `

Dispute Won

Great news! The dispute has been decided in your favor. The original charge remains valid.

Your account remains in good standing. Thank you for your patience during this process.

`; 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 = `

Refund Failed

We attempted to process a refund but it was unsuccessful. This could be due to expired card details or payment processor issues.

Please contact support if you were expecting a refund but haven't received it.

`; 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 = `

Payment Failed

Your payment could not be processed. This could be due to insufficient funds, expired card, or other payment issues.

Your subscription has been cancelled. Please update your payment method to reactivate.

`; 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 = `

Your subscription is active!

Great news! Your subscription has been activated successfully. You now have full access to your plan features.

Current Plan: ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}

`; 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 = `

Subscription Expired

Your subscription has expired and you have been downgraded to the Hobby plan.

Current Plan Hobby
Status Expired

Resubscribe anytime to regain access to all premium features and benefits.

`; 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 = `

Subscription On Hold

Your subscription has been placed on hold. This could be due to a payment issue or account review.

Please update your payment method or contact support to reactivate your subscription.

`; 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 = `

Plan Changed

Your subscription plan has been updated successfully.

New Plan: ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}

`; 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 = `

Subscription Renewed!

Your subscription has been renewed successfully. Thank you for your continued support!

Current Plan: ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}

`; 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 = `

Payment Dispute Created

A payment dispute has been created against your account. We are reviewing the case.

Your subscription has been temporarily suspended. We'll notify you once the dispute is resolved.

`; 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 = `

Refund Processed

We have processed a refund for your payment.

Refund Amount ${formattedAmount}
Status Refunded
`; 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 = `

Subscription Cancelled

Your subscription has been cancelled and you have been downgraded to Hobby plan.

Current Plan Hobby
Status Cancelled

Resubscribe anytime to regain access to all premium features and benefits.

`; 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 = `

Subscription Payment Failed

Your subscription payment failed. This could be due to insufficient funds, expired card, or other payment issues.

Your subscription has been cancelled. Please update your payment method to reactivate.

`; 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), 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 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: { [providerName]: providerCfg } }; 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 }); } 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', '--json'], ['sessions', '--list', '--json'], ['session', 'list', '--json'], ['sessions', 'list', '--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 (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; } 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; if (Array.isArray(session.messages)) { for (const msg of session.messages) { if (msg.status === 'queued' && msg.retryAfterRestart) { hasQueuedMessages = true; messageCount++; log('Restoring message after restart', { sessionId: session.id, messageId: msg.id, role: msg.role }); } } } 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']); // 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, tokensPerDay: 0, requestsPerMinute: 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); Object.keys(providerLimits.limits || {}).forEach((p) => seeds.add(normalizeProviderName(p))); Object.keys(providerUsage || {}).forEach((p) => seeds.add(normalizeProviderName(p))); adminModels.forEach((m) => { (m.providers || []).forEach((p) => seeds.add(extractProviderName(p))); }); (planSettings.planningChain || []).forEach((entry) => seeds.add(normalizeProviderName(entry.provider))); 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.tokensPerDay = sanitizeLimitNumber(cfg.tokensPerDay); cfg.requestsPerMinute = sanitizeLimitNumber(cfg.requestsPerMinute); 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), tokensPerDay: sanitizeLimitNumber(entry.tokensPerDay), requestsPerMinute: sanitizeLimitNumber(entry.requestsPerMinute), 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); models.forEach((m) => { // listModels may return strings or objects; handle both. const name = m.name || m.id || m; if (!name || typeof name !== 'string') return; const parts = name.split('/'); if (parts.length > 1 && parts[0]) add(parts[0], name); else 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) }); } 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 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 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 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} 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} 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, }; } 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 dayAgo = now - DAY_MS; const entries = ensureProviderUsageBucket(key); const filterByModel = !!(model && providerLimits.limits[key] && providerLimits.limits[key].scope === 'model'); const result = { tokensLastMinute: 0, tokensLastDay: 0, requestsLastMinute: 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 isDay = entry.ts >= dayAgo; if (isMinute && matchesModel) { result.tokensLastMinute += Number(entry.tokens || 0); result.requestsLastMinute += 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, tokensLastDay: 0, requestsLastMinute: 0, requestsLastDay: 0 }; } if (isMinute) { result.perModel[targetKey].tokensLastMinute += Number(entry.tokens || 0); result.perModel[targetKey].requestsLastMinute += 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'], ['tokensPerDay', usage.tokensLastDay, 'daily tokens'], ['requestsPerMinute', usage.requestsLastMinute, 'minute 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, }; } 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 }; // 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'); } // 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() { if (!OPENROUTER_API_KEY) return []; try { const res = await fetch('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${OPENROUTER_API_KEY}` } }); 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 []; } } 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 with --json try { const { stdout } = await runCommand(cliCommand, ['models', '--json'], { timeout: 15000 }); const parsed = JSON.parse(stdout); const parsedList = Array.isArray(parsed) ? parsed : parsed.models || []; parsedList.forEach((m) => addModel(m)); } catch (error) { log('Unable to read models via --json', { cli: normalizedCli, error: String(error) }); } // Fallback: Try CLI models without --json 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 (fallbackError) { log('Models text fallback failed', { cli: normalizedCli, error: String(fallbackError) }); } // 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() ]; 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 }) { if (!OPENROUTER_API_KEY) { 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 ${OPENROUTER_API_KEY}`, }; 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' }; if (OLLAMA_API_KEY) headers['Authorization'] = `Bearer ${OLLAMA_API_KEY}`; const payload = { model: targetModel, prompt }; const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) }); 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); // 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); } // 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 const { stdout, stderr } = await opencodeManager.executeInSession( session?.id || 'standalone', workspaceDir, cliCommand, args, { timeout: 600000, // 10 minute timeout to prevent stuck processes env: { ...process.env, OPENAI_API_KEY: OPENCODE_OLLAMA_API_KEY }, 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 }); } } // 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; } } // 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 shouldFallbackCliError(err, message) { if (!err) 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; 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}` }); return null; } try { resetMessageStreamingFields(message); // When switching to backup model, preserve session and keep original content let messageContent = 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); 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; attempts.push({ model: option.model, provider: option.provider, error: err.message || String(err), code: err.code || null, earlyTermination: err.earlyTermination || false, timestamp: new Date().toISOString() }); if (err.earlyTermination) { // Only allow fallback if there's no substantial partial output // If there's substantial output, the model was working fine and shouldn't fallback 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; } log('Allowing automatic fallback due to early termination', { model: option.model, provider: option.provider, error: err.message, partialOutputLength }); return null; } if (!shouldFallbackCliError(err, message)) return err; 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; } 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 }); const 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; } message.status = 'done'; message.reply = reply; 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'; 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 userEmail = userId ? (findUserById(userId)?.email || '') : ''; 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, authorEmail: fr.authorEmail, 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, createdAt: 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, authorEmail: featureRequest.authorEmail, 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 handleContactMessagesList(req, res) { const session = getUserSession(req); const userId = session?.userId || ''; const isAdmin = session?.isAdmin || false; if (!isAdmin) { 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 = getUserSession(req); const isAdmin = session?.isAdmin || false; if (!isAdmin) { 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 = getUserSession(req); const isAdmin = session?.isAdmin || false; if (!isAdmin) { 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 }); } 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 payg = PAYG_ENABLED && isPaidPlan(plan) && !user?.unlimitedUsage ? computePaygSummary(resolvedUserId, plan) : null; return sendJson(res, 200, { ok: true, summary, 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(/ { 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(`

${safeMessage}

`); } 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', 'tokensPerDay', 'requestsPerMinute', '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 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, } }; 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' }); } } // 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 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 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); const content = sanitizeMessage(body.content || ''); 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 }); } const cli = normalizeCli(body.cli || session.cli); const now = new Date().toISOString(); const message = { id: randomUUID(), role: 'user', content, displayContent, model, cli, status: 'queued', createdAt: now, updatedAt: now, opencodeTokensUsed: null }; // 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 }); // 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; const 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; const 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 }); 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('Sending redo command to opencode', { sessionId, messageId, opencodeSessionId: session.opencodeSessionId }); const cliCommand = resolveCliCommand('opencode'); const args = ['--message', '/redo', '--session', session.opencodeSessionId]; await runCommand(cliCommand, args, { cwd: session.workspaceDir || REPO_ROOT, timeout: 30000 }); log('Redo command completed', { sessionId, messageId }); sendJson(res, 200, { ok: true, message: 'Redo command sent successfully' }); } catch (error) { log('Redo command 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', '--json'], { 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; 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 } } } // 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); 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); } archive.file(fileInfo.fullPath, { name: archivePath }); } 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 }); } 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]); 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]); 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 = `

Welcome!

Please verify your email address by clicking the button below — or copy and paste the link into your browser:

${escapeHtml(link)}

`; 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 = `

You requested a password reset.

Reset your password by clicking the button below — or copy and paste the link into your browser:

${escapeHtml(link)}

`; 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 === 'POST' && pathname === '/api/upgrade-popup-tracking') return handleUpgradePopupTracking(req, res); if (req.method === 'POST' && pathname === '/api/admin/cancel-messages') return handleAdminCancelMessages(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); } 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/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'); // 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('

404 Not Found

The page you are looking for does not exist.

'); } } 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')); await loadState(); await loadAdminModelStore(); await loadOpenRouterSettings(); await loadMistralSettings(); await loadPlanSettings(); await loadPlanTokenLimits(); await loadProviderLimits(); await loadProviderUsage(); await loadTokenUsage(); await loadTopupSessions(); await loadPendingTopups(); await loadPaygSessions(); 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) }); } // 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); });