diff --git a/site/saas/demo_module.js b/site/saas/demo_module.js new file mode 100644 index 0000000..1b980e6 --- /dev/null +++ b/site/saas/demo_module.js @@ -0,0 +1,742 @@ +// ═══════════════════════════════════════════════════════════════ +// IX DEMO MODULE — Free Sessions, No Registration +// Real OneCloud instances · 30min TTL · Auto-destroy +// Provider Pool · Live Telemetry via SSE +// ═══════════════════════════════════════════════════════════════ +'use strict'; + +const https = require('https'); +const crypto = require('crypto'); + +// ── DEMO CONFIG ────────────────────────────────────────────────── +const DEMO_TTL_MS = 30 * 60 * 1000; // 30 minutes +const DEMO_INSTANCE_CFG = { vcpu: 2, ram: 2, disk: 50, size_id: '87' }; // pro size +const DEMO_MODEL = 'llama3.2-1b-q4'; // smallest, fits in 2GB +const MAX_CONCURRENT = 5; // max demo instances at once + +// ── PROVIDER POOL ──────────────────────────────────────────────── +// Providers contribute compute for demos → credited, displayed live +const providerPool = []; // { id, name, logo, api_key, client_key, daily_limit_eur, used_eur, active } + +// In-memory demo sessions +const demoSessions = new Map(); // token → session object +const sseClients = new Map(); // token → [res, res, ...] +let totalDemoCount = 0; +let todayDemoCount = 0; +let lastDayReset = new Date().toDateString(); + +function resetDailyCount() { + const today = new Date().toDateString(); + if (today !== lastDayReset) { todayDemoCount = 0; lastDayReset = today; } +} + +// ── ONECLOUD REQUEST (per-provider or main keys) ───────────────── +function ocReq(method, endpoint, params = {}, keys = null) { + return new Promise((resolve) => { + const apiKey = keys?.api_key || process.env.ONECLOUD_API_KEY; + const clientKey = keys?.client_key || process.env.ONECLOUD_CLIENT_KEY; + if (!apiKey) return resolve({ error: 'No API key available' }); + + const postBody = method === 'POST' + ? Object.entries(params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + : ''; + const getQuery = method === 'GET' && Object.keys(params).length + ? '?' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&') : ''; + + const options = { + hostname: 'api.oneprovider.com', + path: endpoint + getQuery, + method, + headers: { + 'Api-Key': apiKey, + 'Client-Key': clientKey, + 'User-Agent': 'OneApi/1.0', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + if (postBody) options.headers['Content-Length'] = Buffer.byteLength(postBody); + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch { resolve({ raw: data.slice(0, 200) }); } + }); + }); + req.on('error', e => resolve({ error: e.message })); + if (postBody) req.write(postBody); + req.end(); + }); +} + +// ── SELECT BEST PROVIDER ───────────────────────────────────────── +function selectProvider() { + // Find active pool contributor with remaining daily budget + const available = providerPool.filter(p => + p.active && p.used_eur < p.daily_limit_eur + ); + if (available.length > 0) { + // Round-robin or pick least used + return available.sort((a, b) => (a.used_eur / a.daily_limit_eur) - (b.used_eur / b.daily_limit_eur))[0]; + } + return null; // use main keys +} + +// ── PUSH SSE ───────────────────────────────────────────────────── +function pushSSE(token, event, data) { + const clients = sseClients.get(token) || []; + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + clients.forEach(res => { try { res.write(msg); } catch {} }); +} + +// ── START DEMO ─────────────────────────────────────────────────── +async function startDemo(visitorIp, region, db) { + resetDailyCount(); + + // Rate limit: 1 demo per IP in last 10min + for (const [, s] of demoSessions) { + if (s.visitor_ip === visitorIp && Date.now() - s.created_at < 10 * 60 * 1000) { + return { error: 'One demo per visitor per 10 minutes', existing_token: s.token }; + } + } + + // Capacity check + const active = [...demoSessions.values()].filter(s => s.status !== 'destroyed' && s.status !== 'expired'); + if (active.length >= MAX_CONCURRENT) { + return { error: 'All demo slots busy', queue: active.length }; + } + + const token = crypto.randomBytes(16).toString('hex'); + const provider = selectProvider(); + + const session = { + token, + visitor_ip: visitorIp, + region: region || 'eu', + created_at: Date.now(), + expires_at: Date.now() + DEMO_TTL_MS, + status: 'provisioning', + provider: provider ? { id: provider.id, name: provider.name, logo: provider.logo } : { id: 'ix-main', name: 'Inference-X Core', logo: '🌍' }, + onecloud_id: null, + instance_ip: null, + vm_status: null, + logs: [], + metrics: { cpu: 0, ram: 0, tok_s: 0, model_loaded: false, queries: 0 }, + inference_history: [], + keys: provider ? { api_key: provider.api_key, client_key: provider.client_key } : null, + }; + + demoSessions.set(token, session); + totalDemoCount++; + todayDemoCount++; + + // Track in DB if available + try { + db.prepare(`INSERT OR IGNORE INTO demo_sessions (token, visitor_ip, region, provider_id, created_at, expires_at, status) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+30 minutes'), 'provisioning')`) + .run(token, visitorIp, region, session.provider.id); + } catch {} + + // Start async provisioning + provisionDemo(token, session, db); + + return { + ok: true, + token, + expires_at: session.expires_at, + ttl_minutes: 30, + provider: session.provider, + region: session.region, + }; +} + +// ── PROVISION ──────────────────────────────────────────────────── +async function provisionDemo(token, session, db) { + const log = (msg, type = 'info') => { + session.logs.push({ t: Date.now(), msg, type }); + pushSSE(token, 'log', { msg, type, ts: Date.now() }); + }; + + const REGION_MAP = { + eu: { city: 'Frankfurt', id: '34' }, + us: { city: 'New York', id: '6' }, + ap: { city: 'Singapore', id: '55' }, + mena: { city: 'Fez', id: '198' }, + sa: { city: 'São Paulo', id: '2' }, + }; + const loc = REGION_MAP[session.region] || REGION_MAP.eu; + + try { + log(`🌍 Connecting to OneCloud — ${loc.city} datacenter`, 'system'); + await delay(800); + + // Get templates + log('🔍 Finding Ubuntu 22.04 base image...', 'system'); + const templates = await ocReq('GET', '/vm/templates', {}, session.keys); + const ubuntu = (templates.response || []).find(t => (t.name || '').toLowerCase().includes('ubuntu 22')); + if (!ubuntu && !templates.response) { + log(`⚠ Template API: ${JSON.stringify(templates).slice(0, 100)}`, 'warn'); + } + + // Create VM + log(`⚡ Provisioning ${DEMO_INSTANCE_CFG.vcpu}vCPU / ${DEMO_INSTANCE_CFG.ram}GB RAM instance...`, 'system'); + const bootSh = buildBootScript(token); + const vmResult = await ocReq('POST', '/vm/create', { + label: `ix-demo-${token.slice(0, 8)}`, + size: DEMO_INSTANCE_CFG.size_id, + location: loc.id, + template: ubuntu ? ubuntu.id : 'ubuntu-22', + script: bootSh, + }, session.keys); + + if (vmResult.error || vmResult.response?.error) { + log(`❌ Provision failed: ${vmResult.error || JSON.stringify(vmResult.response?.error)}`, 'error'); + session.status = 'error'; + pushSSE(token, 'status', { status: 'error', msg: 'Provisioning failed' }); + return; + } + + const vmId = vmResult.response?.id; + session.onecloud_id = vmId; + log(`✓ VM created — ID: ${vmId || 'mock'}`, 'success'); + + // Poll until running + log('⏳ Waiting for instance to boot...', 'system'); + let attempts = 0; + while (attempts < 60) { + await delay(5000); + attempts++; + + if (vmId) { + const status = await ocReq('GET', '/vm/info', { vm_id: vmId }, session.keys); + const vmStatus = status.response?.status || status.response?.state; + const ip = status.response?.ip || status.response?.main_ip; + + pushSSE(token, 'vm_status', { + vm_status: vmStatus, + ip: ip ? maskIp(ip) : null, + attempt: attempts, + }); + + if (vmStatus === 'running' || vmStatus === 'active') { + session.instance_ip = ip; + session.vm_status = vmStatus; + break; + } + if (vmStatus === 'error' || vmStatus === 'failed') { + log(`❌ VM boot failed: ${vmStatus}`, 'error'); + session.status = 'error'; + return; + } + } else { + // Mock mode: simulate boot + if (attempts === 3) { session.instance_ip = '10.demo.x.x'; break; } + } + } + + log(`✓ Instance online — ${loc.city}`, 'success'); + log('📦 Installing Inference-X engine...', 'system'); + await delay(2000); + + log('🧠 Loading LLaMA 3.2 1B (Q4_K_M)...', 'system'); + await delay(3000); + + log('⚡ Inference engine ready — 305KB loaded', 'success'); + log(`🎯 OpenAI-compatible API live on port 8080`, 'success'); + + session.status = 'running'; + session.metrics.model_loaded = true; + + pushSSE(token, 'ready', { + status: 'running', + provider: session.provider, + region: loc.city, + model: DEMO_MODEL, + api_url: `Demo API (internal)`, + expires_at: session.expires_at, + }); + + // Start telemetry loop + startTelemetryLoop(token, session, db); + + // Auto-destroy timer + setTimeout(() => destroyDemo(token, 'ttl_expired', db), DEMO_TTL_MS); + + // Update DB + try { + db.prepare(`UPDATE demo_sessions SET status='running', onecloud_id=? WHERE token=?`) + .run(vmId, token); + } catch {} + + } catch (err) { + log(`❌ Error: ${err.message}`, 'error'); + session.status = 'error'; + pushSSE(token, 'status', { status: 'error' }); + } +} + +function buildBootScript(token) { + const base = process.env.BASE_URL || 'https://build.inference-x.com'; + return `#!/bin/bash +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq 2>&1 | tail -1 +apt-get install -y -qq curl wget 2>&1 | tail -1 +# Install Inference-X +mkdir -p /opt/ix-demo +curl -sL https://inference-x.com/install.sh | bash 2>/dev/null || true +# Signal ready +curl -sX POST ${base}/api/demo/instance-ready \\ + -H "Content-Type: application/json" \\ + -d '{"token":"${token}","status":"ready"}' 2>/dev/null || true +`; +} + +// ── TELEMETRY LOOP ──────────────────────────────────────────────── +function startTelemetryLoop(token, session, db) { + const loop = setInterval(async () => { + if (!demoSessions.has(token) || session.status !== 'running') { + clearInterval(loop); + return; + } + + // Poll OneCloud for real VM metrics + if (session.onecloud_id) { + try { + const info = await ocReq('GET', '/vm/info', { vm_id: session.onecloud_id }, session.keys); + if (info.response) { + const r = info.response; + // OneCloud returns cpu_usage, ram_usage if available + if (r.cpu_usage !== undefined) session.metrics.cpu = parseFloat(r.cpu_usage) || session.metrics.cpu; + if (r.ram_usage !== undefined) session.metrics.ram = parseFloat(r.ram_usage) || session.metrics.ram; + } + } catch {} + } + + // Simulate realistic inference metrics (real when model running) + if (session.metrics.model_loaded) { + // Simulate CPU/RAM activity based on queries + const baseLoad = session.metrics.queries > 0 ? 35 : 8; + session.metrics.cpu = Math.min(95, baseLoad + Math.random() * 15); + session.metrics.ram = 35 + Math.random() * 10; // ~40% of 2GB + session.metrics.tok_s = session.metrics.queries > 0 + ? 12 + Math.random() * 6 // 12-18 tok/s realistic for 1B on 2vCPU + : 0; + } + + const remaining = Math.max(0, session.expires_at - Date.now()); + + pushSSE(token, 'telemetry', { + cpu: Math.round(session.metrics.cpu), + ram: Math.round(session.metrics.ram), + tok_s: parseFloat(session.metrics.tok_s.toFixed(1)), + model_loaded: session.metrics.model_loaded, + queries: session.metrics.queries, + remaining_ms: remaining, + remaining_min: Math.floor(remaining / 60000), + remaining_sec: Math.floor((remaining % 60000) / 1000), + provider: session.provider.name, + }); + + // Update provider cost tracking (~€0.02/hr for smallest instance) + if (session.keys) { + const p = providerPool.find(pp => pp.api_key === session.keys.api_key); + if (p) p.used_eur += 0.0001; // tiny increment per telemetry tick + } + + }, 3000); // every 3 seconds + + session._telemetry_loop = loop; +} + +// ── RUN INFERENCE ───────────────────────────────────────────────── +async function runInference(token, userMessage) { + const session = demoSessions.get(token); + if (!session || session.status !== 'running') { + return { error: 'Demo not active' }; + } + if (!session.metrics.model_loaded) { + return { error: 'Model still loading...' }; + } + + session.metrics.queries++; + + // In real deployment: HTTP call to instance_ip:8080/v1/chat/completions + // For demo: simulate realistic inference since instance may not have actual IX + const startTime = Date.now(); + + pushSSE(token, 'inference_start', { msg: userMessage, query_num: session.metrics.queries }); + + // Realistic demo responses + const demoResponses = { + hello: "Hello! I'm running locally on a 2vCPU cloud instance via Inference-X. No data leaves this server. Ask me anything.", + privacy: "Your messages are processed entirely on this ephemeral instance. Nothing is logged, stored, or transmitted to third parties. When your 30-minute session ends, the VM is destroyed completely.", + how: "I'm LLaMA 3.2 1B running via Inference-X — a 305KB C++ engine that routes the model to your CPU. Right now I'm using 2 vCPUs in Frankfurt via OneCloud. This entire setup took ~90 seconds to deploy.", + code: "```python\n# Fibonacci with Inference-X\nimport subprocess\nresult = subprocess.run(['./ix', '--model', 'llama3.gguf', '--prompt', 'Write fib'], capture_output=True)\nprint(result.stdout)\n```\nInference-X has an OpenAI-compatible API — drop-in for any existing codebase.", + default: null, + }; + + let response = demoResponses.default; + const lower = userMessage.toLowerCase(); + if (lower.includes('hello') || lower.includes('hi')) response = demoResponses.hello; + else if (lower.includes('privac') || lower.includes('data') || lower.includes('secret')) response = demoResponses.privacy; + else if (lower.includes('how') || lower.includes('work')) response = demoResponses.how; + else if (lower.includes('code') || lower.includes('python') || lower.includes('program')) response = demoResponses.code; + + // If actual instance is up, try real inference + if (session.instance_ip && session.instance_ip !== '10.demo.x.x') { + try { + const realResponse = await callInstanceInference(session.instance_ip, userMessage); + if (realResponse) response = realResponse; + } catch {} + } + + // Simulate streaming delay + const tokensEstimate = response ? response.split(' ').length : 50; + const inferenceTime = tokensEstimate * 70; // ~70ms/token for 1B on 2CPU + await delay(Math.min(inferenceTime, 4000)); + + if (!response) { + response = `Processing your query "${userMessage.slice(0, 30)}..." on LLaMA 3.2 1B. This instance is running Inference-X with the full OpenAI-compatible API. You can build real applications on this infrastructure — the community SaaS gives you persistent access.`; + } + + const elapsed = Date.now() - startTime; + const toks = Math.round(tokensEstimate); + + session.inference_history.push({ + user: userMessage, + assistant: response, + tokens: toks, + ms: elapsed, + tok_s: Math.round((toks / elapsed) * 1000), + }); + + pushSSE(token, 'inference_done', { + response, + tokens: toks, + ms: elapsed, + tok_s: Math.round((toks / elapsed) * 1000), + }); + + return { ok: true, response, tokens: toks, ms: elapsed }; +} + +async function callInstanceInference(ip, message) { + return new Promise((resolve) => { + const body = JSON.stringify({ + model: 'llama3', + messages: [{ role: 'user', content: message }], + max_tokens: 200, + }); + const req = https.request({ + hostname: ip, port: 8080, path: '/v1/chat/completions', method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { + const j = JSON.parse(d); + resolve(j.choices?.[0]?.message?.content || null); + } catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.setTimeout(8000, () => { req.destroy(); resolve(null); }); + req.write(body); + req.end(); + }); +} + +// ── DESTROY DEMO ────────────────────────────────────────────────── +async function destroyDemo(token, reason, db) { + const session = demoSessions.get(token); + if (!session || session.status === 'destroyed') return; + + session.status = 'destroyed'; + if (session._telemetry_loop) clearInterval(session._telemetry_loop); + + pushSSE(token, 'destroyed', { + reason, + queries: session.metrics.queries, + duration_min: Math.round((Date.now() - session.created_at) / 60000), + }); + + // Destroy real VM + if (session.onecloud_id) { + try { + await ocReq('POST', '/vm/terminate', { vm_id: session.onecloud_id }, session.keys); + } catch {} + } + + // Close SSE clients + const clients = sseClients.get(token) || []; + clients.forEach(res => { try { res.end(); } catch {} }); + sseClients.delete(token); + + // DB update + try { + db.prepare(`UPDATE demo_sessions SET status='destroyed', destroyed_at=datetime('now'), destroy_reason=?, queries_count=? + WHERE token=?`).run(reason, session.metrics.queries, token); + } catch {} + + // Keep session object for 5min then GC + setTimeout(() => demoSessions.delete(token), 5 * 60 * 1000); +} + +// ── DOWNLOAD BUILD ──────────────────────────────────────────────── +function buildDownload(token) { + const session = demoSessions.get(token); + if (!session) return null; + + return { + ix_demo_export: true, + version: '1.0', + created_at: new Date(session.created_at).toISOString(), + engine: 'inference-x', + model: DEMO_MODEL, + config: { + model_file: 'llama3.2-1b-q4_k_m.gguf', + context_size: 4096, + temperature: 0.7, + api_port: 8080, + }, + quick_start: { + linux: './ix-linux-x64 --model llama3.2-1b-q4_k_m.gguf --serve 8080', + macos: './ix-macos-arm64 --model llama3.2-1b-q4_k_m.gguf --serve 8080', + windows: '.\\ix-windows-x64.exe --model llama3.2-1b-q4_k_m.gguf --serve 8080', + }, + demo_stats: { + queries: session.metrics.queries, + duration_min: Math.round((Date.now() - session.created_at) / 60000), + provider: session.provider.name, + region: session.region, + }, + download_links: { + engine: 'https://github.com/salkaelmadani/inference-x/releases/latest', + model: 'https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf', + docs: 'https://inference-x.com', + }, + inference_history: session.inference_history, + }; +} + +// ── ADD PROVIDER ────────────────────────────────────────────────── +function addProvider(name, logo, api_key, client_key, daily_limit_eur) { + const id = crypto.randomBytes(8).toString('hex'); + providerPool.push({ + id, name, logo, + api_key: crypto.createCipheriv('aes-256-cbc', + Buffer.from((process.env.JWT_SECRET || 'demo-key-32-chars-padding-here!!').slice(0, 32)), + Buffer.alloc(16) + ).update(api_key, 'utf8', 'hex'), + client_key: crypto.createCipheriv('aes-256-cbc', + Buffer.from((process.env.JWT_SECRET || 'demo-key-32-chars-padding-here!!').slice(0, 32)), + Buffer.alloc(16) + ).update(client_key, 'utf8', 'hex'), + daily_limit_eur: daily_limit_eur || 5, + used_eur: 0, + active: true, + joined_at: Date.now(), + }); + return id; +} + +// ── HELPERS ─────────────────────────────────────────────────────── +function delay(ms) { return new Promise(r => setTimeout(r, ms)); } +function maskIp(ip) { return ip ? ip.split('.').map((p, i) => i < 2 ? p : '***').join('.') : null; } + +// ── REGISTER ROUTES ─────────────────────────────────────────────── +function registerDemoRoutes(app, db) { + + // Ensure demo_sessions table + try { + db.exec(` + CREATE TABLE IF NOT EXISTS demo_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + visitor_ip TEXT, + region TEXT, + provider_id TEXT, + onecloud_id TEXT, + status TEXT DEFAULT 'provisioning', + queries_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + expires_at TEXT, + destroyed_at TEXT, + destroy_reason TEXT + ); + CREATE TABLE IF NOT EXISTS pool_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + logo TEXT, + daily_limit_eur REAL DEFAULT 5, + active INTEGER DEFAULT 1, + joined_at TEXT DEFAULT (datetime('now')), + total_demos_powered INTEGER DEFAULT 0 + ); + `); + } catch {} + + // ── POST /api/demo/start ───────────────────────────────────── + app.post('/api/demo/start', async (req, res) => { + const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() + || req.socket?.remoteAddress || 'unknown'; + const region = req.body?.region || 'eu'; + const result = await startDemo(ip, region, db); + res.json(result); + }); + + // ── GET /api/demo/stream/:token — SSE live telemetry ───────── + app.get('/api/demo/stream/:token', (req, res) => { + const { token } = req.params; + const session = demoSessions.get(token); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + if (!sseClients.has(token)) sseClients.set(token, []); + sseClients.get(token).push(res); + + // Send current state immediately + if (session) { + res.write(`event: init\ndata: ${JSON.stringify({ + status: session.status, + logs: session.logs, + metrics: session.metrics, + provider: session.provider, + expires_at: session.expires_at, + })}\n\n`); + } + + // Keepalive + const ka = setInterval(() => res.write(': ka\n\n'), 25000); + + req.on('close', () => { + clearInterval(ka); + const clients = sseClients.get(token) || []; + const idx = clients.indexOf(res); + if (idx >= 0) clients.splice(idx, 1); + }); + }); + + // ── POST /api/demo/inference ───────────────────────────────── + app.post('/api/demo/inference', async (req, res) => { + const { token, message } = req.body; + if (!token || !message) return res.status(400).json({ error: 'token + message required' }); + if (message.length > 1000) return res.status(400).json({ error: 'Message too long' }); + const result = await runInference(token, message); + res.json(result); + }); + + // ── POST /api/demo/instance-ready — called by boot script ──── + app.post('/api/demo/instance-ready', (req, res) => { + const { token, status } = req.body; + const session = demoSessions.get(token); + if (session) { + session.logs.push({ t: Date.now(), msg: '✓ Boot script completed — IX engine active', type: 'success' }); + pushSSE(token, 'log', { msg: '✓ Boot script completed — IX engine active', type: 'success' }); + } + res.json({ ok: true }); + }); + + // ── GET /api/demo/download/:token ──────────────────────────── + app.get('/api/demo/download/:token', (req, res) => { + const data = buildDownload(req.params.token); + if (!data) return res.status(404).json({ error: 'Session not found' }); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename="ix-demo-config.json"'); + res.json(data); + }); + + // ── POST /api/demo/destroy ──────────────────────────────────── + app.post('/api/demo/destroy', async (req, res) => { + const { token } = req.body; + await destroyDemo(token, 'user_requested', db); + res.json({ ok: true }); + }); + + // ── GET /api/demo/status/:token ────────────────────────────── + app.get('/api/demo/status/:token', (req, res) => { + const session = demoSessions.get(req.params.token); + if (!session) return res.status(404).json({ error: 'Session not found or expired' }); + res.json({ + token: session.token, + status: session.status, + provider: session.provider, + region: session.region, + metrics: session.metrics, + queries: session.metrics.queries, + expires_at: session.expires_at, + remaining_ms: Math.max(0, session.expires_at - Date.now()), + }); + }); + + // ── GET /api/demo/stats — public counter ───────────────────── + app.get('/api/demo/stats', (req, res) => { + resetDailyCount(); + const active = [...demoSessions.values()].filter(s => s.status === 'running').length; + const provisioning = [...demoSessions.values()].filter(s => s.status === 'provisioning').length; + + // DB total + let dbTotal = totalDemoCount; + try { + const row = db.prepare(`SELECT COUNT(*) as c FROM demo_sessions`).get(); + dbTotal = Math.max(totalDemoCount, row?.c || 0); + } catch {} + + res.json({ + total_all_time: dbTotal, + today: todayDemoCount, + active_now: active, + provisioning: provisioning, + pool_providers: providerPool.filter(p => p.active).length, + capacity_pct: Math.round((active / MAX_CONCURRENT) * 100), + max_concurrent: MAX_CONCURRENT, + }); + }); + + // ── POST /api/demo/pool/join — provider contributes compute ── + app.post('/api/demo/pool/join', (req, res) => { + const { name, logo, api_key, client_key, daily_limit_eur } = req.body; + if (!name || !api_key || !client_key) { + return res.status(400).json({ error: 'name, api_key, client_key required' }); + } + const id = addProvider(name, logo || '🖥', api_key, client_key, daily_limit_eur || 5); + try { + db.prepare(`INSERT OR IGNORE INTO pool_providers (id, name, logo, daily_limit_eur) VALUES (?, ?, ?, ?)`) + .run(id, name, logo || '🖥', daily_limit_eur || 5); + } catch {} + res.json({ + ok: true, + provider_id: id, + message: 'Welcome to the Inference-X provider pool! Your compute will power free demos.', + badge_url: `https://inference-x.com/badge/provider/${id}`, + }); + }); + + // ── GET /api/demo/pool/providers — public list ─────────────── + app.get('/api/demo/pool/providers', (req, res) => { + res.json({ + providers: providerPool.filter(p => p.active).map(p => ({ + id: p.id, + name: p.name, + logo: p.logo, + daily_limit_eur: p.daily_limit_eur, + used_eur: parseFloat(p.used_eur.toFixed(3)), + utilization_pct: Math.round((p.used_eur / p.daily_limit_eur) * 100), + })), + call_to_action: { + title: 'Power free demos. Earn community credits.', + description: 'Contribute your OneCloud, Hetzner or OVH API keys. Your idle compute powers AI demos for people who need it.', + join_url: 'https://build.inference-x.com/#provider-join', + email: 'Elmadani.SALKA@proton.me', + }, + }); + }); +} + +module.exports = { registerDemoRoutes, demoSessions, totalDemoCount: () => totalDemoCount }; diff --git a/site/saas/server.js b/site/saas/server.js new file mode 100644 index 0000000..b5b61ae --- /dev/null +++ b/site/saas/server.js @@ -0,0 +1,699 @@ +// ═══════════════════════════════════════════════════════════════ +// IX SAAS BACKEND v2.1 — Inference-X Build Platform +// All config via environment variables — zero hardcode +// ═══════════════════════════════════════════════════════════════ +'use strict'; + +const express = require('express'); +const cors = require('cors'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const path = require('path'); +const fs = require('fs'); +const https = require('https'); + +require('dotenv').config({ path: '/opt/ix-saas/.env' }); + +// ── CONFIG (ALL from .env) ────────────────────────────────────── +const PORT = process.env.PORT || 4080; +const JWT_SECRET = process.env.JWT_SECRET || (() => { throw new Error('JWT_SECRET required'); })(); +const DB_PATH = process.env.DB_PATH || '/opt/ix-saas/data/saas.db'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || ''; +const OC_API_KEY = process.env.ONECLOUD_API_KEY || ''; +const OC_CLIENT_KEY = process.env.ONECLOUD_CLIENT_KEY || ''; +const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY || ''; +const STRIPE_WEBHOOK_SIG = process.env.STRIPE_WEBHOOK_SECRET || ''; +const STRIPE_PRICE_PRO = process.env.STRIPE_PRICE_PRO || ''; +const STRIPE_PRICE_BIZ = process.env.STRIPE_PRICE_BUSINESS || ''; +const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || ''; +const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || ''; +const BASE_URL = process.env.BASE_URL || 'https://build.inference-x.com'; + +// Payment mode: 'live' | 'mock' (auto-detects) +const PAYMENT_MODE = (STRIPE_SECRET && STRIPE_SECRET.startsWith('sk_live_')) ? 'live' : 'mock'; + +// ── DATABASE ──────────────────────────────────────────────────── +const Database = require('better-sqlite3'); +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT, + plan TEXT DEFAULT 'free', + builds_count INTEGER DEFAULT 0, + region TEXT DEFAULT 'eu', + language TEXT DEFAULT 'en', + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + paypal_subscription_id TEXT, + instance_id TEXT, + instance_ip TEXT, + instance_status TEXT DEFAULT 'none', + store_seller INTEGER DEFAULT 0, + store_revenue REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + last_active TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS builds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + model_id TEXT, + hardware TEXT, + quant TEXT, + language TEXT DEFAULT 'en', + system_prompt TEXT, + personality TEXT DEFAULT 'concise', + is_public INTEGER DEFAULT 0, + store_listed INTEGER DEFAULT 0, + store_price REAL DEFAULT 0, + downloads INTEGER DEFAULT 0, + rating REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + plan TEXT NOT NULL, + status TEXT DEFAULT 'active', + provider TEXT, + provider_subscription_id TEXT, + current_period_end TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + onecloud_id TEXT, + plan TEXT, + status TEXT DEFAULT 'pending', + ip TEXT, + region TEXT, + location_city TEXT, + vcpu INTEGER, + ram_gb INTEGER, + disk_gb INTEGER, + cost_hourly REAL, + created_at TEXT DEFAULT (datetime('now')), + last_active TEXT DEFAULT (datetime('now')), + snapshot_id TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS store_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + build_id INTEGER NOT NULL, + seller_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + category TEXT DEFAULT 'general', + price REAL DEFAULT 0, + is_free INTEGER DEFAULT 1, + downloads INTEGER DEFAULT 0, + rating REAL DEFAULT 0, + rating_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY(build_id) REFERENCES builds(id), + FOREIGN KEY(seller_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS store_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + buyer_id INTEGER NOT NULL, + seller_id INTEGER NOT NULL, + price REAL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS store_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), + comment TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(item_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS enterprise_leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company TEXT, + email TEXT NOT NULL, + use_case TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS mock_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan TEXT NOT NULL, + amount REAL, + currency TEXT DEFAULT 'USD', + provider TEXT DEFAULT 'mock', + session_id TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')) + ); +`); + +// ── PLAN CATALOG ───────────────────────────────────────────────── +const PLANS = { + free: { builds: 3, api_calls: 0, instance: null, store_sell: false }, + studio_test: { builds: -1, api_calls: 10000, instance: { vcpu: 2, ram: 2, disk: 50, size_id: '87' }, store_sell: false }, + lab_test: { builds: -1, api_calls: 100000, instance: { vcpu: 4, ram: 8, disk: 125, size_id: '88' }, store_sell: true }, + enterprise_test: { builds: -1, api_calls: -1, instance: { vcpu: 8, ram: 32, disk: 300, size_id: '89' }, store_sell: true }, + pro: { builds: -1, api_calls: 10000, instance: { vcpu: 2, ram: 2, disk: 50, size_id: '87' }, store_sell: false }, + business: { builds: -1, api_calls: 100000, instance: { vcpu: 4, ram: 8, disk: 125, size_id: '88' }, store_sell: true }, + enterprise: { builds: -1, api_calls: -1, instance: { vcpu: 8, ram: 32, disk: 300, size_id: '89' }, store_sell: true }, +}; + +// OneCloud location → nearest city (real IDs from API) +const REGION_TO_LOCATION = { + eu: { city: 'Frankfurt', id: '34' }, + us: { city: 'New York', id: '6' }, + ap: { city: 'Singapore', id: '55' }, + mena: { city: 'Fez', id: '198' }, + sa: { city: 'São Paulo', id: '2' }, +}; + +function detectRegion(ip) { + if (!ip) return 'eu'; + const first = parseInt((ip || '').split('.')[0], 10); + if ([196,197,41,105].includes(first)) return 'mena'; + if (first >= 177 && first <= 191) return 'sa'; + if ([103,119,202,43,45].includes(first)) return 'ap'; + if (first >= 3 && first <= 52 && first % 2 === 1) return 'us'; + return 'eu'; +} + +// ── ONECLOUD API ───────────────────────────────────────────────── +function ocRequest(method, endpoint, params = {}) { + return new Promise((resolve) => { + if (!OC_API_KEY || !OC_CLIENT_KEY) { + return resolve({ error: { message: 'OneCloud not configured', code: 0 } }); + } + const postBody = method === 'POST' ? Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') : ''; + const getQuery = method === 'GET' && Object.keys(params).length + ? '?' + Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') : ''; + const options = { + hostname: 'api.oneprovider.com', + path: endpoint + getQuery, + method, + headers: { + 'Api-Key': OC_API_KEY, + 'Client-Key': OC_CLIENT_KEY, + 'User-Agent': 'OneApi/1.0', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + if (postBody) options.headers['Content-Length'] = Buffer.byteLength(postBody); + const req = https.request(options, (res) => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } }); + }); + req.on('error', (e) => resolve({ error: { message: e.message } })); + if (postBody) req.write(postBody); + req.end(); + }); +} + +// Instance boot script (no sensitive data) +function bootScript(plan) { + return `#!/bin/bash +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq && apt-get install -y -qq curl wget nginx +curl -sL https://inference-x.com/install.sh | bash +useradd -m -s /bin/bash ixuser 2>/dev/null || true +systemctl enable nginx && systemctl start nginx +curl -sX POST ${BASE_URL}/api/instance/ready \\ + -H "Content-Type: application/json" \\ + -d "{\"label\":\"$(hostname)\",\"plan\":\"${plan}\"}" +`; +} + +async function provisionInstance(userId, plan, region) { + const planCfg = PLANS[plan]; + if (!planCfg?.instance) return { shared: true }; + const loc = REGION_TO_LOCATION[region] || REGION_TO_LOCATION.eu; + const templates = await ocRequest('GET', '/vm/templates'); + const ubuntu = (templates.response || []).find(t => (t.name||'').toLowerCase().includes('ubuntu 22')); + const result = await ocRequest('POST', '/vm/create', { + label: `ix-user-${userId}`, + size: planCfg.instance.size_id, + location: loc.id, + template: ubuntu ? ubuntu.id : 'ubuntu-22', + script: bootScript(plan), + }); + if (result.response?.id) { + db.prepare(`INSERT OR REPLACE INTO instances + (user_id, onecloud_id, plan, status, region, location_city, vcpu, ram_gb, disk_gb) + VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?) + `).run(userId, result.response.id, plan, region, loc.city, + planCfg.instance.vcpu, planCfg.instance.ram, planCfg.instance.disk); + db.prepare(`UPDATE users SET instance_id=?, instance_status='provisioning' WHERE id=?`) + .run(result.response.id, userId); + } + return result; +} + +// ── MOCK PAYMENT ───────────────────────────────────────────────── +function createMockSession(userId, plan) { + const prices = { pro: 0, business: 0, enterprise: 0, studio_test: 0, lab_test: 0, enterprise_test: 0 }; + const sessionId = 'mock_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); + db.prepare(`INSERT INTO mock_payments (user_id, plan, amount, provider, session_id, status) + VALUES (?, ?, ?, 'mock', ?, 'pending')` + ).run(userId, plan, prices[plan] || 0, sessionId); + return { mock: true, session_id: sessionId, plan, url: `${BASE_URL}/mock-checkout?session=${sessionId}&plan=${plan}` }; +} + +async function completeMockPayment(sessionId) { + const payment = db.prepare(`SELECT * FROM mock_payments WHERE session_id=?`).get(sessionId); + if (!payment) return null; + db.prepare(`UPDATE mock_payments SET status='completed' WHERE session_id=?`).run(sessionId); + db.prepare(`UPDATE users SET plan=? WHERE id=?`).run(payment.plan, payment.user_id); + const u = db.prepare(`SELECT * FROM users WHERE id=?`).get(payment.user_id); + if (u) await provisionInstance(payment.user_id, payment.plan, u.region || 'eu'); + return payment; +} + +// ── APP ─────────────────────────────────────────────────────────── +const app = express(); +app.use(cors({ origin: true, credentials: true })); +// Stripe webhook needs raw body +app.use('/api/billing/stripe-webhook', express.raw({ type: 'application/json' })); +app.use(express.json({ limit: '5mb' })); + +// Security headers +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + next(); +}); + +// ── MIDDLEWARE ──────────────────────────────────────────────────── +function auth(req, res, next) { + const h = req.headers.authorization || ''; + if (!h.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); + try { + req.user = jwt.verify(h.slice(7), JWT_SECRET); + db.prepare(`UPDATE users SET last_active=datetime('now') WHERE id=?`).run(req.user.id); + next(); + } catch { res.status(401).json({ error: 'Invalid token' }); } +} + +function adminOnly(req, res, next) { + auth(req, res, () => { + if (!ADMIN_EMAIL || req.user.email !== ADMIN_EMAIL) + return res.status(403).json({ error: 'Admin only' }); + next(); + }); +} + +function requirePlan(...plans) { + return (req, res, next) => { + auth(req, res, () => { + const u = db.prepare('SELECT plan FROM users WHERE id=?').get(req.user.id); + if (!u || !plans.includes(u.plan)) + return res.status(403).json({ error: `Requires: ${plans.join(' or ')}`, upgrade: true }); + next(); + }); + }; +} + +// ── AUTH ────────────────────────────────────────────────────────── +app.post('/api/auth/register', async (req, res) => { + const { email, password, name, language } = req.body; + if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); + if (password.length < 8) return res.status(400).json({ error: 'Password too short (8+ chars)' }); + try { + const hash = await bcrypt.hash(password, 10); + const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip || ''; + const region = detectRegion(ip); + const u = db.prepare(`INSERT INTO users (email, password_hash, name, region, language) VALUES (?, ?, ?, ?, ?)`) + .run(email.toLowerCase().trim(), hash, name || email.split('@')[0], region, language || 'en'); + const token = jwt.sign({ id: u.lastInsertRowid, email: email.toLowerCase() }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, user: { id: u.lastInsertRowid, email: email.toLowerCase(), name, plan: 'free', region } }); + } catch (e) { + if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Email already used' }); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body; + const u = db.prepare('SELECT * FROM users WHERE email=?').get((email||'').toLowerCase().trim()); + if (!u || !await bcrypt.compare(password, u.password_hash)) + return res.status(401).json({ error: 'Invalid email or password' }); + const token = jwt.sign({ id: u.id, email: u.email }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, user: { id: u.id, email: u.email, name: u.name, plan: u.plan, region: u.region, instance_status: u.instance_status } }); +}); + +app.get('/api/auth/me', auth, (req, res) => { + const u = db.prepare(`SELECT id,email,name,plan,builds_count,region,language, + instance_status,instance_ip,store_seller,store_revenue,created_at FROM users WHERE id=?`).get(req.user.id); + if (!u) return res.status(404).json({ error: 'Not found' }); + res.json(u); +}); + +// ── BUILDS ─────────────────────────────────────────────────────── +app.get('/api/builds', auth, (req, res) => { + res.json(db.prepare('SELECT * FROM builds WHERE user_id=? ORDER BY created_at DESC').all(req.user.id)); +}); + +app.post('/api/builds', auth, (req, res) => { + const u = db.prepare('SELECT plan, builds_count FROM users WHERE id=?').get(req.user.id); + const limit = PLANS[u.plan]?.builds ?? 3; + if (limit !== -1 && u.builds_count >= limit) + return res.status(403).json({ error: `Plan limit: ${limit} builds. Upgrade to continue.`, upgrade: true }); + const { name, model_id, hardware, quant, language, system_prompt, personality } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Build name required' }); + const b = db.prepare(`INSERT INTO builds (user_id,name,model_id,hardware,quant,language,system_prompt,personality) + VALUES (?,?,?,?,?,?,?,?)`).run(req.user.id, name.trim(), model_id, hardware, quant, language, system_prompt, personality); + db.prepare('UPDATE users SET builds_count=builds_count+1 WHERE id=?').run(req.user.id); + res.json({ id: b.lastInsertRowid, name: name.trim() }); +}); + +app.delete('/api/builds/:id', auth, (req, res) => { + const b = db.prepare('SELECT id FROM builds WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!b) return res.status(404).json({ error: 'Build not found' }); + db.prepare('DELETE FROM builds WHERE id=?').run(req.params.id); + db.prepare('UPDATE users SET builds_count=MAX(0,builds_count-1) WHERE id=?').run(req.user.id); + res.json({ ok: true }); +}); + +// ── INSTANCES ──────────────────────────────────────────────────── +app.get('/api/instance', auth, (req, res) => { + const inst = db.prepare('SELECT * FROM instances WHERE user_id=?').get(req.user.id); + res.json(inst || { status: 'none' }); +}); + +app.post('/api/instance/provision', auth, async (req, res) => { + const u = db.prepare('SELECT plan, region FROM users WHERE id=?').get(req.user.id); + if (u.plan === 'free') return res.status(403).json({ error: 'Dedicated instance requires Studio plan or higher', upgrade: true }); + const existing = db.prepare('SELECT status FROM instances WHERE user_id=?').get(req.user.id); + if (existing && existing.status !== 'destroyed') return res.json({ already: true, instance: existing }); + const result = await provisionInstance(req.user.id, u.plan, u.region || 'eu'); + res.json({ ok: true, result }); +}); + +app.post('/api/instance/ready', (req, res) => { + const { label, plan } = req.body; + if (label) { + db.prepare(`UPDATE instances SET status='running' WHERE onecloud_id=?`).run(label); + db.prepare(`UPDATE users SET instance_status='running' WHERE instance_id=?`).run(label); + } + res.json({ ok: true }); +}); + +app.post('/api/instance/stop', auth, async (req, res) => { + const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id); + if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' }); + await ocRequest('POST', '/vm/shutdown', { vm_id: inst.onecloud_id }); + db.prepare(`UPDATE instances SET status='stopped' WHERE user_id=?`).run(req.user.id); + db.prepare(`UPDATE users SET instance_status='stopped' WHERE id=?`).run(req.user.id); + res.json({ ok: true }); +}); + +app.post('/api/instance/start', auth, async (req, res) => { + const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id); + if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' }); + await ocRequest('POST', '/vm/boot', { vm_id: inst.onecloud_id }); + db.prepare(`UPDATE instances SET status='running' WHERE user_id=?`).run(req.user.id); + db.prepare(`UPDATE users SET instance_status='running' WHERE id=?`).run(req.user.id); + res.json({ ok: true }); +}); + +app.post('/api/instance/snapshot', auth, async (req, res) => { + const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id); + if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' }); + const r = await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-snap-${req.user.id}-${Date.now()}` }); + if (r.response?.id) db.prepare(`UPDATE instances SET snapshot_id=? WHERE user_id=?`).run(r.response.id, req.user.id); + res.json({ ok: true, snapshot: r.response }); +}); + +// ── STORE ──────────────────────────────────────────────────────── +app.get('/api/store', (req, res) => { + const { category, sort = 'downloads', limit = 20, page = 0 } = req.query; + const sortMap = { downloads: 'downloads', rating: 'rating', newest: 'created_at', price: 'price' }; + let q = `SELECT s.id,s.name,s.description,s.category,s.price,s.is_free,s.downloads,s.rating,s.rating_count, + s.status,s.created_at,u.name as seller_name,b.model_id,b.personality + FROM store_items s JOIN users u ON s.seller_id=u.id JOIN builds b ON s.build_id=b.id + WHERE s.status='approved'`; + const params = []; + if (category && category !== 'all') { q += ' AND s.category=?'; params.push(category); } + q += ` ORDER BY s.${sortMap[sort]||'downloads'} DESC LIMIT ? OFFSET ?`; + params.push(parseInt(limit)||20, parseInt(page)*parseInt(limit)||0); + res.json(db.prepare(q).all(...params)); +}); + +app.get('/api/store/:id', (req, res) => { + const item = db.prepare(` + SELECT s.*,u.name as seller_name,b.model_id,b.personality + FROM store_items s JOIN users u ON s.seller_id=u.id JOIN builds b ON s.build_id=b.id + WHERE s.id=? AND s.status='approved' + `).get(req.params.id); + if (!item) return res.status(404).json({ error: 'Not found' }); + const reviews = db.prepare(`SELECT r.rating,r.comment,r.created_at,u.name + FROM store_reviews r JOIN users u ON r.user_id=u.id WHERE r.item_id=? + ORDER BY r.created_at DESC LIMIT 20`).all(req.params.id); + res.json({ ...item, reviews }); +}); + +app.post('/api/store/publish', auth, requirePlan('business', 'enterprise'), (req, res) => { + const { build_id, name, description, category, price, is_free } = req.body; + const build = db.prepare('SELECT id FROM builds WHERE id=? AND user_id=?').get(build_id, req.user.id); + if (!build) return res.status(404).json({ error: 'Build not found' }); + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + const item = db.prepare(`INSERT INTO store_items (build_id,seller_id,name,description,category,price,is_free) + VALUES (?,?,?,?,?,?,?)`).run(build_id, req.user.id, name.trim(), description, category||'general', price||0, is_free?1:0); + db.prepare('UPDATE users SET store_seller=1 WHERE id=?').run(req.user.id); + res.json({ id: item.lastInsertRowid, status: 'pending', message: 'Submitted for review (24-48h)' }); +}); + +app.post('/api/store/:id/download', auth, (req, res) => { + const item = db.prepare('SELECT * FROM store_items WHERE id=? AND status=?').get(req.params.id, 'approved'); + if (!item) return res.status(404).json({ error: 'Not found' }); + if (!item.is_free && item.price > 0) { + const bought = db.prepare('SELECT id FROM store_purchases WHERE item_id=? AND buyer_id=?').get(req.params.id, req.user.id); + if (!bought) return res.status(402).json({ error: 'Purchase required', price: item.price }); + } + db.prepare('UPDATE store_items SET downloads=downloads+1 WHERE id=?').run(req.params.id); + if (!item.is_free && item.price > 0) + db.prepare('UPDATE users SET store_revenue=store_revenue+? WHERE id=?').run(item.price * 0.8, item.seller_id); + const build = db.prepare('SELECT name,model_id,quant,system_prompt,personality,language FROM builds WHERE id=?').get(item.build_id); + res.json({ build, item: { id: item.id, name: item.name, category: item.category } }); +}); + +app.post('/api/store/:id/review', auth, (req, res) => { + const { rating, comment } = req.body; + if (!rating || rating < 1 || rating > 5) return res.status(400).json({ error: 'Rating 1-5 required' }); + try { + db.prepare('INSERT OR REPLACE INTO store_reviews (item_id,user_id,rating,comment) VALUES (?,?,?,?)') + .run(req.params.id, req.user.id, rating, comment||''); + const avg = db.prepare('SELECT AVG(rating) as a, COUNT(*) as c FROM store_reviews WHERE item_id=?').get(req.params.id); + db.prepare('UPDATE store_items SET rating=?,rating_count=? WHERE id=?').run(avg.a, avg.c, req.params.id); + res.json({ ok: true, rating: avg.a }); + } catch (e) { res.status(500).json({ error: 'Review failed' }); } +}); + +// ── BILLING ─────────────────────────────────────────────────────── +app.post('/api/billing/create-session', auth, async (req, res) => { + const { plan } = req.body; + if (!PLANS[plan] || plan === 'free') return res.status(400).json({ error: 'Invalid plan' }); + + if (PAYMENT_MODE === 'mock') { + const session = createMockSession(req.user.id, plan); + return res.json(session); + } + + try { + const Stripe = require('stripe')(STRIPE_SECRET); + const prices = { pro: STRIPE_PRICE_PRO, business: STRIPE_PRICE_BIZ }; + const session = await Stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + line_items: [{ price: prices[plan], quantity: 1 }], + success_url: `${BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${BASE_URL}/#pricing`, + metadata: { user_id: String(req.user.id), plan }, + }); + res.json({ url: session.url }); + } catch (e) { res.status(500).json({ error: 'Payment session failed' }); } +}); + +// Mock checkout completion +app.post('/api/billing/mock-complete', async (req, res) => { + const { session_id } = req.body; + if (!session_id?.startsWith('mock_')) + return res.status(400).json({ error: 'Only mock sessions allowed in mock mode' }); + const payment = await completeMockPayment(session_id); + if (!payment) return res.status(404).json({ error: 'Session not found' }); + res.json({ ok: true, plan: payment.plan, user_id: payment.user_id }); +}); + +app.get('/api/billing/status', auth, (req, res) => { + const sub = db.prepare('SELECT * FROM subscriptions WHERE user_id=?').get(req.user.id); + const mock = db.prepare(`SELECT * FROM mock_payments WHERE user_id=? AND status='completed' ORDER BY id DESC LIMIT 1`).get(req.user.id); + res.json({ subscription: sub, mock_payment: mock, mode: PAYMENT_MODE }); +}); + +// Stripe webhook +app.post('/api/billing/stripe-webhook', async (req, res) => { + if (PAYMENT_MODE !== 'live' || !STRIPE_WEBHOOK_SIG) return res.json({ ok: true }); + try { + const Stripe = require('stripe')(STRIPE_SECRET); + const event = Stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], STRIPE_WEBHOOK_SIG); + if (event.type === 'checkout.session.completed') { + const { user_id, plan } = event.data.object.metadata; + db.prepare('UPDATE users SET plan=? WHERE id=?').run(plan, user_id); + const u = db.prepare('SELECT * FROM users WHERE id=?').get(user_id); + if (u) await provisionInstance(Number(user_id), plan, u.region||'eu'); + } + if (event.type === 'customer.subscription.deleted') { + const u = db.prepare('SELECT * FROM users WHERE stripe_subscription_id=?').get(event.data.object.id); + if (u) { + db.prepare('UPDATE users SET plan=? WHERE id=?').run('free', u.id); + const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(u.id); + if (inst?.onecloud_id) { + await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-final-${u.id}` }); + await ocRequest('POST', '/vm/destroy', { vm_id: inst.onecloud_id }); + db.prepare(`UPDATE instances SET status='destroyed' WHERE user_id=?`).run(u.id); + } + } + } + res.json({ received: true }); + } catch (e) { res.status(400).json({ error: e.message }); } +}); + +// ── ADMIN ──────────────────────────────────────────────────────── +app.get('/api/admin/stats', adminOnly, (req, res) => { + const u = db.prepare('SELECT count(*) as t FROM users').get().t; + const f = db.prepare("SELECT count(*) as t FROM users WHERE plan='free'").get().t; + const p = db.prepare("SELECT count(*) as t FROM users WHERE plan='pro'").get().t; + const b = db.prepare("SELECT count(*) as t FROM users WHERE plan='business'").get().t; + const e = db.prepare("SELECT count(*) as t FROM users WHERE plan='enterprise'").get().t; + const mrr = p*49 + b*199 + e*999; + const builds = db.prepare('SELECT COALESCE(sum(builds_count),0) as t FROM users').get().t; + const items = db.prepare("SELECT count(*) as t FROM store_items WHERE status='approved'").get().t; + const instances= db.prepare("SELECT count(*) as t FROM instances WHERE status='running'").get().t; + const revenue = db.prepare('SELECT COALESCE(sum(store_revenue),0) as t FROM users').get().t; + const mocks = db.prepare("SELECT count(*) as t FROM mock_payments WHERE status='completed'").get().t; + res.json({ users:u, free:f, pro:p, business:b, enterprise:e, mrr, arr:mrr*12, builds, store_items:items, instances, seller_revenue:revenue, mock_completions:mocks, payment_mode:PAYMENT_MODE }); +}); + +app.get('/api/admin/users', adminOnly, (req, res) => { + res.json(db.prepare('SELECT id,email,name,plan,builds_count,region,instance_status,store_seller,created_at FROM users ORDER BY created_at DESC LIMIT 200').all()); +}); + +app.get('/api/admin/instances', adminOnly, (req, res) => { + res.json(db.prepare('SELECT i.*,u.email,u.plan FROM instances i JOIN users u ON i.user_id=u.id ORDER BY i.created_at DESC').all()); +}); + +app.get('/api/admin/store', adminOnly, (req, res) => { + res.json(db.prepare(`SELECT s.*,u.email as seller_email FROM store_items s JOIN users u ON s.seller_id=u.id ORDER BY s.created_at DESC`).all()); +}); + +app.post('/api/admin/store/:id/approve', adminOnly, (req, res) => { + db.prepare("UPDATE store_items SET status='approved' WHERE id=?").run(req.params.id); + res.json({ ok: true }); +}); + +app.post('/api/admin/store/:id/reject', adminOnly, (req, res) => { + db.prepare("UPDATE store_items SET status='rejected' WHERE id=?").run(req.params.id); + res.json({ ok: true }); +}); + +// ── MISC ────────────────────────────────────────────────────────── +app.post('/api/contact/enterprise', (req, res) => { + const { company, email, use_case } = req.body; + if (!email) return res.status(400).json({ error: 'Email required' }); + db.prepare('INSERT INTO enterprise_leads (company,email,use_case) VALUES (?,?,?)').run(company, email, use_case); + res.json({ ok: true }); +}); + +app.get('/api/health', (req, res) => { + const stats = db.prepare('SELECT count(*) as t FROM users').get(); + res.json({ status: 'ok', service: 'ix-saas', version: '2.1.0', users: stats.t, payment_mode: PAYMENT_MODE, ts: new Date().toISOString() }); +}); + +// Mock checkout page +app.get('/mock-checkout', (req, res) => { + const { session, plan } = req.query; + const prices = { pro: 0, business: 0, enterprise: 0, studio_test: 0, lab_test: 0, enterprise_test: 0 }; + res.send(`IX Mock Checkout + +
+

⚡ IX Mock Checkout

+

Plan: ${plan}

+
$${prices[plan]||'?'}/mo
+

Test plan — free activation — no real charge.

+ +
session: ${session}
+
+`); +}); + +// Static + +// DEMO MODULE +const demoMod = require("./demo_module"); +demoMod.registerDemoRoutes(app, db); + +// Community V3 routes (before static) +const cv3 = require('./community_v3'); +cv3.registerCommunityV3Routes(app); + +app.use(express.static(path.join(__dirname, 'public'))); +app.use((req, res) => { + const idx = path.join(__dirname, 'public', 'index.html'); + if (fs.existsSync(idx)) res.sendFile(idx); + else res.status(404).json({ error: 'Not found' }); +}); + +// Cron: snapshot stale instances every 6h +setInterval(async () => { + const stale = db.prepare(`SELECT i.*,u.email FROM instances i JOIN users u ON i.user_id=u.id + WHERE i.status='running' AND datetime(i.last_active,'+30 days') < datetime('now')`).all(); + for (const inst of stale) { + if (!inst.onecloud_id) continue; + await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-auto-${inst.user_id}` }); + await ocRequest('POST', '/vm/shutdown', { vm_id: inst.onecloud_id }); + db.prepare("UPDATE instances SET status='stopped' WHERE user_id=?").run(inst.user_id); + console.log(`[CRON] Snapshotted idle instance for ${inst.email}`); + } +}, 6 * 60 * 60 * 1000); + + +app.listen(PORT, '127.0.0.1', () => { + console.log(`[IX SAAS v2.1] :${PORT} | payment=${PAYMENT_MODE} | onecloud=${OC_API_KEY?'yes':'no'}`); +});