Files
rm-circle-team-router/server.js
T
martbost c1ec687768 Let admins enable AI chat by pasting an OpenRouter key in the admin panel
Key is stored on the persistent volume (0600, never in git), read
dynamically per request, maskable status only in admin state, and
clearable to fall back to built-in answers. OPENROUTER_API_KEY env var
still takes precedence if ever set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:17:39 -05:00

220 lines
19 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
const SEED_DIR = path.join(ROOT, 'seed');
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
const SESSION_TTL = 8 * 60 * 60 * 1000;
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key');
function getOpenRouterKey() {
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
}
const chatHits = new Map();
function chatRateLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip);
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
rec.count++; return rec.count > 10;
}
function chatSystemPrompt() {
const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors);
const waiting = sponsors.filter(s=>s.status==='waiting').length;
return `You are "Team Help", the assistant on ${c.siteName || 'Crypto Team Build'} (https://rmcircle.saasy.top), the team site for the RM Circle Premium team build — a project of the Crypto Team Build Network.
FACTS:
- Strategy: enter RM Circle dApp at Premium tier (${c.premiumEntryPol || 362} POL on Polygon Mainnet, chain ID 137, POL is the gas token). Each member gets EXACTLY 2 directs, then the position is "qualified" and its referral link is retired. The team then helps those 2 get their 2 (moving-link strategy). Build depth, not width. Never add extra directs to a qualified link.
- First team goal: 30 properly placed positions (2+4+8+16), then 32, 64, 128 and beyond.
- 8 Premium levels in order: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona. Everyone starts at Scintilla. Upgrade as quickly as practical, ideally with earned POL; the first two payments at each level help fund the next upgrade. Stay aware of your active downline's levels so you don't fall behind and miss payments.
- Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.saasy.top/start right before joining.
- Site pages: https://rmcircle.saasy.top/ (strategy overview + roadmap), https://rmcircle.saasy.top/start (current sponsor + join steps), https://rmcircle.saasy.top/training (4 videos: 1. How the team build works, 2. Create your MetaMask wallet, 3. Funding your wallet, 4. Buying the Premium position).
- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
RULES:
- Keep answers short: 1-4 sentences, plain text, no markdown formatting. Include full URLs when pointing to a page.
- NEVER promise, estimate, or imply earnings or income. If asked about returns/profit, say results depend on team effort, duplication, upgrades, smart-contract rules and POL's market value, that no income is guaranteed, and to only use funds they can afford to lose.
- NEVER ask for or discuss handling anyone's Secret Recovery Phrase or private keys except to warn they must never share them with anyone.
- Only answer questions about this project, the site, wallets/POL as they relate to joining, and the team process. For anything else, or anything you are not sure about, say you're not sure and point them to the Telegram group: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
- Never give financial, legal, or tax advice.`;
}
async function handleChat(req, res) {
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' });
const apiKey = getOpenRouterKey();
if (!apiKey) return json(res, 200, { fallback: true });
const b = await bodyJson(req).catch(()=>null);
if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' });
const msgs = b.messages.slice(-8)
.filter(m=>m&&(m.role==='user'||m.role==='assistant')&&typeof m.content==='string')
.map(m=>({ role: m.role, content: m.content.slice(0, 500) }));
if (!msgs.length || msgs[msgs.length-1].role !== 'user') return json(res, 400, { error: 'Invalid request' });
try {
const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000);
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', signal: ctrl.signal,
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' },
body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] })
});
clearTimeout(timer);
if (!r.ok) { console.error('openrouter status', r.status); return json(res, 200, { fallback: true }); }
const d = await r.json();
const reply = d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content;
if (!reply) return json(res, 200, { fallback: true });
return json(res, 200, { reply: String(reply).trim().slice(0, 2000) });
} catch (e) { console.error('openrouter error', e.message); return json(res, 200, { fallback: true }); }
}
const sessions = new Map();
function ensureDataFile(name) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const target = path.join(DATA_DIR, name);
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
}
ensureDataFile('sponsors.json');
ensureDataFile('config.json');
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
if (!fs.existsSync(ANALYTICS_FILE)) fs.writeFileSync(ANALYTICS_FILE, JSON.stringify({ sources: {} }, null, 2));
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
function writeJson(file, data) {
const temp = `${file}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
fs.renameSync(temp, file);
}
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
function getConfig() { return readJson(CONFIG_FILE); }
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
function recordEvent(event, source) {
if (!['bridge','start','click','training'].includes(event)) return;
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
const a = getAnalytics(); if (!a.sources) a.sources = {};
if (!a.sources[s]) { if (Object.keys(a.sources).length >= 500) return; a.sources[s] = { bridge:0, start:0, click:0 }; }
a.sources[s][event] = (a.sources[s][event]||0) + 1;
writeJson(ANALYTICS_FILE, a);
}
function normalizeStatuses(sponsors, preferredActiveId=null) {
const eligible=sponsors.filter(s=>s.status!=='qualified');
let activeId=preferredActiveId;
if(!activeId || !eligible.some(s=>s.id===activeId)){
const existing=eligible.find(s=>s.status==='active');
activeId=existing?existing.id:(eligible[0]?.id||null);
}
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
}
function publicSponsorPayload(sponsor, config) {
if(!sponsor)return null;
return {id:sponsor.id,name:config.showSponsorName?sponsor.name:null,directs:sponsor.directs,goal:2,level:sponsor.level,referralUrl:`${config.dappReferralBaseUrl}${encodeURIComponent(sponsor.id)}`};
}
function securityHeaders(extra={}) {
return {
'X-Content-Type-Options':'nosniff','X-Frame-Options':'DENY','Referrer-Policy':'strict-origin-when-cross-origin',
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'",
...extra
};
}
function send(res,status,body,headers={}) { res.writeHead(status,securityHeaders(headers));res.end(body); }
function json(res,status,obj,headers={}) { send(res,status,JSON.stringify(obj),{'Content-Type':'application/json; charset=utf-8',...headers}); }
function parseCookies(req){const out={};for(const p of (req.headers.cookie||'').split(';')){const i=p.indexOf('=');if(i>0)out[p.slice(0,i).trim()]=decodeURIComponent(p.slice(i+1).trim())}return out}
function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}}
function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true}
async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})}
function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.mp4':'video/mp4','.webm':'video/webm'}[ext]||'application/octet-stream')}
function staticFile(req,res,file,status=200){
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
const size=fs.statSync(file).size;
const base={'Content-Type':contentType(file),'Accept-Ranges':'bytes','Cache-Control':['.html','.css','.js'].includes(path.extname(file))?'no-cache':'public, max-age=3600'};
const m=status===200&&req.headers.range?String(req.headers.range).match(/^bytes=(\d*)-(\d*)$/):null;
if(m&&(m[1]!==''||m[2]!=='')){
const start=m[1]===''?Math.max(0,size-Number(m[2])):Number(m[1]);
const end=(m[1]!==''&&m[2]!=='')?Math.min(Number(m[2]),size-1):size-1;
if(start>end||start>=size){res.writeHead(416,securityHeaders({'Content-Range':`bytes */${size}`}));res.end();return true}
res.writeHead(206,securityHeaders({...base,'Content-Range':`bytes ${start}-${end}/${size}`,'Content-Length':end-start+1}));
if(req.method==='HEAD')res.end();else fs.createReadStream(file,{start,end}).pipe(res);
return true;
}
res.writeHead(status,securityHeaders({...base,'Content-Length':size}));
if(req.method==='HEAD')res.end();else fs.createReadStream(file).pipe(res);
return true;
}
async function handleApi(req,res,pathname){
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
if(req.method==='GET'&&pathname==='/api/public/config'){
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
}
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
}
if(req.method==='POST'&&pathname==='/api/public/join-click'){
const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source);
let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true});
}
if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res);
if(req.method==='POST'&&pathname==='/api/public/track'){
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true});
}
if(req.method==='POST'&&pathname==='/api/admin/login'){
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'});
const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});const cookie=`ctb.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL/1000}${IS_PROD?'; Secure':''}`;return json(res,200,{ok:true},{'Set-Cookie':cookie});
}
if(req.method==='POST'&&pathname==='/api/admin/logout'){
const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
}
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
if(key===null)return json(res,400,{error:'Invalid request.'});
if(key===''){try{fs.unlinkSync(OPENROUTER_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getOpenRouterKey()});}
if(key.length<20||/\s/.test(key))return json(res,400,{error:'That does not look like a valid API key.'});
fs.writeFileSync(OPENROUTER_KEY_FILE,key,{mode:0o600});
return json(res,200,{configured:true});
}
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes=''}=b;if(!id||!name)return json(res,400,{error:'ID and name are required.'});if(!LEVELS.includes(level))return json(res,400,{error:'Invalid level.'});let sponsors=getSponsors();if(sponsors.some(s=>String(s.id)===String(id)))return json(res,409,{error:'That sponsor ID already exists.'});
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim()});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
}
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
}
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
if(req.method==='PATCH'&&!action){const b=await bodyJson(req);if(Object.prototype.hasOwnProperty.call(b,'level')&&!LEVELS.includes(b.level))return json(res,400,{error:'Invalid level.'});for(const k of ['name','parentId','directs','level','notes'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=b[k];sponsors[idx].directs=Math.max(0,Math.min(2,Number(sponsors[idx].directs)||0));saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='DELETE'&&!action){const wasActive=sponsors[idx].status==='active';sponsors.splice(idx,1);if(wasActive)sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='increment'){sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='activate'){if(sponsors[idx].status==='qualified')return json(res,400,{error:'Qualified sponsors cannot be activated until reset.'});sponsors=normalizeStatuses(sponsors,id);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='qualify'){sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors,active:activeSponsor(sponsors)});}
if(req.method==='POST'&&action==='reset'){sponsors[idx]={...sponsors[idx],directs:0,status:'waiting'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='move'){const b=await bodyJson(req);const swap=b.direction==='up'?idx-1:idx+1;if(swap>=0&&swap<sponsors.length){const t=sponsors[idx].sortOrder;sponsors[idx].sortOrder=sponsors[swap].sortOrder;sponsors[swap].sortOrder=t;saveSponsors(sponsors)}return json(res,200,{sponsors:getSponsors()});}
}
return json(res,404,{error:'API endpoint not found'});
}
const server=http.createServer(async(req,res)=>{
try{
const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname);
if(pathname==='/health'||pathname.startsWith('/api/'))return await handleApi(req,res,pathname);
if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'});
let file;
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else{
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
}
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
});
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});