Files
shopify-ai-backup/chat/server.js.backup

16638 lines
642 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('<svg') || head.startsWith('<?xml') || head.includes('<svg');
}
function validateImageSignature(clientMimeType, buffer) {
const mt = String(clientMimeType || '').toLowerCase();
if (!mt.startsWith('image/')) return true;
if (!Buffer.isBuffer(buffer) || buffer.length < 8) return false;
if (mt === 'image/png') return isLikelyPng(buffer);
if (mt === 'image/jpeg') return isLikelyJpeg(buffer);
if (mt === 'image/gif') return isLikelyGif(buffer);
if (mt === 'image/webp') return isLikelyWebp(buffer);
if (mt === 'image/svg+xml') return isLikelySvg(buffer);
return true;
}
async function compressImageBuffer(buffer, mimeType) {
if (!IMAGE_COMPRESSION_ENABLED) return { buffer, mimeType, ext: null };
if (!sharp) return { buffer, mimeType, ext: null };
const mt = String(mimeType || '').toLowerCase();
if (!mt.startsWith('image/')) return { buffer, mimeType, ext: null };
if (mt === 'image/svg+xml' || mt === 'image/gif') return { buffer, mimeType, ext: null };
try {
const pipeline = sharp(buffer, { failOn: 'none' }).rotate();
const resized = pipeline.resize({ width: IMAGE_MAX_DIMENSION, height: IMAGE_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true });
const out = await resized.webp({ quality: IMAGE_WEBP_QUALITY }).toBuffer();
if (out && out.length && out.length < buffer.length) {
return { buffer: out, mimeType: 'image/webp', ext: 'webp' };
}
} catch (err) {
log('image compression failed; storing original', { mimeType: mt, err: String(err) });
}
return { buffer, mimeType, ext: null };
}
function safeFileNamePart(raw) {
return String(raw || 'upload').replace(/[^a-zA-Z0-9\-_\.]/g, '_').slice(0, 140) || 'upload';
}
function extensionForMime(mimeType) {
const mt = String(mimeType || '').toLowerCase();
if (mt === 'image/png') return 'png';
if (mt === 'image/jpeg') return 'jpg';
if (mt === 'image/gif') return 'gif';
if (mt === 'image/webp') return 'webp';
if (mt === 'image/svg+xml') return 'svg';
if (mt === 'application/pdf') return 'pdf';
if (mt === 'text/plain') return 'txt';
if (mt === 'text/css') return 'css';
if (mt === 'application/javascript') return 'js';
if (mt === 'text/html') return 'html';
if (mt === 'text/markdown') return 'md';
if (mt === 'text/csv') return 'csv';
if (mt === 'application/json') return 'json';
if (mt === 'application/xml' || mt === 'text/xml') return 'xml';
return 'bin';
}
function toDataUrl(mimeType, base64Data) {
const mt = String(mimeType || 'application/octet-stream');
return `data:${mt};base64,${base64Data || ''}`;
}
async function buildOpenRouterMessagesForSession(session, currentMessage) {
const history = Array.isArray(session?.messages) ? session.messages : [];
const prior = history.filter((m) => m && m.id !== currentMessage?.id && m.status === 'done').slice(-8);
const mapped = prior.map((m) => {
if (m.role === 'assistant') return { role: 'assistant', content: String(m.reply || m.partialOutput || '') };
return { role: 'user', content: String(m.content || '') };
}).filter((m) => (m.content || '').trim().length);
const parts = [];
const text = String(currentMessage?.content || '').trim();
if (text) parts.push({ type: 'text', text });
const images = (Array.isArray(currentMessage?.attachments) ? currentMessage.attachments : []).filter((a) => a && isImageMime(a.type) && a.url);
for (const img of images.slice(0, 6)) {
try {
const filename = String(img.url || '').split('/').pop();
if (!filename) continue;
const candidate = path.join(session.uploadsDir, filename);
const resolved = path.resolve(candidate);
const uploadsRoot = path.resolve(session.uploadsDir);
if (!resolved.startsWith(uploadsRoot + path.sep) && resolved !== uploadsRoot) continue;
const buf = await fs.readFile(resolved);
parts.push({ type: 'image_url', image_url: { url: toDataUrl(img.type, buf.toString('base64')) } });
} catch (err) {
log('failed to embed image for OpenRouter', { err: String(err), url: img.url });
}
}
const userMsg = parts.length ? { role: 'user', content: parts } : { role: 'user', content: text };
return mapped.concat([userMsg]);
}
const state = { sessions: [] };
const sessionQueues = new Map();
const activeStreams = new Map(); // Track active SSE streams per message
const runningProcesses = new Map(); // Track running opencode processes
let resourceReservations = 0; // Tracks reserved slots; Node event loop serializes updates.
const SUPPORTED_CLIS = ['opencode'];
let server = null;
let isShuttingDown = false;
const AUTO_SAVE_INTERVAL_MS = 120000; // Auto-save every 2 minutes (reduced from 30s)
let autoSaveTimer = null;
// ============================================================================
// Memory Management Constants and State
// ============================================================================
const MEMORY_CLEANUP_INTERVAL_MS = 300000; // Run cleanup every 5 minutes (reduced from 60s)
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const MESSAGE_HISTORY_LIMIT = 50; // Max messages to keep per session (reduced from 100)
const PARTIAL_OUTPUT_MAX_LENGTH = 50000; // Truncate large outputs in completed messages
const STALE_STREAM_TIMEOUT_MS = 600000; // 10 minutes - clean up stale SSE streams
const STALE_PROCESS_TIMEOUT_MS = 900000; // 15 minutes - clean up potentially stuck processes
const MAX_SESSION_QUEUES = 1000; // Limit concurrent session queues
const MAX_OAUTH_STATES = 10000; // Limit OAuth state entries
const MAX_LOGIN_ATTEMPTS_ENTRIES = 50000; // Limit login attempt tracking
let lastMemoryCleanup = 0;
let memoryCleanupTimer = null;
const recentlyCleanedSessions = new Set(); // Track recently cleaned sessions to avoid redundant work
// Track spawned child processes for proper cleanup
const childProcesses = new Map(); // processId -> { pid, startTime, sessionId, messageId }
// ============================================================================
// Memory Cleanup Functions
// ============================================================================
/**
* Triggers garbage collection hint and runs memory cleanup
* @param {string} reason - Reason for triggering cleanup (for logging)
*/
function triggerMemoryCleanup(reason = 'manual') {
const now = Date.now();
// Debounce - don't run more than once every 60 seconds (increased from 10s)
if (now - lastMemoryCleanup < 60000) return;
lastMemoryCleanup = now;
const beforeMem = process.memoryUsage();
try {
// Clean up completed session data (only if needed)
const activeMessages = state.sessions.reduce((count, s) => count + (s.pending || 0), 0);
if (activeMessages === 0 || state.sessions.length > 20) {
cleanupCompletedSessions();
}
// Clean up stale entries from Maps
cleanupStaleMaps();
// Clean up orphaned processes
cleanupOrphanedProcesses();
// Truncate large message outputs (less frequently)
if (now % 300000 < 60000) { // Every 5 minutes
truncateLargeOutputs();
}
// Hint to V8 to run GC if available (--expose-gc flag)
if (global.gc) {
global.gc();
}
const afterMem = process.memoryUsage();
const freedMb = ((beforeMem.heapUsed - afterMem.heapUsed) / (1024 * 1024)).toFixed(2);
if (freedMb > 1) {
log('Memory cleanup completed', {
reason,
freedMb: `${freedMb}MB`,
beforeHeap: `${(beforeMem.heapUsed / 1024 / 1024).toFixed(2)}MB`,
afterHeap: `${(afterMem.heapUsed / 1024 / 1024).toFixed(2)}MB`,
rss: `${(afterMem.rss / 1024 / 1024).toFixed(2)}MB`
});
}
} catch (err) {
log('Memory cleanup error', { error: String(err), reason });
}
}
/**
* Cleans up old and completed session data
*/
function cleanupCompletedSessions() {
const now = Date.now();
let cleanedSessions = 0;
let cleanedMessages = 0;
for (const session of state.sessions) {
// Skip sessions cleaned in the last hour to avoid redundant work
const lastCleaned = session.lastCleanedAt ? now - new Date(session.lastCleanedAt).getTime() : Infinity;
if (lastCleaned < 3600000) continue; // Skip if cleaned within last hour
let sessionChanged = false;
// Clean up old messages within sessions
if (Array.isArray(session.messages) && session.messages.length > MESSAGE_HISTORY_LIMIT) {
const excess = session.messages.length - MESSAGE_HISTORY_LIMIT;
// Remove oldest completed messages first
const toRemove = session.messages
.filter(m => m.status === 'done' || m.status === 'error' || m.status === 'cancelled')
.slice(0, excess);
for (const msg of toRemove) {
const idx = session.messages.indexOf(msg);
if (idx !== -1) {
session.messages.splice(idx, 1);
cleanedMessages++;
sessionChanged = true;
}
}
}
// Clear partialOutput from completed messages (keep reply only)
if (Array.isArray(session.messages)) {
for (const msg of session.messages) {
if ((msg.status === 'done' || msg.status === 'error') && msg.partialOutput && msg.reply) {
// Keep only the final reply, not the streaming partial output
delete msg.partialOutput;
cleanedMessages++;
sessionChanged = true;
}
}
}
// Check session age
const sessionAge = now - new Date(session.createdAt).getTime();
if (sessionAge > SESSION_MAX_AGE_MS && session.pending === 0) {
// Mark for cleanup but don't delete - just clean up heavy data
if (session.messages && session.messages.length > 20) {
const oldLength = session.messages.length;
session.messages = session.messages.slice(-20); // Keep only last 20 messages
if (session.messages.length < oldLength) {
cleanedSessions++;
sessionChanged = true;
}
}
}
// Mark session as cleaned
if (sessionChanged) {
session.lastCleanedAt = new Date().toISOString();
}
}
if (cleanedSessions > 0 || cleanedMessages > 0) {
log('Cleaned session data', { cleanedSessions, cleanedMessages });
}
}
/**
* Cleans up stale entries from various Maps to prevent memory leaks
*/
function cleanupStaleMaps() {
const now = Date.now();
let cleaned = { streams: 0, queues: 0, oauth: 0, processes: 0, loginAttempts: 0, apiRate: 0 };
// Clean up stale active streams (SSE connections that didn't close properly)
for (const [messageId, streams] of activeStreams.entries()) {
// If message is no longer running/queued, cleanup its streams
let messageFound = false;
for (const session of state.sessions) {
const msg = session.messages?.find(m => m.id === messageId);
if (msg) {
messageFound = true;
if (msg.status !== 'running' && msg.status !== 'queued') {
// Message completed - close and remove streams
if (streams instanceof Set) {
for (const stream of streams) {
try { stream.end(); } catch (_) {}
}
}
activeStreams.delete(messageId);
cleaned.streams++;
}
break;
}
}
if (!messageFound) {
// Message not found at all - cleanup streams
if (streams instanceof Set) {
for (const stream of streams) {
try { stream.end(); } catch (_) {}
}
}
activeStreams.delete(messageId);
cleaned.streams++;
}
}
// Clean up old session queues for deleted/inactive sessions
for (const [sessionId, _queue] of sessionQueues.entries()) {
const session = state.sessions.find(s => s.id === sessionId);
if (!session || session.pending === 0) {
// Check if there are any running messages
const hasRunning = session?.messages?.some(m => m.status === 'running' || m.status === 'queued');
if (!hasRunning) {
sessionQueues.delete(sessionId);
cleaned.queues++;
}
}
}
// Limit session queues size
if (sessionQueues.size > MAX_SESSION_QUEUES) {
const toDelete = sessionQueues.size - MAX_SESSION_QUEUES;
let deleted = 0;
for (const [sessionId] of sessionQueues.entries()) {
if (deleted >= toDelete) break;
const session = state.sessions.find(s => s.id === sessionId);
if (!session || session.pending === 0) {
sessionQueues.delete(sessionId);
deleted++;
cleaned.queues++;
}
}
}
// Clean up running processes map for processes that finished
for (const [messageId, processInfo] of runningProcesses.entries()) {
// Check if message is still running
let stillRunning = false;
for (const session of state.sessions) {
const msg = session.messages?.find(m => m.id === messageId);
if (msg && (msg.status === 'running' || msg.status === 'queued')) {
stillRunning = true;
break;
}
}
if (!stillRunning) {
runningProcesses.delete(messageId);
cleaned.processes++;
}
// Also check for stale processes (running too long)
if (processInfo.started && (now - processInfo.started) > STALE_PROCESS_TIMEOUT_MS) {
log('Cleaning up stale process entry', { messageId, age: now - processInfo.started });
runningProcesses.delete(messageId);
cleaned.processes++;
}
}
// Clean up expired OAuth state entries (deferred cleanup - handled here for Maps that grow unbounded)
// Note: oauthStateStore is defined later, so we access it via typeof check
if (typeof oauthStateStore !== 'undefined' && oauthStateStore instanceof Map) {
for (const [key, entry] of oauthStateStore.entries()) {
if (entry.expiresAt && entry.expiresAt < now) {
oauthStateStore.delete(key);
cleaned.oauth++;
}
}
// Hard limit on OAuth states
if (oauthStateStore.size > MAX_OAUTH_STATES) {
const toDelete = oauthStateStore.size - MAX_OAUTH_STATES;
let deleted = 0;
for (const [key] of oauthStateStore.entries()) {
if (deleted >= toDelete) break;
oauthStateStore.delete(key);
deleted++;
cleaned.oauth++;
}
}
}
// Clean up old login attempt entries (older than 1 hour)
const LOGIN_ATTEMPT_MAX_AGE = 60 * 60 * 1000; // 1 hour
if (typeof loginAttempts !== 'undefined' && loginAttempts instanceof Map) {
for (const [key, record] of loginAttempts.entries()) {
const age = now - (record.windowStart || 0);
if (age > LOGIN_ATTEMPT_MAX_AGE && (!record.lockedUntil || record.lockedUntil < now)) {
loginAttempts.delete(key);
cleaned.loginAttempts++;
}
}
// Hard limit
if (loginAttempts.size > MAX_LOGIN_ATTEMPTS_ENTRIES) {
const toDelete = loginAttempts.size - MAX_LOGIN_ATTEMPTS_ENTRIES;
let deleted = 0;
for (const [key, record] of loginAttempts.entries()) {
if (deleted >= toDelete) break;
if (!record.lockedUntil || record.lockedUntil < now) {
loginAttempts.delete(key);
deleted++;
cleaned.loginAttempts++;
}
}
}
}
// Clean up old admin login attempt entries
if (typeof adminLoginAttempts !== 'undefined' && adminLoginAttempts instanceof Map) {
for (const [key, record] of adminLoginAttempts.entries()) {
const age = now - (record.windowStart || 0);
if (age > LOGIN_ATTEMPT_MAX_AGE && (!record.lockedUntil || record.lockedUntil < now)) {
adminLoginAttempts.delete(key);
cleaned.loginAttempts++;
}
}
}
// Clean up old API rate limit entries (older than rate limit window)
const API_RATE_LIMIT_MAX_AGE = 60 * 60 * 1000; // 1 hour
if (typeof apiRateLimit !== 'undefined' && apiRateLimit instanceof Map) {
for (const [key, record] of apiRateLimit.entries()) {
const age = now - (record.windowStart || 0);
if (age > API_RATE_LIMIT_MAX_AGE) {
apiRateLimit.delete(key);
cleaned.apiRate++;
}
}
}
const totalCleaned = Object.values(cleaned).reduce((a, b) => a + b, 0);
if (totalCleaned > 0) {
log('Cleaned stale map entries', cleaned);
}
}
/**
* Cleans up orphaned child processes
*/
function cleanupOrphanedProcesses() {
const now = Date.now();
let killed = 0;
for (const [processId, info] of childProcesses.entries()) {
const age = now - info.startTime;
// Kill processes running longer than timeout
if (age > STALE_PROCESS_TIMEOUT_MS) {
try {
process.kill(info.pid, 'SIGTERM');
setTimeout(() => {
try { process.kill(info.pid, 'SIGKILL'); } catch (_) {}
}, 5000);
killed++;
log('Killed orphaned process', { processId, pid: info.pid, age, sessionId: info.sessionId });
} catch (err) {
// Process may already be dead
if (err.code !== 'ESRCH') {
log('Failed to kill orphaned process', { processId, error: String(err) });
}
}
childProcesses.delete(processId);
}
}
if (killed > 0) {
log('Cleaned up orphaned processes', { killed });
}
}
/**
* Truncates large outputs in completed messages to save memory
*/
function truncateLargeOutputs() {
let truncated = 0;
for (const session of state.sessions) {
if (!Array.isArray(session.messages)) continue;
for (const msg of session.messages) {
// Only truncate completed messages
if (msg.status !== 'done' && msg.status !== 'error') continue;
// Truncate large reply
if (msg.reply && msg.reply.length > PARTIAL_OUTPUT_MAX_LENGTH) {
msg.reply = msg.reply.substring(0, PARTIAL_OUTPUT_MAX_LENGTH) + '\n\n[Output truncated due to length...]';
truncated++;
}
// Remove partialOutput if we have reply
if (msg.partialOutput && msg.reply) {
delete msg.partialOutput;
truncated++;
}
// Truncate large content
if (msg.content && msg.content.length > PARTIAL_OUTPUT_MAX_LENGTH) {
msg.content = msg.content.substring(0, PARTIAL_OUTPUT_MAX_LENGTH) + '\n\n[Content truncated...]';
truncated++;
}
}
}
if (truncated > 0) {
log('Truncated large message fields', { truncated });
}
}
/**
* Starts periodic memory cleanup
*/
function startMemoryCleanup() {
if (memoryCleanupTimer) return;
memoryCleanupTimer = setInterval(() => {
if (isShuttingDown) return;
triggerMemoryCleanup('periodic');
}, MEMORY_CLEANUP_INTERVAL_MS);
log('Memory cleanup scheduler started', { intervalMs: MEMORY_CLEANUP_INTERVAL_MS });
}
/**
* Stops periodic memory cleanup
*/
function stopMemoryCleanup() {
if (memoryCleanupTimer) {
clearInterval(memoryCleanupTimer);
memoryCleanupTimer = null;
}
}
/**
* Registers a child process for tracking
* @param {string} processId - Unique process identifier
* @param {number} pid - Process ID
* @param {string} sessionId - Associated session ID
* @param {string} messageId - Associated message ID
*/
function registerChildProcess(processId, pid, sessionId, messageId) {
childProcesses.set(processId, {
pid,
startTime: Date.now(),
sessionId,
messageId
});
}
/**
* Unregisters a child process
* @param {string} processId - Process identifier
*/
function unregisterChildProcess(processId) {
childProcesses.delete(processId);
}
// ============================================================================
// OpenCode Process Manager - Singleton for managing a single OpenCode instance
// ============================================================================
class OpencodeProcessManager {
constructor() {
this.process = null;
this.isReady = false;
this.pendingRequests = new Map(); // messageId -> { resolve, reject, timeout }
this.heartbeatInterval = null;
this.lastActivity = Date.now();
this.sessionWorkspaces = new Map(); // sessionId -> workspaceDir
}
async start() {
if (this.process) {
log('OpenCode process manager already running');
return;
}
log('Starting OpenCode process manager singleton...');
try {
const cliCommand = resolveCliCommand('opencode');
// Verify CLI exists
try {
fsSync.accessSync(cliCommand, fsSync.constants.X_OK);
} catch (err) {
throw new Error(`OpenCode CLI not found: ${cliCommand}`);
}
// Start OpenCode in server/daemon mode if supported
// Otherwise, we'll continue using per-message spawning
// Check if OpenCode supports a server/daemon mode
const serverArgs = this.getServerModeArgs();
if (serverArgs) {
log('Starting OpenCode in server mode', { args: serverArgs });
this.process = spawn(cliCommand, serverArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }
});
this.process.stdout.on('data', (data) => this.handleStdout(data));
this.process.stderr.on('data', (data) => this.handleStderr(data));
this.process.on('error', (err) => this.handleError(err));
this.process.on('exit', (code) => this.handleExit(code));
// Wait for ready signal
await this.waitForReady();
// Start heartbeat to keep process alive
this.startHeartbeat();
log('OpenCode process manager started successfully');
} else {
log('OpenCode does not support server mode, will use per-session approach');
// We'll track sessions instead of using a single process
this.isReady = true;
}
} catch (err) {
log('Failed to start OpenCode process manager', { error: String(err) });
this.process = null;
this.isReady = false;
throw err;
}
}
getServerModeArgs() {
// Check if OpenCode supports server/daemon mode
// This would need to be customized based on actual OpenCode CLI capabilities
// For now, we return null to indicate no server mode support
// If OpenCode adds 'serve' or 'daemon' command, update this
return null;
}
async waitForReady(timeout = 10000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('OpenCode process failed to become ready'));
}, timeout);
const checkReady = () => {
if (this.isReady) {
clearTimeout(timer);
resolve();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
});
}
handleStdout(data) {
const text = data.toString();
log('OpenCode process stdout', { text: text.slice(0, 200) });
// Check for ready signal
if (text.includes('ready') || text.includes('listening')) {
this.isReady = true;
}
// Process responses for pending requests
this.processOutput(text);
}
handleStderr(data) {
const text = data.toString();
log('OpenCode process stderr', { text: text.slice(0, 200) });
}
handleError(err) {
log('OpenCode process error', { error: String(err) });
this.isReady = false;
this.rejectAllPending(err);
}
handleExit(code) {
log('OpenCode process exited', { code });
this.isReady = false;
this.process = null;
this.rejectAllPending(new Error(`OpenCode process exited with code ${code}`));
// Auto-restart if not shutting down
if (!isShuttingDown) {
log('Auto-restarting OpenCode process...');
setTimeout(() => this.start().catch(err =>
log('Failed to restart OpenCode', { error: String(err) })
), 5000);
}
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (!this.process || !this.isReady) return;
// Send a lightweight heartbeat command
// This keeps the process alive and checks responsiveness
const idleTime = Date.now() - this.lastActivity;
// If idle for more than 5 minutes, send a ping
if (idleTime > 300000) {
log('Sending heartbeat to OpenCode process', { idleTime });
// Could send a simple command like 'version' or 'status'
}
}, 60000); // Every minute
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
processOutput(text) {
// Parse output and route to appropriate pending request
// This would need to be customized based on OpenCode's output format
this.lastActivity = Date.now();
}
rejectAllPending(error) {
for (const [messageId, request] of this.pendingRequests.entries()) {
if (request.timeout) clearTimeout(request.timeout);
request.reject(error);
}
this.pendingRequests.clear();
}
async executeInSession(sessionId, workspaceDir, command, args, options = {}) {
if (!this.isReady && !this.process) {
// Fall back to per-message spawning if server mode not available
return this.executeStandalone(workspaceDir, command, args, options);
}
// Track workspace for this session
this.sessionWorkspaces.set(sessionId, workspaceDir);
this.lastActivity = Date.now();
// Send command to persistent process
// This would need protocol implementation based on OpenCode's capabilities
// For now, fall back to standalone execution
return this.executeStandalone(workspaceDir, command, args, options);
}
async executeStandalone(workspaceDir, command, args, options) {
// Execute as a standalone process (current behavior)
// But track it so we can see when multiple processes are running
const processId = randomUUID();
const startTime = Date.now();
log('Executing OpenCode command (standalone)', {
processId,
command,
args,
workspaceDir,
activeProcesses: runningProcesses.size
});
try {
const result = await runCommand(command, args, {
...options,
cwd: workspaceDir
});
const duration = Date.now() - startTime;
log('OpenCode command completed', { processId, duration, activeProcesses: runningProcesses.size });
return result;
} catch (err) {
const duration = Date.now() - startTime;
log('OpenCode command failed', { processId, duration, error: String(err) });
throw err;
}
}
async stop() {
log('Stopping OpenCode process manager...');
this.stopHeartbeat();
this.isReady = false;
if (this.process) {
this.process.kill('SIGTERM');
// Wait for graceful shutdown
await new Promise((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
log('Force killing OpenCode process');
this.process.kill('SIGKILL');
}
resolve();
}, 5000);
if (this.process) {
this.process.once('exit', () => {
clearTimeout(timeout);
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
this.process = null;
}
this.sessionWorkspaces.clear();
log('OpenCode process manager stopped');
}
getStats() {
return {
isRunning: !!this.process,
isReady: this.isReady,
pendingRequests: this.pendingRequests.size,
activeSessions: this.sessionWorkspaces.size,
lastActivity: this.lastActivity,
idleTime: Date.now() - this.lastActivity
};
}
}
// Global singleton instance
const opencodeManager = new OpencodeProcessManager();
// ============================================================================
// End OpenCode Process Manager
// ============================================================================
async function safeWriteFile(filePath, data, maxRetries = 3) {
const tempPath = filePath + '.tmp';
const parentDir = path.dirname(filePath);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await fs.mkdir(parentDir, { recursive: true });
await fs.writeFile(tempPath, data, 'utf8');
await fs.rename(tempPath, filePath);
return;
} catch (error) {
if (error.code === 'ENOENT' && attempt < maxRetries) {
log(`safeWriteFile retry ${attempt}/${maxRetries} for ${path.basename(filePath)}`, { error: String(error) });
await delay(50 * attempt);
continue;
}
log(`safeWriteFile failed for ${path.basename(filePath)}`, { error: String(error), attempt });
throw error;
}
}
}
async function persistAllState() {
const persistFunctions = [
persistState,
persistAdminModels,
persistOpenRouterSettings,
persistMistralSettings,
persistPlanSettings,
persistPlanTokenLimits,
persistProviderLimits,
persistProviderUsage,
persistTokenUsage,
persistTopupSessions,
persistPendingTopups,
persistPaygSessions,
persistPendingPayg,
persistUsersDb,
persistAffiliatesDb,
persistFeatureRequestsDb,
persistContactMessagesDb,
];
for (const persistFn of persistFunctions) {
try {
await persistFn();
} catch (error) {
log(`Failed to persist ${persistFn.name}`, { error: String(error) });
}
}
}
async function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
log(`Received ${signal}, starting graceful shutdown with session preservation...`);
// Stop periodic tasks first
stopAutoSave();
stopMemoryCleanup();
// Notify all active SSE connections about the restart
for (const [messageId, streams] of activeStreams.entries()) {
try {
if (streams instanceof Set) {
for (const stream of streams) {
try {
stream.write(`data: ${JSON.stringify({ type: 'server-restart', message: 'Server is restarting, your session will be restored automatically...' })}\n\n`);
stream.end();
} catch (_) {}
}
} else if (streams && streams.write) {
streams.write(`data: ${JSON.stringify({ type: 'server-restart', message: 'Server is restarting, your session will be restored automatically...' })}\n\n`);
}
} catch (e) {
// Stream may already be closed, ignore
}
}
activeStreams.clear();
// Kill all tracked child processes
log('Terminating child processes...');
for (const [processId, info] of childProcesses.entries()) {
try {
process.kill(info.pid, 'SIGTERM');
} catch (_) {
// Process may already be dead
}
}
// Give them a moment to terminate gracefully
await delay(1000);
// Force kill any remaining
for (const [processId, info] of childProcesses.entries()) {
try {
process.kill(info.pid, 'SIGKILL');
} catch (_) {}
}
childProcesses.clear();
runningProcesses.clear();
sessionQueues.clear();
log('Stopping OpenCode process manager...');
await opencodeManager.stop();
log('Persisting all state with session continuity info...');
await persistAllState();
if (server) {
log('Closing HTTP server...');
await new Promise((resolve) => server.close(resolve));
}
log('Graceful shutdown complete, sessions preserved for restoration');
process.exit(0);
}
function startAutoSave() {
autoSaveTimer = setInterval(async () => {
if (isShuttingDown) return;
try {
await persistAllState();
} catch (error) {
log('Auto-save failed', { error: String(error) });
}
}, AUTO_SAVE_INTERVAL_MS);
}
function stopAutoSave() {
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
autoSaveTimer = null;
}
}
let cachedModels = new Map();
let cachedModelsAt = new Map();
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;')
.replace(/\//g, '&#47;');
}
// 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 ? `
<tr>
<td style="background: linear-gradient(135deg, ${accent} 0%, ${accent2} 100%); padding: 32px 40px; text-align: center;">
<img src="${logoUrl}" alt="Plugin Compass" width="56" height="56" style="border-radius:12px; display: inline-block; margin-bottom: 16px; background: rgba(255,255,255,0.15);">
<h1 style="color: #ffffff; margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -0.5px;">${escapeHtml(heroTitle || safeTitle)}</h1>
${heroSubtitle ? `<p style="color: rgba(255,255,255,0.85); margin: 8px 0 0 0; font-size: 15px;">${escapeHtml(heroSubtitle)}</p>` : ''}
</td>
</tr>
` : `
<tr>
<td style="padding: 28px 32px 16px 32px; text-align: center; border-bottom: 1px solid ${borderColor};">
<img src="${logoUrl}" alt="Plugin Compass" width="52" height="52" style="border-radius:10px; display: inline-block; margin-bottom: 8px;">
<div style="color: ${accent}; font-size: 16px; font-weight: 700; letter-spacing: -0.3px;">${safeTitle}</div>
</td>
</tr>
`;
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body { margin: 0; padding: 0; background: ${bg}; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; }
.wrapper { width: 100%; max-width: 620px; margin: 0 auto; padding: 24px 16px; }
.card { background: ${cardBg}; border-radius: 16px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.06); border: 1px solid ${borderColor}; }
.card-inner { background: ${cardBg}; }
.body-content { padding: 28px 32px 24px 32px; color: ${textPrimary}; font-size: 15px; line-height: 1.7; }
.body-content p { margin: 0 0 16px 0; }
.body-content p:last-child { margin-bottom: 0; }
.details-box { background: ${bg}; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid ${borderColor}; }
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid ${borderColor}; }
.detail-row:last-child { border-bottom: none; padding-bottom: 0; }
.detail-row:first-child { padding-top: 0; }
.detail-label { color: ${textSecondary}; font-size: 14px; }
.detail-value { font-weight: 600; color: ${textPrimary}; font-size: 14px; }
.cta-section { padding: 0 32px 28px 32px; text-align: center; }
.btn { display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, ${accent} 0%, ${accent2} 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25); }
.btn:hover { opacity: 0.95; }
.success-icon { width: 64px; height: 64px; background: ${accentLight}; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.success-icon svg { width: 32px; height: 32px; color: ${accent}; }
.footer { padding: 24px 32px; border-top: 1px solid ${borderColor}; text-align: center; font-size: 13px; color: ${textMuted}; }
.footer a { color: ${accent}; text-decoration: none; }
.footer a:hover { text-decoration: underline; }
.preheader { display: none !important; visibility: hidden; mso-hide: all; font-size: 0; line-height: 0; }
.divider { height: 1px; background: ${borderColor}; margin: 0; }
.highlight-box { background: ${accentLight}; border-left: 4px solid ${accent}; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0; }
@media only screen and (max-width: 640px) {
.wrapper { padding: 12px; }
.body-content { padding: 20px 24px 20px 24px; }
.cta-section { padding: 0 24px 24px 24px; }
.footer { padding: 20px 24px; }
}
</style>
</head>
<body>
<span class="preheader">${safePre}</span>
<div class="wrapper">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<table class="card" width="100%" cellpadding="0" cellspacing="0" border="0">
${heroSection}
<tr>
<td class="body-content">
${bodyHtml}
</td>
</tr>
${buttonText ? `
<tr>
<td class="cta-section">
<a class="btn" href="${safeBtnLink}">${escapeHtml(buttonText)}</a>
</td>
</tr>
` : ''}
<tr>
<td class="divider"></td>
</tr>
<tr>
<td class="footer">
<p style="margin: 0 0 8px 0;">Need help? Contact us at <a href="mailto:support@plugincompass.com">support@plugincompass.com</a></p>
<p style="margin: 0 0 4px 0;">© ${new Date().getFullYear()} Plugin Compass. All rights reserved.</p>
<p style="margin: 0;"><a href="${escapeHtml(PUBLIC_BASE_URL || '')}">${escapeHtml(PUBLIC_BASE_URL || '') || 'Visit our website'}</a> &nbsp;|&nbsp; <a href="${escapeHtml(PUBLIC_BASE_URL || '' || '')}/privacy">Privacy</a> &nbsp;|&nbsp; <a href="${escapeHtml(PUBLIC_BASE_URL || '' || '')}/terms">Terms</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>`;
return html;
}
async function sendVerificationEmail(user, baseUrl) {
if (!user || !user.email || !user.verificationToken) return;
const link = `${baseUrl.replace(/\/+$/, '')}/verify-email?token=${encodeURIComponent(user.verificationToken)}`;
const safeLink = escapeHtml(link);
const text = `Welcome!\n\nPlease verify your email address by visiting the link below:\n${link}\n\nThis link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.`;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to Plugin Compass!</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Thanks for signing up! Please verify your email address to get started with AI-powered development.</p>
<div style="text-align: center; margin: 28px 0;">
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Verify Email Address</a>
</div>
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.</strong><br>If you didn't sign up for Plugin Compass, you can safely ignore this email.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Verify your email',
preheader: 'Confirm your email to get started',
bodyHtml,
buttonText: '',
buttonLink: '',
});
await sendEmail({ to: user.email, subject: 'Plugin Compass — Verify your email', text, html });
}
async function sendAffiliateVerificationEmail(affiliate, baseUrl) {
if (!affiliate || !affiliate.email || !affiliate.verificationToken) return;
const link = `${baseUrl.replace(/\/+$/, '')}/affiliate-verify-email?token=${encodeURIComponent(affiliate.verificationToken)}`;
const safeLink = escapeHtml(link);
const text = `Welcome to the Plugin Compass Affiliate Program!\n\nPlease verify your email address by visiting the link below:\n${link}\n\nThis link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.`;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to the Affiliate Program!</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Thanks for joining our Affiliate Program! Please verify your email address to start earning 7.5% recurring commissions.</p>
<div style="text-align: center; margin: 28px 0;">
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Verify Email Address</a>
</div>
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000))} hours.</strong><br>If you didn't sign up for the Affiliate Program, you can safely ignore this email.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Verify your affiliate email',
preheader: 'Confirm your email to start earning commissions',
bodyHtml,
buttonText: '',
buttonLink: '',
});
await sendEmail({ to: affiliate.email, subject: 'Plugin Compass — Verify your affiliate email', text, html });
}
async function sendPasswordResetEmail(user, baseUrl) {
if (!user || !user.email || !user.resetToken) return;
const link = `${baseUrl.replace(/\/+$/, '')}/reset-password?token=${encodeURIComponent(user.resetToken)}`;
const safeLink = escapeHtml(link);
const text = `You requested a password reset.\n\nReset your password using the link below:\n${link}\n\nThis link expires in ${Math.round(PASSWORD_RESET_TTL_MS / (60 * 1000))} minutes.`;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#E65100" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Reset your password</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We received a request to reset your password. Click the button below to create a new password for your account.</p>
<div style="text-align: center; margin: 28px 0;">
<a class="btn" href="${safeLink}" style="display: inline-block; padding: 14px 28px; border-radius: 10px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 15px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); box-shadow: 0 4px 12px rgba(0, 66, 37, 0.25);">Reset Password</a>
</div>
<p style="margin-bottom: 8px; color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; margin: 0; color: #004225; font-size: 13px;"><a href="${safeLink}" style="color: #004225; text-decoration: none;">${safeLink}</a></p>
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #E65100; padding: 16px 20px; margin: 24px 0 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>This link expires in ${Math.round(PASSWORD_RESET_TTL_MS / (60 * 1000))} minutes.</strong><br>If you didn't request a password reset, you can safely ignore this email.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Reset your password',
preheader: 'Reset access to your Plugin Compass account',
bodyHtml,
buttonText: '',
buttonLink: '',
});
await sendEmail({ to: user.email, subject: 'Plugin Compass — Reset your password', text, html });
}
async function sendPaymentConfirmationEmail(user, paymentType, details) {
if (!user || !user.email) return;
let title, preheader, bodyHtml, buttonText, buttonLink, subject, showHero, heroTitle, heroSubtitle;
if (paymentType === 'topup') {
const tokens = details.tokens || 0;
const currency = (details.currency || 'USD').toUpperCase();
const amount = (details.amount || 0) / 100;
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
subject = 'Plugin Compass — Payment Confirmed';
title = 'Payment Successful';
preheader = 'Your token purchase is complete';
showHero = true;
heroTitle = 'Payment Confirmed';
heroSubtitle = 'Your tokens have been added to your account';
bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div class="success-icon" style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Thank you for your purchase!</p>
</div>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Description</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Token Top-up</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Tokens Added</span>
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${tokens.toLocaleString()}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Amount Paid</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Completed</span>
</div>
</div>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your tokens are ready to use!</strong> You can now use them for AI-powered development and building.</p>
</div>
`;
buttonText = 'View Your Account';
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
} else if (paymentType === 'payg') {
const tokens = details.tokens || 0;
const currency = (details.currency || 'USD').toUpperCase();
const amount = (details.amount || 0) / 100;
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
subject = 'Plugin Compass — Payment Confirmed';
title = 'Pay-as-you-go Billing';
preheader = 'Your overage billing is complete';
showHero = true;
heroTitle = 'Payment Processed';
heroSubtitle = 'Your usage has been billed';
bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div class="success-icon" style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Your pay-as-you-go billing has been processed</p>
</div>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Type</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Pay-as-you-go Usage</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Tokens Billed</span>
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${tokens.toLocaleString()}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Amount Charged</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Completed</span>
</div>
</div>
`;
buttonText = 'View Usage';
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
} else if (paymentType === 'subscription') {
const plan = details.plan || '';
const billingCycle = details.billingCycle || 'monthly';
const currency = (details.currency || 'USD').toUpperCase();
const amount = (details.amount || 0) / 100;
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency }).format(amount);
const nextBillingDate = new Date();
nextBillingDate.setMonth(nextBillingDate.getMonth() + (billingCycle === 'yearly' ? 12 : 1));
subject = 'Plugin Compass — Subscription Activated';
title = 'Welcome to ' + plan.charAt(0).toUpperCase() + plan.slice(1);
preheader = 'Your subscription is now active';
showHero = true;
heroTitle = 'Subscription Active';
heroSubtitle = 'Welcome to ' + plan.charAt(0).toUpperCase() + plan.slice(1) + ' Plan';
bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Welcome to the ${plan.charAt(0).toUpperCase() + plan.slice(1)} Plan!</p>
</div>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription is now active!</strong> You have full access to all ${plan} features and benefits.</p>
</div>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Plan</span>
<span class="detail-value" style="font-weight: 600; color: #004225; font-size: 14px;">${plan.charAt(0).toUpperCase() + plan.slice(1)}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Billing Cycle</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${billingCycle.charAt(0).toUpperCase() + billingCycle.slice(1)}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Amount</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}/${billingCycle === 'yearly' ? 'year' : 'month'}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Next Billing Date</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${nextBillingDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Active</span>
</div>
</div>
`;
buttonText = 'Explore Your Plan';
buttonLink = `${PUBLIC_BASE_URL || ''}/settings`;
}
const html = renderBrandedEmail({
title,
preheader,
bodyHtml,
buttonText,
buttonLink,
showHero,
heroTitle,
heroSubtitle,
});
const text = bodyHtml.replace(/<[^>]*>/g, '').replace(/\n\s*\n/g, '\n\n').trim();
try {
await sendEmail({ to: user.email, subject, text, html });
} catch (err) {
log('failed to send payment confirmation email', { userId: user.id, email: user.email, paymentType, error: String(err) });
}
}
async function sendPaymentCancelledEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Cancelled</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your payment has been cancelled. This could be due to a timeout, customer cancellation, or payment method issue.</p>
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>No charges were made.</strong> You can try making the payment again if you wish.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Payment Cancelled',
preheader: 'Your payment was cancelled',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send payment cancelled email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeAcceptedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Accepted</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A dispute related to your payment has been accepted by the bank. The funds have been returned to the customer.</p>
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please contact support if you have any questions.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Accepted',
preheader: 'Payment dispute accepted by bank',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Accepted', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute accepted email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeCancelledEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Cancelled</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Good news! The dispute related to your payment has been cancelled and your account is in good standing.</p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription remains active.</strong> Thank you for your continued support!</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Cancelled',
preheader: 'Payment dispute has been cancelled',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute cancelled email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeChallengedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Challenged</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A dispute related to your payment has been challenged. The bank is reviewing the case.</p>
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription may be temporarily affected.</strong> We'll notify you once the review is complete.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Challenged',
preheader: 'Payment dispute is being reviewed',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Challenged', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute challenged email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeExpiredEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Expired</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Good news! The dispute related to your payment has expired. The dispute period has ended and the original charge stands.</p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your account remains in good standing.</strong> Thank you for your continued support!</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Expired',
preheader: 'Payment dispute has expired',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Expired', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute expired email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeLostEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Lost</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We regret to inform you that the dispute has been decided in favor of the customer. The funds have been refunded.</p>
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> If you believe this is an error, please contact our support team.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Lost',
preheader: 'Payment dispute was not successful',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Lost', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute lost email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendDisputeWonEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Dispute Won</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Great news! The dispute has been decided in your favor. The original charge remains valid.</p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your account remains in good standing.</strong> Thank you for your patience during this process.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Dispute Won',
preheader: 'Payment dispute was successful',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Dispute Won', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send dispute won email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendRefundFailedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Refund Failed</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We attempted to process a refund but it was unsuccessful. This could be due to expired card details or payment processor issues.</p>
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Please contact support</strong> if you were expecting a refund but haven't received it.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Refund Failed',
preheader: 'Refund processing failed',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Refund Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send refund failed email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendPaymentFailedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Failed</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your payment could not be processed. This could be due to insufficient funds, expired card, or other payment issues.</p>
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please update your payment method to reactivate.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Payment Failed',
preheader: 'Your payment could not be processed',
bodyHtml,
buttonText: 'Update Payment',
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send payment failed email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionActiveEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Your subscription is active!</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Great news! Your subscription has been activated successfully. You now have full access to your plan features.</p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Current Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription Active',
preheader: 'Your subscription is now active',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Active', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription active email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionExpiredEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Expired</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has expired and you have been downgraded to the Hobby plan.</p>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Current Plan</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Hobby</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #D32F2F; font-size: 14px;">Expired</span>
</div>
</div>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Resubscribe anytime</strong> to regain access to all premium features and benefits.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription Expired',
preheader: 'Your subscription has expired',
bodyHtml,
buttonText: 'Renew Subscription',
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Expired', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription expired email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionOnHoldEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFF3E0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#FF9800" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription On Hold</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been placed on hold. This could be due to a payment issue or account review.</p>
<div class="highlight-box" style="background: #FFF3E0; border-left: 4px solid #FF9800; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Please update your payment method</strong> or contact support to reactivate your subscription.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription On Hold',
preheader: 'Your subscription is temporarily on hold',
bodyHtml,
buttonText: 'Update Payment',
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription On Hold', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription on hold email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionPlanChangedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E3F2FD; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#1976D2" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Plan Changed</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription plan has been updated successfully.</p>
<div class="highlight-box" style="background: #E3F2FD; border-left: 4px solid #1976D2; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>New Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Plan Changed',
preheader: 'Your subscription plan has been updated',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Plan Changed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send plan changed email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionRenewedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div class="success-icon" style="width: 72px; height: 72px; background: linear-gradient(135deg, #004225 0%, #006B3D 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Renewed!</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been renewed successfully. Thank you for your continued support!</p>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Current Plan:</strong> ${user.plan ? user.plan.charAt(0).toUpperCase() + user.plan.slice(1) : 'Hobby'}</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription Renewed',
preheader: 'Your subscription has been renewed',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Renewed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription renewed email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendPaymentDisputeCreatedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Payment Dispute Created</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">A payment dispute has been created against your account. We are reviewing the case.</p>
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been temporarily suspended.</strong> We'll notify you once the dispute is resolved.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Payment Dispute Created',
preheader: 'A dispute has been created',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Payment Dispute Created', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send payment dispute created email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendChargeRefundedEmail(user, data) {
if (!user || !user.email) return;
const amount = data.amount || 0;
const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: data.currency || 'USD' }).format(amount / 100);
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #E8F5EC; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#004225" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Refund Processed</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">We have processed a refund for your payment.</p>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Refund Amount</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">${formattedAmount}</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #006B3D; font-size: 14px;">Refunded</span>
</div>
</div>
`;
const html = renderBrandedEmail({
title: 'Refund Processed',
preheader: 'A refund has been processed',
bodyHtml,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Refund Processed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send refund processed email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionCancelledEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Cancelled</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription has been cancelled and you have been downgraded to Hobby plan.</p>
<div class="details-box" style="background: #f9f7f4; border-radius: 10px; padding: 20px; margin: 20px 0; border: 1px solid #e8e4de;">
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e8e4de;">
<span class="detail-label" style="color: #666; font-size: 14px;">Current Plan</span>
<span class="detail-value" style="font-weight: 600; color: #1a1a1a; font-size: 14px;">Hobby</span>
</div>
<div class="detail-row" style="display: flex; justify-content: space-between; padding: 10px 0;">
<span class="detail-label" style="color: #666; font-size: 14px;">Status</span>
<span class="detail-value" style="font-weight: 600; color: #D32F2F; font-size: 14px;">Cancelled</span>
</div>
</div>
<div class="highlight-box" style="background: #E8F5EC; border-left: 4px solid #004225; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Resubscribe anytime</strong> to regain access to all premium features and benefits.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription Cancelled',
preheader: 'Your subscription has been cancelled',
bodyHtml,
buttonText: 'Resubscribe',
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Cancelled', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription cancelled email', { userId: user.id, email: user.email, error: String(err) });
}
}
async function sendSubscriptionPaymentFailedEmail(user, data) {
if (!user || !user.email) return;
const bodyHtml = `
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 72px; height: 72px; background: #FFEBEE; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#D32F2F" style="width: 36px; height: 36px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p style="margin: 0; color: #1a1a1a; font-size: 16px;">Subscription Payment Failed</p>
</div>
<p style="margin-top: 0; margin-bottom: 16px; color: #1a1a1a;">Your subscription payment failed. This could be due to insufficient funds, expired card, or other payment issues.</p>
<div class="highlight-box" style="background: #FFEBEE; border-left: 4px solid #D32F2F; padding: 16px 20px; margin: 16px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1a1a1a;"><strong>Your subscription has been cancelled.</strong> Please update your payment method to reactivate.</p>
</div>
`;
const html = renderBrandedEmail({
title: 'Subscription Payment Failed',
preheader: 'Your subscription payment failed',
bodyHtml,
buttonText: 'Update Payment',
buttonLink: `${PUBLIC_BASE_URL || ''}/settings`,
});
try {
await sendEmail({ to: user.email, subject: 'Plugin Compass — Subscription Payment Failed', text: bodyHtml.replace(/<[^>]*>/g, '').trim(), html });
} catch (err) {
log('failed to send subscription payment failed email', { userId: user.id, email: user.email, error: String(err) });
}
}
function findUserByEmail(email) {
const normalized = (email || '').trim().toLowerCase();
return usersDb.find(u => u.email === normalized);
}
function findUserByProvider(provider, providerId) {
if (!provider || !providerId) return null;
const normalizedProvider = provider.toLowerCase();
return usersDb.find((u) =>
Array.isArray(u.providers) &&
u.providers.some((p) => p.provider === normalizedProvider && p.id === providerId)
);
}
async function upsertOAuthUser(provider, providerId, email, profile = {}) {
const normalizedProvider = (provider || '').toLowerCase();
const normalizedEmail = (email || '').trim().toLowerCase();
if (!normalizedProvider || !providerId) {
throw new Error('OAuth provider and id are required');
}
const now = new Date().toISOString();
let user = findUserByProvider(normalizedProvider, providerId) || (normalizedEmail ? findUserByEmail(normalizedEmail) : null);
if (!user) {
const placeholderPassword = await bcrypt.hash(randomBytes(32).toString('hex'), PASSWORD_SALT_ROUNDS);
user = {
id: randomUUID(),
email: normalizedEmail || `noreply+${providerId}@example.com`,
password: placeholderPassword,
createdAt: now,
lastLoginAt: now,
providers: [],
plan: null,
billingStatus: DEFAULT_BILLING_STATUS,
billingEmail: normalizedEmail,
stripeCustomerId: '',
stripePaymentMethodId: '',
paymentMethodBrand: '',
paymentMethodExpMonth: null,
paymentMethodExpYear: null,
paymentMethodLast4: '',
dodoCustomerId: '',
dodoSubscriptionId: '',
billingCycle: null,
subscriptionCurrency: null,
subscriptionRenewsAt: null,
};
usersDb.push(user);
}
user.providers = Array.isArray(user.providers) ? user.providers : [];
const existingProvider = user.providers.find((p) => p.provider === normalizedProvider && p.id === providerId);
const providerPayload = {
provider: normalizedProvider,
id: providerId,
email: normalizedEmail || existingProvider?.email || '',
name: profile.name || profile.login || existingProvider?.name || '',
avatar: profile.picture || profile.avatar_url || existingProvider?.avatar || '',
};
if (existingProvider) {
Object.assign(existingProvider, providerPayload);
} else {
user.providers.push(providerPayload);
}
if (normalizedEmail && (!user.email || user.email.endsWith('@example.com'))) {
user.email = normalizedEmail;
}
user.lastLoginAt = now;
await persistUsersDb();
log('OAuth user upserted', { provider: normalizedProvider, providerId, userId: user.id, email: user.email });
return user;
}
function findUserById(userId) {
return usersDb.find(u => u.id === userId);
}
async function createUser(email, password, options = {}) {
const normalized = (email || '').trim().toLowerCase();
if (!normalized || !password) {
throw new Error('Email and password are required');
}
if (findUserByEmail(normalized)) {
throw new Error('User already exists with this email');
}
const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS);
const user = {
id: randomUUID(),
email: normalized,
password: hashedPassword,
createdAt: new Date().toISOString(),
lastLoginAt: null,
providers: [],
emailVerified: false,
verificationToken: '',
verificationExpiresAt: null,
resetToken: '',
resetExpiresAt: null,
plan: null,
billingStatus: DEFAULT_BILLING_STATUS,
billingEmail: normalized,
stripeCustomerId: '',
stripePaymentMethodId: '',
paymentMethodBrand: '',
paymentMethodExpMonth: null,
paymentMethodExpYear: null,
paymentMethodLast4: '',
dodoCustomerId: '',
dodoSubscriptionId: '',
billingCycle: null,
subscriptionCurrency: null,
subscriptionRenewsAt: null,
referredByAffiliateCode: sanitizeAffiliateCode(options.referredByAffiliateCode),
affiliateAttributionAt: options.referredByAffiliateCode ? new Date().toISOString() : null,
affiliatePayouts: [],
onboardingCompleted: false,
};
assignVerificationToken(user);
usersDb.push(user);
await persistUsersDb();
log('Created new user', { userId: user.id, email: user.email });
return user;
}
async function verifyUserPassword(email, password) {
const user = findUserByEmail(email);
if (!user || typeof user.password !== 'string' || !user.password.length) return null;
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
// Track failed attempts
user.failedLogins = (user.failedLogins || 0) + 1;
// Lock account after 5 failed attempts
if (user.failedLogins >= 5) {
user.lockedUntil = Date.now() + LOGIN_LOCKOUT_MS;
log('account locked due to failed attempts', { email, failedAttempts: user.failedLogins });
}
await persistUsersDb();
return null;
}
// Update last login
user.lastLoginAt = new Date().toISOString();
user.failedLogins = 0;
user.lockedUntil = null;
await persistUsersDb();
return user;
}
function normalizePlanSelection(plan) {
const candidate = (plan || '').toString().trim().toLowerCase();
return USER_PLANS.includes(candidate) ? candidate : null;
}
function resolveUserPlan(userId) {
const user = findUserById(userId);
return normalizePlanSelection(user?.plan) || DEFAULT_PLAN;
}
function getPlanAppLimit(plan) {
const key = normalizePlanSelection(plan) || DEFAULT_PLAN;
const limit = PLAN_APP_LIMITS[key];
return Number.isFinite(limit) ? limit : Infinity;
}
function isPaidPlan(plan) {
const normalized = (plan || '').toLowerCase();
return PAID_PLANS.has(normalized);
}
function planPriority(plan) {
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
if (normalized === 'enterprise') return 4;
if (normalized === 'professional') return 3;
if (normalized === 'starter') return 2;
return 1; // hobby
}
async function applyPlanPriorityDelay(plan) {
const priority = planPriority(plan);
if (priority === 4) return;
let delayMs = HOBBY_PRIORITY_DELAY_MS;
if (priority === 3) delayMs = BUSINESS_PRIORITY_DELAY_MS;
else if (priority === 2) delayMs = STARTER_PRIORITY_DELAY_MS;
await delay(delayMs);
}
function resolveFallbackModel(cli = 'opencode') {
const configured = getConfiguredModels(cli);
if (Array.isArray(configured) && configured.length) {
return configured[0].name || configured[0].id || 'default';
}
return 'default';
}
function refreshAdminModelIndex() {
const next = new Map();
adminModels.forEach((model) => {
if (!model) return;
const idKey = String(model.id || '').trim();
const nameKey = String(model.name || '').trim();
if (idKey) next.set(`id:${idKey}`, model);
if (nameKey) next.set(`name:${nameKey}`, model);
});
adminModelIndex = next;
}
function getAdminModelByIdOrName(value) {
const key = String(value || '').trim();
if (!key) return null;
return adminModelIndex.get(`id:${key}`) || adminModelIndex.get(`name:${key}`) || null;
}
function resolvePlanModel(plan, requestedModel) {
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
const rawRequested = (requestedModel || '').trim();
const requestedRecord = rawRequested ? getAdminModelByIdOrName(rawRequested) : null;
const normalizedRequested = requestedRecord ? requestedRecord.name : '';
const requested = normalizedRequested || rawRequested;
const requestedTier = getModelTier(requested);
// If user explicitly requests a valid model (not 'auto'), use it
if (requested && requestedTier && requested !== AUTO_MODEL_TOKEN) {
return requested;
}
const candidateList = getConfiguredModels();
// For hobby/free plan users, use the admin-configured auto model
if (normalized === 'hobby') {
const adminDefault = (planSettings.freePlanModel || '').trim();
if (adminDefault) return adminDefault;
const firstModel = candidateList[0];
if (firstModel) return firstModel.name;
return resolveFallbackModel();
}
// For paid plans: respect user's model choice
// If they requested a specific model (even without a tier), use it
// Only fall back to first configured if they didn't request anything or requested 'auto'
if (requested && requested !== AUTO_MODEL_TOKEN) {
return requested;
}
// No specific request or 'auto' requested - use first configured model
if (candidateList.length) {
return candidateList[0].name;
}
return resolveFallbackModel();
}
function addMonthsSafely(date, months) {
const base = new Date(date);
const day = base.getDate();
base.setDate(1);
base.setMonth(base.getMonth() + months);
const lastDay = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate();
base.setDate(Math.min(day, lastDay));
return base;
}
function computeRenewalDate(cycle = 'monthly') {
const now = new Date();
if (cycle === 'yearly') return addMonthsSafely(now, 12).toISOString();
return addMonthsSafely(now, 1).toISOString();
}
async function serializeAccount(user) {
if (!user) return null;
const normalizedPayouts = Array.isArray(user.affiliatePayouts)
? user.affiliatePayouts.map((p) => normalizePlanSelection(p)).filter(Boolean)
: [];
let paymentMethod = null;
if (user.dodoSubscriptionId && DODO_ENABLED) {
try {
const subscription = await dodoRequest(`/subscriptions/${user.dodoSubscriptionId}`, {
method: 'GET',
});
if (subscription && subscription.payment_method) {
const pm = subscription.payment_method;
paymentMethod = {
brand: pm.card?.brand || pm.payment_method || 'Card',
last4: pm.card?.last4 || pm.card?.last_digits || '',
expiresAt: pm.card?.expiry || pm.card?.expires_at || '',
};
}
} catch (error) {
console.error('[SerializeAccount] Failed to fetch payment method:', error.message);
}
}
return {
id: user.id,
email: user.email,
plan: user.plan || DEFAULT_PLAN,
billingStatus: user.billingStatus || DEFAULT_BILLING_STATUS,
billingEmail: user.billingEmail || user.email || '',
subscriptionRenewsAt: user.subscriptionRenewsAt || null,
referredByAffiliateCode: sanitizeAffiliateCode(user.referredByAffiliateCode),
affiliateAttributionAt: user.affiliateAttributionAt || null,
affiliatePayouts: normalizedPayouts,
createdAt: user.createdAt || null,
lastLoginAt: user.lastLoginAt || null,
unlimitedUsage: Boolean(user.unlimitedUsage),
balance: Number(user.balance || 0),
currency: String(user.currency || 'usd').toLowerCase(),
limits: {
uploadZipBytes: MAX_UPLOAD_ZIP_SIZE,
},
tokenUsage: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN),
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<boolean>} True if a cancellation request succeeded.
*/
async function cancelDodoSubscription(user, reason = 'manual', { clearOnFailure = false } = {}) {
if (!user?.dodoSubscriptionId) return false;
const subscriptionId = user.dodoSubscriptionId;
for (const attempt of DODO_SUBSCRIPTION_CANCEL_ATTEMPTS) {
try {
await dodoRequest(`/subscriptions/${subscriptionId}`, attempt);
log('Dodo subscription cancelled', { userId: user.id, subscriptionId, reason, method: attempt.method, mode: attempt.label });
user.dodoSubscriptionId = '';
await persistUsersDb();
return true;
} catch (error) {
log('Failed to cancel Dodo subscription', {
userId: user.id,
subscriptionId,
reason,
method: attempt.method,
mode: attempt.label,
error: String(error)
});
}
}
if (clearOnFailure) {
user.dodoSubscriptionId = '';
await persistUsersDb();
}
return false;
}
/**
* Change an existing Dodo subscription to a different plan using the Change Plan API.
* This properly handles upgrades and downgrades with proration.
* @param {object} user - User record containing dodoSubscriptionId.
* @param {string} newPlan - The new plan to change to (starter, professional, enterprise).
* @param {string} billingCycle - The billing cycle (monthly, yearly).
* @param {string} currency - The currency (usd, gbp, eur).
* @returns {Promise<object>} The change plan response from Dodo.
*/
async function changeDodoSubscriptionPlan(user, newPlan, billingCycle, currency) {
if (!user?.dodoSubscriptionId) {
throw new Error('No active subscription to change');
}
const subscriptionId = user.dodoSubscriptionId;
const product = resolveSubscriptionProduct(newPlan, billingCycle, currency);
if (!product) {
throw new Error('Invalid plan, billing cycle, or currency combination');
}
try {
// Use Dodo's Change Plan API with difference_immediately proration
// This will charge the difference for upgrades and apply credits for downgrades
const result = await dodoRequest(`/subscriptions/${subscriptionId}/change-plan`, {
method: 'POST',
body: {
product_id: product.productId,
quantity: 1,
proration_billing_mode: 'difference_immediately',
},
});
log('Dodo subscription plan changed', {
userId: user.id,
subscriptionId,
oldPlan: user.plan,
newPlan,
billingCycle,
currency,
productId: product.productId,
});
return result;
} catch (error) {
log('Failed to change Dodo subscription plan', {
userId: user.id,
subscriptionId,
oldPlan: user.plan,
newPlan,
error: String(error),
});
throw error;
}
}
async function ensureDodoCustomer(user) {
if (!user) return '';
if (user.dodoCustomerId) return user.dodoCustomerId;
const email = user.billingEmail || user.email || '';
if (!email) return '';
const customers = await dodoRequest('/customers', {
method: 'POST',
body: {
email,
name: email.split('@')[0] || email,
metadata: { userId: String(user.id) },
},
});
const customerId = customers?.customer_id || customers?.id || '';
if (customerId) {
user.dodoCustomerId = customerId;
await persistUsersDb();
}
return customerId;
}
async function ingestUsageEvent({ user, tokens, amountCents, currency, ratePerMillion, plan, month }) {
if (!DODO_ENABLED || !DODO_USAGE_EVENTS_ENABLED) return false;
if (!user || !Number.isFinite(tokens) || tokens <= 0) return false;
if (!Number.isFinite(amountCents) || amountCents <= 0) return false;
const customerId = await ensureDodoCustomer(user);
if (!customerId) return false;
const metadata = {
[DODO_USAGE_EVENT_TOKENS_FIELD]: Math.max(0, Math.round(tokens)),
[DODO_USAGE_EVENT_COST_FIELD]: Math.max(0, Math.round(amountCents)),
currency: String(currency || 'usd').toLowerCase(),
ratePerMillion: Math.max(0, Number(ratePerMillion || 0)),
plan: String(plan || '').toLowerCase(),
month: String(month || ''),
userId: String(user.id),
};
const event = {
event_id: `usage_${randomUUID()}`,
customer_id: customerId,
event_name: DODO_USAGE_EVENT_NAME,
timestamp: new Date().toISOString(),
metadata,
};
await dodoRequest('/events/ingest', {
method: 'POST',
body: { events: [event] },
});
return true;
}
async function refreshDodoProductsCache() {
const now = Date.now();
if (dodoProductCache.items.length && (now - dodoProductCache.fetchedAt) < DODO_PRODUCTS_CACHE_TTL_MS) {
return dodoProductCache;
}
const items = [];
const pageSize = 100;
for (let page = 0; page < 10; page += 1) {
const data = await dodoRequest('/products', {
method: 'GET',
query: { page_size: pageSize, page_number: page },
});
const list = Array.isArray(data?.items) ? data.items : [];
items.push(...list);
if (list.length < pageSize) break;
}
const byId = new Map();
items.forEach((item) => {
if (item?.product_id) byId.set(String(item.product_id), item);
});
dodoProductCache = { fetchedAt: now, items, byId };
return dodoProductCache;
}
async function getDodoProductById(productId) {
if (!productId) return null;
const cache = await refreshDodoProductsCache();
if (cache.byId.has(productId)) return cache.byId.get(productId);
return null;
}
// Subscription product resolution
function resolveSubscriptionProduct(plan, billingCycle, currency) {
const normalizedPlan = String(plan || '').toLowerCase();
const normalizedBilling = String(billingCycle || '').toLowerCase();
const normalizedCurrency = String(currency || '').toLowerCase();
// Only support paid plans for subscriptions
if (!PAID_PLANS.has(normalizedPlan)) return null;
if (!BILLING_CYCLES.includes(normalizedBilling)) return null;
if (!SUPPORTED_CURRENCIES.includes(normalizedCurrency)) return null;
const productKey = `${normalizedPlan}_${normalizedBilling}_${normalizedCurrency}`;
const productId = SUBSCRIPTION_PRODUCT_IDS[productKey];
const price = SUBSCRIPTION_PRICES[productKey];
if (!productId || !price) return null;
return {
plan: normalizedPlan,
billingCycle: normalizedBilling,
currency: normalizedCurrency,
productId,
price,
productKey,
};
}
// Get all available subscription products for a plan
function getAvailableSubscriptionProducts(plan) {
const normalizedPlan = String(plan || '').toLowerCase();
if (!PAID_PLANS.has(normalizedPlan)) return [];
const products = [];
for (const billing of BILLING_CYCLES) {
for (const currency of SUPPORTED_CURRENCIES) {
const product = resolveSubscriptionProduct(normalizedPlan, billing, currency);
if (product) products.push(product);
}
}
return products;
}
// Validate subscription selection
function validateSubscriptionSelection(plan, billingCycle, currency) {
const product = resolveSubscriptionProduct(plan, billingCycle, currency);
return product !== null;
}
function getPlanTokenLimits(plan, userId) {
const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
const base = (planTokenLimits && typeof planTokenLimits[normalized] === 'number') ? planTokenLimits[normalized] : (PLAN_TOKEN_LIMITS[DEFAULT_PLAN] || 0);
const bucket = ensureTokenUsageBucket(userId) || { addOns: 0 };
return Math.max(0, Number(base || 0) + Number(bucket.addOns || 0));
}
function getTokenUsageSummary(userId, plan) {
const bucket = ensureTokenUsageBucket(userId) || { usage: 0, addOns: 0 };
const limit = getPlanTokenLimits(plan, userId);
const used = Math.max(0, Number(bucket.usage || 0));
const remaining = limit > 0 ? Math.max(0, limit - used) : 0;
return {
month: bucket.month || currentMonthKey(),
used,
limit,
remaining,
percent: limit > 0 ? Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))) : 0,
addOn: Math.max(0, Number(bucket.addOns || 0)),
plan: normalizePlanSelection(plan) || DEFAULT_PLAN,
};
}
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);
});
});
}
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)` });
});
}
}
}
// 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 || '');
// 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 = finalOutput;
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(/</g, '\\u003c');
script = `
(() => {
try {
const data = ${payloadJson};
const json = JSON.stringify(data);
localStorage.setItem('wordpress_plugin_ai_user', json);
localStorage.setItem('shopify_ai_user', json);
document.cookie = 'chat_user=' + encodeURIComponent(data.accountId) + '; path=/; SameSite=Lax';
} catch (_) { /* ignore */ }
window.location.href = '${safeNext}';
})();
`;
}
const statusCode = success ? 200 : 400;
res.writeHead(statusCode, { 'Content-Type': 'text/html' });
res.end(`<!doctype html><html><body><p>${safeMessage}</p><script>${script}</script></body></html>`);
}
function renderOAuthError(res, message, next) {
return renderOAuthResultPage(res, { success: false, message: message || 'Unable to sign you in', next: next || '/login' });
}
function renderOAuthSuccess(res, user, token, next, provider) {
// Check if user has selected a plan
const hasPlan = normalizePlanSelection(user?.plan);
const redirectNext = hasPlan ? next : '/select-plan';
return renderOAuthResultPage(res, { success: true, message: 'Signing you in...', next: redirectNext, user, token, provider });
}
async function handleGoogleAuthStart(req, res, url) {
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
return renderOAuthError(res, 'Google sign-in is not configured', url.searchParams.get('next') || '/login');
}
const next = url.searchParams.get('next') || '/apps';
const remember = url.searchParams.get('remember') === 'true' || url.searchParams.get('remember') === '1';
const state = createOAuthState(next, 'google', remember);
const redirectUri = buildRedirectUri(req, 'google');
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
state,
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
});
res.writeHead(302, { Location: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` });
res.end();
}
async function handleGoogleAuthCallback(req, res, url) {
const stateParam = url.searchParams.get('state') || '';
const stateEntry = consumeOAuthState(stateParam, 'google');
const hintNext = sanitizeRedirectPath(url.searchParams.get('next') || '/apps');
const next = stateEntry?.next || hintNext;
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
return renderOAuthError(res, 'Google sign-in is not configured', next);
}
if (!stateEntry) {
return renderOAuthError(res, 'This Google login request has expired. Please try again.', next);
}
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
return renderOAuthError(res, `Google authentication was cancelled: ${escapeHtml(error)}`, next);
}
if (!code) {
return renderOAuthError(res, 'Missing Google authorization code', next);
}
try {
const redirectUri = buildRedirectUri(req, 'google');
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
})
});
const tokenJson = await tokenRes.json().catch(() => ({}));
const accessToken = tokenJson.access_token;
const idToken = tokenJson.id_token;
if (!tokenRes.ok || (!accessToken && !idToken)) {
log('google oauth token exchange failed', { status: tokenRes.status, body: tokenJson });
return renderOAuthError(res, 'Google authentication failed during token exchange', next);
}
let profile = {};
if (idToken) {
profile = decodeJwtPayload(idToken);
}
if (accessToken) {
try {
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (userInfoRes.ok) {
profile = await userInfoRes.json();
}
} catch (err) {
log('google userinfo fetch failed', { err: String(err) });
}
}
const email = (profile?.email || '').toLowerCase();
const providerId = profile?.sub || profile?.id || null;
if (!providerId) {
return renderOAuthError(res, 'Google account did not include an id', next);
}
if (!email) {
return renderOAuthError(res, 'Google account did not return an email address', next);
}
const user = await upsertOAuthUser('google', providerId, email, profile || {});
const token = startUserSession(res, user.id, stateEntry.remember || false);
log('google oauth login success', { userId: user.id, email: user.email, remember: stateEntry.remember });
return renderOAuthSuccess(res, user, token, next, 'google');
} catch (err) {
log('google oauth callback error', { error: String(err) });
return renderOAuthError(res, 'Unexpected error during Google sign-in', next);
}
}
async function handleGithubAuthStart(req, res, url) {
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
return renderOAuthError(res, 'GitHub sign-in is not configured', url.searchParams.get('next') || '/login');
}
const next = url.searchParams.get('next') || '/apps';
const remember = url.searchParams.get('remember') === 'true' || url.searchParams.get('remember') === '1';
const state = createOAuthState(next, 'github', remember);
const redirectUri = buildRedirectUri(req, 'github');
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state
});
res.writeHead(302, { Location: `https://github.com/login/oauth/authorize?${params.toString()}` });
res.end();
}
async function handleGithubAuthCallback(req, res, url) {
const stateParam = url.searchParams.get('state') || '';
const stateEntry = consumeOAuthState(stateParam, 'github');
const hintNext = sanitizeRedirectPath(url.searchParams.get('next') || '/apps');
const next = stateEntry?.next || hintNext;
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
return renderOAuthError(res, 'GitHub sign-in is not configured', next);
}
if (!stateEntry) {
return renderOAuthError(res, 'This GitHub login request has expired. Please try again.', next);
}
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
return renderOAuthError(res, `GitHub authentication was cancelled: ${escapeHtml(error)}`, next);
}
if (!code) {
return renderOAuthError(res, 'Missing GitHub authorization code', next);
}
try {
const redirectUri = buildRedirectUri(req, 'github');
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
})
});
const tokenJson = await tokenRes.json().catch(() => ({}));
const accessToken = tokenJson.access_token;
if (!tokenRes.ok || !accessToken) {
log('github oauth token exchange failed', { status: tokenRes.status, body: tokenJson });
return renderOAuthError(res, 'GitHub authentication failed during token exchange', next);
}
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': OAUTH_USER_AGENT
}
});
const userProfile = await userRes.json().catch(() => ({}));
if (!userRes.ok) {
log('github user profile fetch failed', { status: userRes.status, body: userProfile });
return renderOAuthError(res, 'Could not read your GitHub profile', next);
}
let email = (userProfile.email || '').toLowerCase();
if (!email) {
try {
const emailRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': OAUTH_USER_AGENT
}
});
const emails = await emailRes.json().catch(() => []);
if (Array.isArray(emails)) {
const verifiedEmails = emails.filter((e) => e && e.verified);
const primary = verifiedEmails.find((e) => e.primary) || verifiedEmails[0];
if (primary?.email) {
email = primary.email.toLowerCase();
} else {
return renderOAuthError(res, 'GitHub account must have a verified email address', next);
}
}
} catch (err) {
log('github email fetch failed', { err: String(err) });
}
}
const providerId = userProfile?.id ? String(userProfile.id) : null;
if (!providerId) {
return renderOAuthError(res, 'GitHub account did not include an id', next);
}
if (!email) {
return renderOAuthError(res, 'GitHub account did not return a verified email address', next);
}
const user = await upsertOAuthUser('github', providerId, email, userProfile || {});
const token = startUserSession(res, user.id, stateEntry.remember || false);
log('github oauth login success', { userId: user.id, email: user.email, remember: stateEntry.remember });
return renderOAuthSuccess(res, user, token, next, 'github');
} catch (err) {
log('github oauth callback error', { error: String(err) });
return renderOAuthError(res, 'Unexpected error during GitHub sign-in', next);
}
}
async function handleAdminLogin(req, res) {
if (!ADMIN_USER || !ADMIN_PASSWORD) return sendJson(res, 500, { error: 'Admin credentials not configured' });
try {
const body = await parseJsonBody(req);
const user = (body.username || body.user || '').trim();
const pass = (body.password || body.pass || '').trim();
// Normalize username comparison to be case-insensitive and trim whitespace
const adminUserNormalized = (ADMIN_USER || '').trim().toLowerCase();
const userNormalized = (user || '').trim().toLowerCase();
const clientIp = req.socket?.remoteAddress || 'unknown';
// Check honeypot
if (checkHoneypot(body)) {
log('admin login honeypot triggered', { ip: clientIp });
return sendJson(res, 400, { error: 'Invalid request' });
}
// Check rate limit
const rateLimit = checkLoginRateLimit(clientIp, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts);
if (rateLimit.blocked) {
log('admin login rate limited', { ip: clientIp, retryAfter: rateLimit.retryAfter });
return sendJson(res, 429, {
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter || 60
});
}
if (!adminUserNormalized || !ADMIN_PASSWORD) {
log('admin credentials not configured (runtime)');
return sendJson(res, 500, { error: 'Admin credentials not configured' });
}
// Validate username
if (userNormalized !== adminUserNormalized) {
log('failed admin login', { user: userNormalized, ip: clientIp, reason: 'invalid_username' });
return sendJson(res, 401, { error: 'Incorrect credentials' });
}
// Validate password using bcrypt
let passwordValid = false;
if (adminPasswordHash) {
passwordValid = await bcrypt.compare(pass, adminPasswordHash);
} else {
// Fallback to plaintext comparison if hashing failed at startup
passwordValid = pass === ADMIN_PASSWORD.trim();
}
if (!passwordValid) {
log('failed admin login', { user: userNormalized, ip: clientIp, reason: 'invalid_password' });
return sendJson(res, 401, { error: 'Incorrect credentials' });
}
// Clear failed attempts on success
adminLoginAttempts.delete(clientIp);
const token = startAdminSession(res);
sendJson(res, 200, { ok: true, token, user: ADMIN_USER, expiresAt: Date.now() + ADMIN_SESSION_TTL_MS });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to login' });
}
}
async function handleAdminLogout(req, res) {
const session = getAdminSession(req);
if (session) adminSessions.delete(session.token);
clearAdminSession(res);
sendJson(res, 200, { ok: true });
}
async function handleAdminMe(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
sendJson(res, 200, { ok: true, user: ADMIN_USER, expiresAt: session.expiresAt });
}
async function handleAdminAvailableModels(req, res, cliParam = 'opencode') {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const cli = normalizeCli(cliParam);
const models = await listModels(cli);
sendJson(res, 200, { models });
} catch (error) {
sendJson(res, 500, { error: error.message || 'Failed to load available models' });
}
}
async function handleAdminListIcons(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
const icons = await listAdminIcons();
sendJson(res, 200, { icons });
}
async function handleAdminModelsList(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
const models = (adminModels || []).map((m) => ({
id: m.id,
name: m.name,
label: m.label || m.name,
icon: m.icon || '',
cli: m.cli || 'opencode',
providers: Array.isArray(m.providers) ? m.providers : [],
primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
tier: m.tier || 'free',
multiplier: getTierMultiplier(m.tier || 'free'),
supportsMedia: m.supportsMedia ?? false,
})).sort((a, b) => (a.label || '').localeCompare(b.label || ''));
sendJson(res, 200, { models });
}
async function handleAdminModelUpsert(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
const modelName = (body.name || body.model || '').trim();
const label = (body.label || body.displayName || modelName).trim();
const cli = normalizeCli(body.cli || 'opencode');
const tier = normalizeTier(body.tier);
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
const id = body.id || randomUUID();
const existingIndex = adminModels.findIndex((m) => m.id === id);
const existing = existingIndex >= 0 ? adminModels[existingIndex] : null;
let icon = existing?.icon || '';
if (typeof body.icon === 'string' && body.icon.trim()) {
icon = await normalizeIconPath(body.icon);
if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' });
}
let providers = [];
if (Array.isArray(body.providers)) {
providers = body.providers.map((p, idx) => ({
provider: normalizeProviderName(p.provider || p.name || p.id || 'opencode'),
model: (p.model || p.name || modelName || '').trim() || modelName,
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
})).filter((p) => !!p.model);
} else if (typeof body.provider === 'string') {
const normalized = normalizeProviderName(body.provider);
providers = [{ provider: normalized, model: modelName, primary: true }];
}
if (!providers.length) providers = [{ provider: 'opencode', model: modelName, primary: true }];
const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0].provider;
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
const payload = { id, name: modelName, label: label || modelName, cli, icon, providers, primaryProvider, tier, multiplier: getTierMultiplier(tier), supportsMedia };
if (existingIndex >= 0) adminModels[existingIndex] = { ...adminModels[existingIndex], ...payload };
else adminModels.push(payload);
await persistAdminModels();
sendJson(res, 200, { model: payload, models: getConfiguredModels(cli) });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save model' });
}
}
async function handleAdminModelDelete(req, res, id) {
const session = requireAdminAuth(req, res);
if (!session) return;
const before = adminModels.length;
adminModels = adminModels.filter((m) => m.id !== id);
if (adminModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
await persistAdminModels();
sendJson(res, 200, { ok: true, models: getConfiguredModels('opencode') });
}
async function handleAdminOpenRouterSettingsGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
sendJson(res, 200, openrouterSettings);
}
async function handleAdminMistralSettingsGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
sendJson(res, 200, mistralSettings);
}
async function handleAdminMistralSettingsPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
mistralSettings = {
primaryModel: (body.primaryModel || '').trim(),
backupModel1: (body.backupModel1 || '').trim(),
backupModel2: (body.backupModel2 || '').trim(),
backupModel3: (body.backupModel3 || '').trim(),
};
await persistMistralSettings();
sendJson(res, 200, { ok: true, settings: mistralSettings });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save settings' });
}
}
async function handleAdminPlanSettingsGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
sendJson(res, 200, planSettings);
}
async function handleAdminPlanSettingsPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
if (body.provider && PLANNING_PROVIDERS.includes(normalizeProviderName(body.provider))) {
planSettings.provider = normalizeProviderName(body.provider);
}
if (Array.isArray(body.planningChain)) {
planSettings.planningChain = normalizePlanningChain(body.planningChain);
}
if (typeof body.freePlanModel === 'string') {
planSettings.freePlanModel = body.freePlanModel.trim();
}
if (!planSettings.planningChain.length) {
planSettings.planningChain = defaultPlanningChainFromSettings(planSettings.provider);
}
await persistPlanSettings();
sendJson(res, 200, { ok: true, settings: planSettings });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save settings' });
}
}
// Get current plan token allocations
async function handleAdminPlanTokensGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
sendJson(res, 200, { limits: planTokenLimits });
} catch (error) {
sendJson(res, 500, { error: error.message || 'Unable to fetch plan tokens' });
}
}
// Update plan token allocations
async function handleAdminPlanTokensPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
if (!body || typeof body !== 'object' || typeof body.limits !== 'object') return sendJson(res, 400, { error: 'Invalid payload' });
const incoming = body.limits || {};
const clean = JSON.parse(JSON.stringify(planTokenLimits || {}));
for (const plan of Object.keys(clean)) {
if (incoming[plan] && typeof incoming[plan] === 'number') {
clean[plan] = Math.max(0, Number(incoming[plan] || 0));
}
}
planTokenLimits = clean;
await persistPlanTokenLimits();
sendJson(res, 200, { ok: true, limits: planTokenLimits });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save plan tokens' });
}
}
// Get token rates
async function handleAdminTokenRatesGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
sendJson(res, 200, { rates: tokenRates });
} catch (error) {
sendJson(res, 500, { error: error.message || 'Unable to fetch token rates' });
}
}
// Update token rates
async function handleAdminTokenRatesPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
if (!body || typeof body !== 'object' || typeof body.rates !== 'object') return sendJson(res, 400, { error: 'Invalid payload' });
const incoming = body.rates || {};
const clean = JSON.parse(JSON.stringify(tokenRates || {}));
for (const currency of Object.keys(clean)) {
if (incoming[currency] && typeof incoming[currency] === 'number') {
clean[currency] = Math.max(0, Number(incoming[currency] || 0));
}
}
tokenRates = clean;
await persistTokenRates();
sendJson(res, 200, { ok: true, rates: tokenRates });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save token rates' });
}
}
async function handleAdminProviderLimitsGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const discovery = await discoverProviderModels();
const snapshot = getProviderUsageSnapshot(discovery.providers);
sendJson(res, 200, {
limits: providerLimits.limits,
usage: snapshot,
opencodeBackupModel: providerLimits.opencodeBackupModel || '',
providers: discovery.providers,
providerModels: discovery.providerModels,
});
} catch (error) {
sendJson(res, 500, { error: error.message || 'Unable to load provider limits' });
}
}
async function handleAdminProviderLimitsPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
const provider = normalizeProviderName(body.provider);
if (!provider) return sendJson(res, 400, { error: 'Provider is required' });
const cfg = ensureProviderLimitDefaults(provider);
if (body.scope === 'model') cfg.scope = 'model';
else if (body.scope === 'provider') cfg.scope = 'provider';
const targetModel = (body.model || '').trim();
const target = cfg.scope === 'model' && targetModel ? (cfg.perModel[targetModel] = cfg.perModel[targetModel] || {}) : cfg;
['tokensPerMinute', '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 = `<p style="margin-top:0;">Welcome!</p><p>Please verify your email address by clicking the button below — or copy and paste the link into your browser:</p><p style="word-break:break-all;"><a href="${escapeHtml(link)}">${escapeHtml(link)}</a></p>`;
const html = renderBrandedEmail({ title: 'Verify your email address', preheader: 'Confirm your email to get started', bodyHtml, buttonText: 'Verify email', buttonLink: link });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(html);
}
if (type === 'reset') {
const link = `${resolveBaseUrl(req)}/reset-password?token=${encodeURIComponent(token)}`;
const bodyHtml = `<p style="margin-top:0;">You requested a password reset.</p><p>Reset your password by clicking the button below — or copy and paste the link into your browser:</p><p style="word-break:break-all;"><a href="${escapeHtml(link)}">${escapeHtml(link)}</a></p>`;
const html = renderBrandedEmail({ title: 'Reset your password', preheader: 'Reset access to your Plugin Compass account', bodyHtml, buttonText: 'Reset password', buttonLink: link });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(html);
}
return sendJson(res, 400, { error: 'type must be verification or reset' });
}
if (req.method === 'POST' && pathname === '/api/password/reset') return handlePasswordReset(req, res);
if (req.method === 'POST' && pathname === '/api/apps/upload') {
const userId = requireUserId(req, res, url);
if (!userId) return;
return handleUploadApp(req, res, userId);
}
if (req.method === 'POST' && pathname === '/api/admin/login') return handleAdminLogin(req, res);
if (req.method === 'POST' && pathname === '/api/admin/logout') return handleAdminLogout(req, res);
if (req.method === 'GET' && pathname === '/api/admin/me') return handleAdminMe(req, res);
if (req.method === 'GET' && pathname === '/api/admin/available-models') {
const cliParam = url.searchParams.get('cli');
return handleAdminAvailableModels(req, res, cliParam);
}
if (req.method === 'GET' && pathname === '/api/admin/icons') return handleAdminListIcons(req, res);
if (req.method === 'GET' && pathname === '/api/admin/models') return handleAdminModelsList(req, res);
if (req.method === 'POST' && pathname === '/api/admin/models') return handleAdminModelUpsert(req, res);
if (req.method === 'GET' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/plan-settings') return handleAdminPlanSettingsGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/plan-settings') return handleAdminPlanSettingsPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/plan-tokens') return handleAdminPlanTokensGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/plan-tokens') return handleAdminPlanTokensPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/token-rates') return handleAdminTokenRatesGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/token-rates') return handleAdminTokenRatesPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/provider-limits') return handleAdminProviderLimitsGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/provider-limits') return handleAdminProviderLimitsPost(req, res);
// Admin-only env debug endpoint
if (req.method === 'GET' && pathname === '/api/admin/env-config') return handleAdminEnvConfig(req, res);
if (req.method === 'GET' && pathname === '/api/admin/accounts') return handleAdminAccountsList(req, res);
if (req.method === 'POST' && pathname === '/api/admin/accounts/plan') return handleAdminAccountPlanUpdate(req, res);
if (req.method === 'DELETE' && pathname === '/api/admin/accounts') return handleAdminAccountDelete(req, res);
if (req.method === 'GET' && pathname === '/api/admin/affiliates') return handleAdminAffiliatesList(req, res);
if (req.method === 'DELETE' && pathname === '/api/admin/affiliates') return handleAdminAffiliateDelete(req, res);
if (req.method === 'GET' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalsList(req, res);
if (req.method === 'PUT' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalUpdate(req, res);
if (req.method === 'GET' && pathname === '/api/admin/tracking') return handleAdminTrackingStats(req, res);
if (req.method === 'GET' && pathname === '/api/admin/resources') return handleAdminResources(req, res);
if (req.method === '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('<h1>404 Not Found</h1><p>The page you are looking for does not exist.</p>');
}
}
async function bootstrap() {
process.on('uncaughtException', async (error) => {
log('Uncaught Exception, saving state before exit', { error: String(error), stack: error.stack });
try {
await persistAllState();
} catch (saveError) {
log('Failed to save state during uncaughtException', { error: String(saveError) });
}
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
log('Unhandled Rejection', { reason: String(reason), promise: String(promise) });
});
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
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); });