Files
rm-circle-team-router/server.js
T
martbost 7c41499312 Add on-chain payment proof: live payout feed, ID verification, admin lineage tools
New chain.js reads the RM Circle contract (0x33Bd…2DAF, Polygon) via free
public RPCs — no API keys. Daily snapshot rebuilds complete payout history
from getIncomeHistory (log providers prune old history), and a 60s
eth_getLogs tail catches new payouts with tx hashes, upgrade context, and
passed-over upline IDs.

- Bridge page: "Live payment proof" feed + timed toast pop-ups for payouts
  seen in the last 15 min, every row linking to Polygonscan.
- /start: same toasts; ID submissions are now verified against the contract
  (result shown to the member, in Telegram notify, and in admin).
- Admin: on-chain member lookup (lineage to root, directs, matrix children,
  full income history) and a collapsible full matrix tree view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:25:07 -05:00

302 lines
25 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const chain = require('./chain');
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.`;
}
const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json');
if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]');
const submitHits = new Map();
function submitRateLimited(ip) {
const now = Date.now(), rec = submitHits.get(ip);
if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; }
rec.count++; return rec.count > 5;
}
function sendTelegram(text) {
const c = getConfig();
if (!c.telegramBotToken || !c.telegramChatId) return;
const payload = { chat_id: c.telegramChatId, text };
if (c.telegramTopicId && /^[0-9]+$/.test(String(c.telegramTopicId))) payload.message_thread_id = Number(c.telegramTopicId);
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(async r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status, (await r.text().catch(()=>'')).slice(0,200)); })
.catch(e=>console.error('telegram error', e.message));
}
function firePostback(clickid, txid, source) {
const pb = getConfig().bemobPostbackUrl;
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`)
.then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); })
.catch(e=>console.error('bemob postback error', e.message));
}
async function handleSubmitId(req, res) {
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
const b = await bodyJson(req).catch(()=>null);
if (!b) return json(res, 400, { error: 'Invalid request.' });
const newId = String(b.newId||'').trim();
if (!/^[0-9]{1,10}$/.test(newId)) return json(res, 400, { error: 'Enter your numeric RM Circle ID (numbers only).' });
const memberName = String(b.memberName||'').replace(/[\u0000-\u001f\u007f]/g,'').trim().slice(0, 60);
if (!memberName) return json(res, 400, { error: 'Add your name or Telegram handle so the team can reach you.' });
const sponsorId = String(b.sponsorId||'').trim().slice(0, 20).replace(/[^0-9A-Za-z._-]/g,'') || '?';
const source = typeof b.source==='string' ? b.source : '';
const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : '';
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
// on-chain verification: does this ID actually exist on the contract?
let onchain = null;
try {
onchain = await Promise.race([
chain.verifyMember(Number(newId)),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
]);
} catch (e) { onchain = null; }
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(),
onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId, uplineId: onchain.uplineId } : undefined });
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
recordEvent('purchase', source);
firePostback(clickid, `purchase-${clickid}`, source);
const chainLine = onchain === null ? '⏳ On-chain check unavailable — verify manually in admin.'
: onchain.registered
? `✅ VERIFIED ON-CHAIN: ${onchain.tierName} tier, level ${onchain.levelName}, referred by ID ${onchain.referrerId}${String(onchain.referrerId)!==sponsorId?` ⚠ (submitted sponsor was ${sponsorId})`:''}`
: `❌ NOT FOUND ON-CHAIN — ID ${newId} has no registration on the contract yet.`;
sendTelegram(`🔔 RM Circle: NEW MEMBER CONFIRMED\nName: ${memberName}\nNew ID: ${newId}\nJoined under sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n${chainLine}\n→ Add ${memberName} (ID ${newId}) to the rotation queue.`);
return json(res, 200, { ok: true, onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId } : null });
}
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','postback','purchase'].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)}`};
}
const CSP_BASE="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";
function securityHeaders(extra={}) {
// Public pages must render inside safelist / traffic-exchange iframes, so framing stays open here; admin.html re-locks it via ADMIN_FRAME_HEADERS.
return {
'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin',
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
'Content-Security-Policy':`${CSP_BASE}; frame-ancestors *`,
...extra
};
}
const ADMIN_FRAME_HEADERS={'X-Frame-Options':'DENY','Content-Security-Policy':`${CSP_BASE}; frame-ancestors 'none'`};
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',...(path.basename(file)==='admin.html'?ADMIN_FRAME_HEADERS:{})};
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/payouts'){
return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'});
}
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);
const clickid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
firePostback(clickid,`join-${clickid}`,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/submit-id')return await handleSubmitId(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/matrix-tree'){
return json(res,200,chain.getMatrixTree());
}
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
try{
const r=await Promise.race([chain.memberLookup(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Chain RPC timeout — try again.')),25000))]);
return json(res,200,r);
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
}
if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,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','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId'])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.');});
chain.startIndexer();