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 messages = require('./messages'); const tweet = require('./tweet'); 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'; messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD }); 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 || 'RM Circle Team Build'} (https://rmcircle.team), the team site for the RM Circle Premium team build. 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 2 directs to become "qualified"; the team then recommends retiring that link and helping those 2 get their 2 with their OWN links (moving-link strategy) — a position only qualifies through its own directs. If an extra signup lands on a qualified link anyway, present it as a BONUS, not a rule violation: it still pays that member the full entry reward and spills downward into the first open slot in their leg (depth). Spillover never qualifies the people below. - 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. - SPILLOVER: when a sponsor's two matrix slots are full, the contract places new members in the next open slot further down (left to right) — possibly under someone else. Spillover fills that member's matrix and sets up future upgrade income to their position, but does NOT count toward qualification: only people who join using a member's own ID are their directs. Upgrade payments travel up the MATRIX (not who-referred-whom) and only stop at positions that are qualified (2 directs) AND already at the level being bought — otherwise the payment passes them by to the next eligible upline. Members can see their own matrix, spillover tags, and payments at https://rmcircle.team/my - PAY FLOW (full diagram at https://rmcircle.team/how-pay-works): TWO income streams. (1) Entry rewards — when a direct joins under a member's link, that member gets the entry reward (~326 POL at Premium) and keeps it, on EVERY direct (not just the first two). (2) Upgrade payments — each person below you pays you ONCE, at the single level matching how far below you they sit: someone 1 layer down pays you at their Ascensus upgrade, 2 layers down at Fabrica, 3 at Culmen, 4 at Apex, and so on. To catch each one you must be at that level yourself AND qualified — so stay one level ahead of your team's deepest active layer. This is why a member can be "skipped" on a payment: it was reserved for the position at the matching depth, or they weren't leveled up in time. - TIERS: Premium (the tier the whole team builds at, ${c.premiumEntryPol || 362} POL entry) pays and earns FULL amounts; Standard costs about half and pays/earns HALF at every level. A smaller-than-expected payment almost always came from a Standard-tier position below. Tier is fixed at registration and CANNOT be changed later (upgrading advances your LEVEL, not your tier). Always recommend joining Premium and having recruits do the same. Amount comparison at /how-pay-works. - MEMBER DASHBOARD & ALERTS: each member has a live dashboard at https://rmcircle.team/my (enter your ID) showing position, team, payments, pipeline (incoming money forming below), a team-depth summary (members per generation below you and which level's upgrade each generation pays you at), a "Coach Your Team" panel (who in YOUR leg needs a nudge — qualified-but-not-upgraded members sitting on entry rewards, members about to miss forming payments, members one direct from qualifying), spillover tags, and qualification badges. Members can turn on opt-in EMAIL ALERTS there (notified when paid, and when they need to upgrade to catch incoming pay). A member's personal invite page to share is https://rmcircle.team/join/. - RESILIENCE ("what if the creators disappear / owner loses keys / it falls apart over time"): the contract is autonomous and immutable — NO admin action, heartbeat, or living operator is required for joins, upgrades, matrix placement, or payouts; there is no pause switch and no expiry. Verified on-chain that the founder, development, and fee-receiver wallets are ordinary wallets (EOAs), NOT smart contracts — an ordinary wallet always accepts incoming POL even if its key is lost forever, so a dead or abandoned admin wallet cannot block any member payment (only the project's OWN uncollected fee would sit idle). The contract stores no balance (every payment is delivered in the same transaction). If the owner's key were lost, only the four limited admin powers freeze in place; members are unaffected. Details in section 6 of https://rmcircle.team/contract. - 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.team/start right before joining. - Site pages: https://rmcircle.team/ (strategy overview + roadmap + live team stats), https://rmcircle.team/start (current sponsor + join steps), https://rmcircle.team/training (THE CIRCLE METHOD — the team's free 10-lesson course in 3 modules. M1 Get Your Two: L1 mindset, L2 warm list, L3 the conversation, L4 objections. M2 Help Your Two: L5 dashboard-as-coaching-desk, L6 first 48 hours, L7 stalled people & pass-ups, L8 timing upgrades to catches. M3 Teach the Teachers: L9 run the same play, L10 the 20-minute weekly rhythm. ROUTING RULE — answer with the lesson: how do I find people→L2 (/training#lesson-2); what do I say→L3; pyramid objection→L4; new member just joined→L6; someone stalled→L7; should I upgrade→L8; overwhelmed→L10. Deep links: /training#lesson-N — plus 7 how-to videos — team overview, wallet setup, funding, the new connect-wallet join flow on the site, the dApp backup method, how payments work, and a full 14-min Member Dashboard walkthrough — + spillover article), https://rmcircle.team/how-pay-works (the two income streams shown as a pay-flow diagram + Premium/Standard tier comparison), https://rmcircle.team/contract (plain-language security review of the verified smart contract — code can't change, no pooled funds, locked rules, honest list of operator powers), https://rmcircle.team/weekly-rhythm (printable 20-minute Weekly Rhythm routine + 4-week habit tracker from Method Lesson 10, personalized like the Fast Start sheet), https://rmcircle.team/fast-start (printable 48-Hour Fast Start checklist — personalized with the member's invite link and a scannable QR code when opened from their dashboard; prints clean black-on-white, and prints in whatever language the member selected with the 🌐 button), https://rmcircle.team/my (member dashboard — its "Your team" panel opens with an organization bar: total members in your org, generations deep, qualified count below you, POL earned below you, and its approximate USD value at an hourly-cached POL price; the matrix under it drills leg by leg), https://rmcircle.team/tools (for existing team members who want to promote — share-ready promo videos (including the “Pocket Change” curiosity hook video — 25 ways people flush pocket change weekly with nothing to show for it, then the side-hustle flip; it deliberately shows no URL so the poster's invite link in the caption/description carries the credit, and matching pocket-change post copy sits in the Social posts section), copy-paste social posts, short/long email swipes, a downloadable banner kit in every standard size, and an Official RM Circle Media library (13 vertical social videos + 15 graphics from the creators — pair them with your own invite link in the caption; each curiosity video also has a MATCHED invite link (adds ?v= to the member's /join link) that makes the landing page continue that video's hook — recommend it when members ask which link to use with a video); open it from the gold Promo Tools button on your dashboard and every post/swipe arrives pre-personalized with YOUR invite link; to write promos in their own voice, mybrandedvoice.com), https://rmcircle.team/disclaimer (affiliate/earnings/risk disclosures). - UPGRADING FROM THE DASHBOARD: a qualified member can upgrade their level directly on their dashboard (rmcircle.team/my/) — an "Upgrade" card appears with the exact next-level cost read live from the contract; they connect the wallet that OWNS the position, confirm one transaction, done. The site never touches the funds (wallet pays the contract directly). If the wallet doesn't cover the cost, the card offers the MoonPay card-buy option. On phones, open the page inside the wallet app's browser. - MESSAGES (on-site, wallet-verified): every member dashboard has a Messages panel — sign in once with the wallet that owns your position (a free signature, cannot move funds), then message your upline or anyone in your own team, or broadcast to your whole team. Spam-proof by design: messaging only works along your own matrix lines, so strangers can't message you. Unread messages show as a bell on your dashboard. Members are told the team admin can review messages for abuse. No email address needed. - BUYING POL WITH A CARD (for people brand new to crypto): the site links to MoonPay (moonpay.com/buy/pol) on the training page, the start page, and automatically on the join page when a connected wallet's balance is short. Guidance to give: choose POL on the POLYGON network, send it to YOUR OWN wallet address, buy about entry + gas (~385 POL). When explaining gas, use the car analogy: just like a car needs fuel to get anywhere, every blockchain transaction burns a tiny bit of POL to move — keep a little in the tank beyond the entry, because a wallet with an empty tank cannot make the trip. MoonPay is an independent company (merchant of record) — it handles ID verification and charges its own card fee (~4.5%); this site never touches or holds anyone's money. First purchases can take a few minutes to arrive. - LANGUAGE: always reply in the language the member writes in — translate program terms naturally and keep level names (Scintilla, Ascensus, ...) as-is. Site pages have a floating 🌐 Translate button (bottom-left) that machine-translates any page and remembers the choice. - COACHING DOCTRINE (teach forward): whenever you give a member guidance about helping their team, frame it so they learn to run the same play for their own two — e.g. "do X, then show your two how to spot this on THEIR dashboard's Coach Your Team panel." The goal is never just fixing one member's next step; it is teaching people how to teach. Every member's dashboard has the same Coach Your Team panel, so the play duplicates at every depth. - SPENDING/CASH-OUT (the "now how do I use it?" question): guide at /training#spending. Easiest path: e-gift cards — send POL to CWallet (cwallet.com), swap to a USD token internally (near-free swaps), buy gift cards for everyday brands; eGifter (egifter.com) accepts crypto directly. Straight cash: regulated exchange in their country (send → sell → bank withdrawal, KYC applies). Or leave catches in the wallet to fund the next level (Lesson 8 doctrine). ALWAYS add: independent custodial services — only move what you're about to spend; region availability and fees vary; possible tax implications — keep records; never financial advice. - Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'} - OBJECTIONS (answer honestly, never with hype or promises): - "Is this a pyramid/Ponzi/scam?": a pyramid or Ponzi routes everyone's money to a central company that pays earlier joiners out of later joiners' deposits, with nothing verifiable. Here NO company holds funds — the smart contract pays person-to-person in the same transaction and the code plus every payout are public on-chain (verify at https://rmcircle.team/contract). Be plain that it is a team build with real effort and real crypto risk, not a passive investment. - "If my two can't get their two, doesn't it stop there?": everyone needs their OWN 2 to qualify (spillover never qualifies for them), but a leg does not dead-end — it is a team: you help your 2 get theirs, uplines earn as the team grows so helping you is in their interest, and spillover adds depth when personal recruiting is slow. It only truly stalls if everyone stops helping. - "What if I can't afford the upgrades?": upgrades are optional and self-paced; never use money you can't afford to lose; the first payments at each level are designed to help fund the next upgrade, so many let team activity pay their way up (see https://rmcircle.team/how-pay-works). - "My prospect wants to join but has no money": coach the member to sort which of three objections it really is. (1) Most common — "sounds expensive/complicated": entry is ${c.premiumEntryPol || 362} POL — right now roughly ${entryUsdLine()} — payable with a regular debit card via the join page; many prospects imagine far more. ALWAYS quote the current dollar figure from this prompt, never a remembered one, and add that POL's price moves. (2) Priority dressed as money: send the "Pocket Change" curiosity video from /tools and go silent (it calls out weekly wasted spending — fully evergreen, no dollar figures) — never argue it. (3) Genuinely broke: the honest answer is "don't join yet" — never rent money, never desperation recruiting; ask "when's payday?" and follow up then. Waiting costs the prospect nothing: invite links don't expire and the team's growth is publicly visible on-chain while they wait. HARD RULE: nobody ever fronts or loans someone's entry — not the sponsor, not the team; it kills commitment and creates one person controlling positions registered to others' wallets. Teach-forward: have the member log how each conversation goes, because their people will hit the same objection. - "Someone below me won't upgrade / is stalled": a paused member NEVER blocks money. Each person below you pays you separately, once, at one level — nothing waits on pairs or chains of upgrades. Payments a paused member can't catch pass straight UP through them to the next ready position (often the asker's), and everyone below the paused member keeps flowing normally; the only thing on hold is the single payment that person would send at the asker's matching level. Never pressure anyone — paused members keep collecting entry rewards from new team growth and often return once the upgrade funds itself. Point the asker at the pipeline panel on their dashboard to see who IS moving, and (teach-forward) have them show their two the same panel. - "What's the actual product? / What do I get?": answer with the business-in-a-box inventory that comes with a position on THIS team: (1) The Circle Method — a complete 10-lesson recruiting & coaching course; (2) a promo arsenal — 4 professionally produced curiosity videos with matched landing pages, ready-made posts, descriptions, email swipes and a full banner kit, all auto-personalized with their invite link; (3) printable playbooks (48-Hour Fast Start + Weekly Rhythm) with their own QR code; (4) an AI coach on every page answering in 21+ languages around the clock; (5) a live coaching dashboard reading the blockchain — pipeline, org bar, coach panel, wallet-verified team messaging. Be clear this toolkit is provided by this team around the public smart contract; the contract itself is the payment engine, the toolkit is what this team adds. - "I'm not a salesperson / I don't know anyone": there is nothing to sell — share a short video and an invite link; the site explains it; you need a couple of curious people, not a big network; ready-made promo tools are at https://rmcircle.team/tools. - "What if people quit?": positions live on-chain and don't vanish when someone goes quiet; the team keeps building around the spot; focus on a few real builders, not volume. No income is guaranteed. 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 memberCache = new Map(); const polPrice={usd:0,at:0}; async function getPolUsd(){ if(polPrice.usd&&Date.now()-polPrice.at<3600000)return polPrice.usd; try{ const r=await Promise.race([ fetch('https://api.coingecko.com/api/v3/simple/price?ids=polygon-ecosystem-token&vs_currencies=usd').then(x=>x.json()), new Promise((_,rej)=>setTimeout(()=>rej(new Error('price timeout')),8000)) ]); const usd=Number(r&&r['polygon-ecosystem-token']&&r['polygon-ecosystem-token'].usd); if(usd>0){polPrice.usd=usd;polPrice.at=Date.now();} }catch(e){} return polPrice.usd||0; // 0 = unknown; dashboard simply hides the USD tile } const lookupHits = new Map(); function memberLookupLimited(ip) { const now = Date.now(), rec = lookupHits.get(ip); if (!rec || now > rec.reset) { lookupHits.set(ip, { count: 1, reset: now + 60000 }); return false; } rec.count++; return rec.count > 20; } // Admin login brute-force gate: after 8 failures from an IP, lock it out for // 15 minutes (escalating). Timing-safe password compare above. In-memory — // a restart clears it, which is fine (attacker loses their progress too). const loginHits = new Map(); const LOGIN_MAX = 8, LOGIN_LOCK_MS = 15 * 60 * 1000; function loginGate(ip) { const r = loginHits.get(ip); if (r && r.until > Date.now()) return { locked: true, mins: Math.ceil((r.until - Date.now()) / 60000) }; return { locked: false }; } function loginFail(ip) { const now = Date.now(); let r = loginHits.get(ip); if (!r || (r.until && r.until < now && r.count >= LOGIN_MAX)) r = { count: 0, until: 0 }; r.count++; if (r.count >= LOGIN_MAX) { r.until = now + LOGIN_LOCK_MS * Math.min(8, r.count - LOGIN_MAX + 1); loginHits.set(ip, r); return { locked: true, mins: Math.ceil((r.until - now) / 60000) }; } loginHits.set(ip, r); return { locked: false, left: LOGIN_MAX - r.count }; } function loginReset(ip) { loginHits.delete(ip); } 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; } // Post to the team Telegram. topicId overrides the default team-build topic // (config.telegramTopicId) — used to fan the same event out to a second forum // topic (e.g. the recruiting/new-members topic) with different copy. function sendTelegram(text, topicId, replyMarkup) { const c = getConfig(); if (!c.telegramBotToken || !c.telegramChatId) return; const payload = { chat_id: c.telegramChatId, text }; const thread = topicId != null ? topicId : c.telegramTopicId; if (thread && /^[0-9]+$/.test(String(thread))) payload.message_thread_id = Number(thread); if (replyMarkup) payload.reply_markup = replyMarkup; 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)); } // Recruiting-framed version of a team event for the new-members topic: social // proof + a call to action to the home page, no internal/contact detail. function recruitMsg(evt) { const home = 'rmcircle.team', tags = '#RMCircle #Polygon #Crypto'; if (evt.type === 'payout') { const line = evt.kind === 'upline' ? `Member #${evt.toId} just earned ${evt.pol.toFixed(2)} POL${evt.gen ? ` — a Gen ${evt.gen} upgrade pass-up` : ''}, paid automatically as their team grew beneath them.` : `Member #${evt.toId} just earned ${evt.pol.toFixed(2)} POL the moment a new teammate joined on their link.`; return `💸 Another on-chain payout just landed! 🎉\n\n${line}\n\nInstant, automatic, and verifiable on the blockchain — no company holding the money. This is what building on The RM Circle looks like. 🚀\n\n👉 Start yours at ${home}\n\n${tags}`; } if (evt.type === 'registered') { return `🔥 The team just grew!\n\nA new member joined The RM Circle and locked in their position on-chain. The momentum is real.\n\n👉 Claim your spot at ${home} 🚀\n\n${tags}`; } if (evt.type === 'upgraded') { return `⚡ Level up!\n\nMember #${evt.id} just upgraded to ${evt.levelName} on The RM Circle — climbing the ranks and opening up bigger pass-ups.\n\n👉 Start building at ${home} 🚀\n\n${tags}`; } return ''; } const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key'); function getSendgridKey() { if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY; try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; } } // SendGrid is domain-authenticated for marketingwithmarty.com and // mybrandedvoice.com — the from address must stay on one of those or DKIM fails. function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team '; } function sendEmailRaw(toEmail, subject, text) { const key = getSendgridKey(); if (!key || !toEmail) return; const fromStr = emailFrom(); const m = fromStr.match(/^(.*)<([^>]+)>\s*$/); const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() }; fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ personalizations: [{ to: [{ email: toEmail }] }], from, subject, content: [{ type: 'text/plain', value: text }] }) }).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); }) .catch(e => console.error('sendgrid error', e.message)); } function sendPaidEmail(toEmail, memberName, evt, unsub) { const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`; const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : ''; const foot = unsub ? `\n\nStop these alerts: ${unsub}` : `\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`; const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.team/my/${evt.toId}\n\n— The RM Circle Team${foot}`; sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text); } 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; } // the chain decides the path: a rotation join is one whose on-chain referrer is // a rotation-queue sponsor that IS or HAS BEEN worked (status active or // qualified) — not just the currently-active one. With auto-advance, the direct // who completes a sponsor's 2/2 (and thus joined under it) submits their ID // AFTER the rotation has already moved on, so keying off "active only" wrongly // labeled them a leg join. A referrer that's a still-waiting queue position, or // not in the queue at all, is a personal leg join. const sponsorsNow = getSponsors(); const active = activeSponsor(sponsorsNow); let joinPath = 'unknown'; if (onchain && onchain.registered) { const refSp = sponsorsNow.find(s => String(s.id) === String(onchain.referrerId)); joinPath = (refSp && (refSp.status === 'active' || refSp.status === 'qualified')) ? 'rotation' : 'leg'; } else if (onchain && !onchain.registered) joinPath = 'notfound'; subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(), path: joinPath, 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); let msg; if (joinPath === 'rotation') { // rotation joins go straight into the queue as waiting positions — no manual step let queueNote = ''; try { let sponsors = getSponsors(); if (sponsors.some(s => String(s.id) === String(newId))) { queueNote = 'Already in the rotation queue.'; } else { const maxOrder = sponsors.reduce((m, s) => Math.max(m, s.sortOrder || 0), 0); sponsors.push({ id: String(newId), name: memberName, parentId: String(onchain.referrerId), directs: 0, level: onchain.levelName || 'Scintilla', status: sponsors.some(s => s.status === 'active') ? 'waiting' : 'active', sortOrder: maxOrder + 10, clicks: 0, notes: `auto-added: rotation join under #${onchain.referrerId} ${new Date().toISOString().slice(0, 10)}` }); sponsors = normalizeStatuses(sponsors); saveSponsors(sponsors); const waitingAhead = sponsors.filter(s => s.status === 'waiting' && (s.sortOrder || 0) < maxOrder + 10).length; queueNote = `Auto-added to the rotation queue (${waitingAhead} waiting ahead of them).`; } } catch (e) { queueNote = `⚠ Auto-add to queue failed (${e.message}) — add manually.`; console.error('queue auto-add', e.message); } msg = `🔔 RM Circle: ROTATION JOIN CONFIRMED ✅\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nJoined under rotation sponsor: #${onchain.referrerId}\nSource: ${source||'(direct)'}\n✅ ${queueNote}\n(Sponsor #${onchain.referrerId}'s direct count syncs from the chain automatically.)`; } else if (joinPath === 'leg') { msg = `🌱 RM Circle: TEAM-BUILD JOIN (not rotation)\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nActual sponsor on-chain: #${onchain.referrerId}${sponsorId!=='?'&&String(onchain.referrerId)!==sponsorId?` (form said ${sponsorId})`:''}\nSource: ${source||'(direct)'}\n→ Leg growth under #${onchain.referrerId} — no rotation action needed. Add them to the rotation queue only if they want the team effort.`; } else if (joinPath === 'notfound') { msg = `🔔 RM Circle: ID SUBMITTED — ❌ NOT FOUND ON-CHAIN\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ ID has no registration on the contract — double-check with them before any queue action.`; } else { msg = `🔔 RM Circle: NEW MEMBER SUBMITTED\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n⏳ On-chain check unavailable — verify manually in admin (member lookup).`; } sendTelegram(msg); return json(res, 200, { ok: true, path: joinPath, 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.team', '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 }); } } // Sessions persist in the data volume so redeploys stop logging the admin out. const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); const sessions = new Map(); try { const saved = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); const now = Date.now(); for (const [t, s] of Object.entries(saved)) if (s && s.expires > now) sessions.set(t, s); } catch (e) {} function saveSessions() { try { const now = Date.now(); for (const [t, s] of sessions) if (s.expires <= now) sessions.delete(t); const tmp = SESSIONS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 }); fs.renameSync(tmp, SESSIONS_FILE); } catch (e) { console.error('session save failed', e.message); } } 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','join','joinnow'].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) { // Chain reconciliation: queue members can qualify through leg activity long // BEFORE their rotation turn, and the event-driven auto-counter never sees // it (bit us twice on 2026-08-19: #41, then #46 activated while already // 2/2 on-chain). Never activate someone the chain says is qualified. sponsors=sponsors.map(s=>{ if(s.status==='qualified')return s; const dc=chain.liveDirects(Number(s.id)); if(dc!=null&&dc>=2)return {...s,directs:2,status:'qualified'}; if(dc!=null&&dc>(Number(s.directs)||0))return {...s,directs:dc}; return s; }); 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)}`}; } // --- on-demand UI translation: strings cached forever on the volume, misses // filled by the same OpenRouter model the chatbot uses. Public site text only. const TR_FILE = path.join(DATA_DIR, 'translations.json'); const TR_LANGS = new Set(['es','pt','fr','de','it','nl','pl','ro','ru','uk','tr','ar','hi','fil','vi','id','th','zh','ja','ko','sw']); const TR_LANG_NAMES = {es:'Spanish',pt:'Portuguese',fr:'French',de:'German',it:'Italian',nl:'Dutch',pl:'Polish',ro:'Romanian',ru:'Russian',uk:'Ukrainian',tr:'Turkish',ar:'Arabic',hi:'Hindi',fil:'Filipino (Tagalog)',vi:'Vietnamese',id:'Indonesian',th:'Thai',zh:'Simplified Chinese',ja:'Japanese',ko:'Korean',sw:'Swahili'}; let trCache=null,trDirty=false; function trLoad(){ if(trCache)return trCache; try{trCache=JSON.parse(fs.readFileSync(TR_FILE,'utf8'))}catch(e){trCache={}} return trCache; } setInterval(()=>{ if(trDirty){trDirty=false;try{fs.writeFileSync(TR_FILE,JSON.stringify(trCache))}catch(e){}} },15000).unref(); const trIpHits=new Map(); function entryUsdLine(){ const pol=Number(getConfig().premiumEntryPol)||362; if(!polPrice.usd)return 'a small one-time amount (see the join page for the current dollar figure)'; const entry=pol*polPrice.usd, allin=entry*1.055+2; // card fee ~4.5% + a little gas return `$${entry.toFixed(0)} (about $${(Math.ceil(allin/5)*5).toFixed(0)} all-in with the card fee and gas)`; } function trLimited(ip){ const now=Date.now(); const h=trIpHits.get(ip)||{n:0,ts:now}; if(now-h.ts>600000){h.n=0;h.ts=now} h.n++; trIpHits.set(ip,h); if(trIpHits.size>2000)trIpHits.clear(); return h.n>60; } async function handleTranslate(req,res){ const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); // direct container-internal calls (no proxy header) skip the limiter — used by the cache warmer const internal=!req.headers['x-forwarded-for']&&/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|::ffff:(127\.|10\.|172\.|192\.168\.))/.test(String(req.socket.remoteAddress||'')); if(!internal&&trLimited(ip))return json(res,429,{error:'Too many translation requests — give it a minute.'}); const b=await bodyJson(req).catch(()=>null); const tl=b?String(b.tl||''):''; const texts=b&&Array.isArray(b.texts)?b.texts.slice(0,25).map(t=>String(t).slice(0,300)):null; if(!TR_LANGS.has(tl)||!texts||!texts.length)return json(res,400,{error:'Bad request'}); if(texts.reduce((a,t)=>a+t.length,0)>9000)return json(res,400,{error:'Too much text'}); const cache=trLoad(); const keyOf=t=>tl+'|'+crypto.createHash('sha1').update(t).digest('hex').slice(0,16); const out=new Array(texts.length); const miss=[]; texts.forEach((t,i)=>{ const c=cache[keyOf(t)]; if(c!=null)out[i]=c; else miss.push(i); }); if(miss.length){ const apiKey=getOpenRouterKey(); let done=false; if(apiKey){ try{ const ctrl=new AbortController(); const timer=setTimeout(()=>ctrl.abort(),25000); 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.team','X-Title':'RM Circle Translate'}, body:JSON.stringify({model:OPENROUTER_MODEL,max_tokens:6000,temperature:0,messages:[ {role:'system',content:`You translate website UI strings from English to ${TR_LANG_NAMES[tl]}. The input is numbered items, each preceded by a line @@N@@. Reply with the SAME @@N@@ marker lines, each followed by that item's translation (multi-line items keep their line breaks). Output nothing except markers and translations — no preamble, no code fences. Keep these words untranslated wherever they appear: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona, POL, Polygon, RM Circle, MoonPay, MetaMask, Trust Wallet. Keep numbers, #ids, emoji and punctuation intact. Natural, friendly tone.`}, {role:'user',content:miss.map((i,j)=>`@@${j+1}@@\n${texts[i]}`).join('\n')} ]}) }); clearTimeout(timer); if(r.ok){ const d=await r.json(); let reply=d&&d.choices&&d.choices[0]&&d.choices[0].message&&d.choices[0].message.content||''; reply=reply.replace(/^```[a-z]*\s*/,'').replace(/```\s*$/,'').trim(); const parts=reply.split(/^@@(\d+)@@\s*$/m); // parts: [lead, "1", text1, "2", text2, ...] const arr=[]; for(let k=1;k+1{ const tr=typeof arr[j]==='string'&&arr[j].length?String(arr[j]).slice(0,600):null; if(tr){ out[idx]=tr; cache[keyOf(texts[idx])]=tr; got++; } }); if(got){ trDirty=true; done=true; } if(got{out[i]=texts[i]}); } return json(res,200,{t:out}); } const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; 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{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();await getPolUsd().catch(()=>{});return json(res,200,{polUsd:polPrice.usd||0,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/member'){ const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); if(memberLookupLimited(ip))return json(res,429,{error:'Too many lookups — give it a minute.'}); const q0=new URL(req.url,'http://x').searchParams; const id=Number(q0.get('id')||0); if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'}); const cached=memberCache.get(id); // fresh=1: the caller just completed their own transaction — bypass the // cache once so the dashboard reflects it immediately if(q0.get('fresh')!=='1'&&cached&&Date.now()-cached.ts<120000)return json(res,200,cached.data,{'Cache-Control':'public, max-age=15'}); try{ const r=await Promise.race([chain.memberPublic(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),20000))]); if(r.registered)r.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(id)}`; // align next-in-line with the human-curated rotation: prefer the ACTIVE // rotation sponsor when they sit in this member's leg and need directs; // else the first chain-order position that's a queue participant; else // keep the chain's pure structural pick (covers legs outside the queue). if(r.registered&&r.subtree){ try{ const sponsors=getSponsors(); const act=activeSponsor(sponsors); const participants=new Set(sponsors.filter(s=>s.status!=='qualified').map(s=>String(s.id))); const bfs=[];const q=[r.subtree.left,r.subtree.right].filter(Boolean); while(q.length){const n=q.shift();bfs.push(n);if(n.left)q.push(n.left);if(n.right)q.push(n.right);} let pick=null; if(act)pick=bfs.find(n=>String(n.id)===String(act.id)&&(n.directCount||0)<2); if(!pick)pick=bfs.find(n=>participants.has(String(n.id))&&(n.directCount||0)<2); if(pick)r.nextInLine={id:pick.id,directCount:pick.directCount||0,levelName:pick.levelName}; }catch(e){} } if(r.nextInLine)r.nextInLine.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(r.nextInLine.id)}`; // Moving-link routing: where a join through THIS member's personal link // actually lands. Not-yet-qualified -> their own position (still filling // their 2). Qualified -> the next-to-qualify in their leg (nextInLine, // already aligned to the curated rotation above), so their promotion // builds the team down in order instead of spilling onto themselves. // Whole leg qualified -> the global rotation sponsor. Last resort -> their // own position (spillover, the prior behavior). if(r.registered){ const base=getConfig().dappReferralBaseUrl; if((r.directCount||0)<2){ r.joinTarget={id,referralUrl:r.referralUrl,reason:'self'}; }else if(r.nextInLine){ r.joinTarget={id:r.nextInLine.id,referralUrl:r.nextInLine.referralUrl,invitedBy:id,reason:'leg',directCount:r.nextInLine.directCount}; }else{ let g=null;try{g=activeSponsor(getSponsors());}catch(e){} if(g&&String(g.id)!==String(id)&&(g.directs||0)<2){ r.joinTarget={id:g.id,referralUrl:`${base}${encodeURIComponent(g.id)}`,invitedBy:id,reason:'global',directCount:g.directs}; }else{ r.joinTarget={id,referralUrl:r.referralUrl,reason:'spillover'}; } } } // Next-step plan + funded badge. Wallet balance is checked server-side and // ONLY the boolean (covers next upgrade?) is exposed — never the raw amount. if(r.registered){ const lvl=r.level||1, tier=r.tier===2?2:1, qualified=(r.directCount||0)>=2; if(!qualified){ r.nextStep={kind:'qualify',need:2-(r.directCount||0)}; }else if(lvl<8){ const cost=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl-1])||0; let funded=null; try{ if(r.account&&cost){ const bal=await chain.balanceOf(r.account); if(bal!=null) funded=bal>=cost; } }catch(e){} r.nextStep={kind:'upgrade',nextLevel:lvl+1,cost,funded}; }else{ r.nextStep={kind:'max'}; } } const usd=await getPolUsd(); if(usd>0){r.polUsd=usd;r.polUsdAt=polPrice.at;} memberCache.set(id,{data:r,ts:Date.now()}); if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value); return json(res,200,r,{'Cache-Control':'public, max-age=60'}); }catch(e){return json(res,502,{error:e.message||'Lookup failed'})} } if(req.method==='GET'&&pathname==='/api/public/payouts'){ const q=new URL(req.url,'http://x').searchParams; const offset=Number(q.get('offset')||0); const limit=Number(q.get('limit')||40); return json(res,200,chain.getPayoutsPublic(offset,limit),{'Cache-Control':'public, max-age=20'}); } if(req.method==='GET'&&pathname==='/api/public/org-stats'){ const root=Number(getConfig().orgRootId||21); const d=chain.getOrgShare(Number.isInteger(root)&&root>0?root:21); // expose only the aggregate showcase numbers (all public on-chain data) return json(res,200,{ready:d.ready,found:!!d.found,memberPct:d.memberPct,orgMembers:d.orgMembers,generations:d.generations,orgPol:d.orgPol,totalMembers:d.totalMembers},{'Cache-Control':'public, max-age=60'}); } if(req.method==='GET'&&pathname==='/api/public/alert-status'){ const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0); const rec=getMemberAlerts()[id]; return json(res,200,{subscribed:!!(rec&&rec.email),email:rec&&rec.email?maskEmail(rec.email):null}); } if(req.method==='POST'&&pathname==='/api/public/alert-signup'){ const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); if(submitRateLimited(ip))return json(res,429,{error:'Too many requests — wait a few minutes.'}); const b=await bodyJson(req).catch(()=>null); if(!b)return json(res,400,{error:'Invalid request.'}); const id=Number(b.id); if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter your numeric member ID.'}); const email=String(b.email||'').trim(); const ma=getMemberAlerts(); if(!email){ if(ma[id]){delete ma[id];saveMemberAlerts(ma);} return json(res,200,{ok:true,subscribed:false}); } if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)||email.length>120)return json(res,400,{error:'Enter a valid email address.'}); let onchain=null; try{ onchain=await Promise.race([chain.verifyMember(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('t')),6000))]); }catch(e){ onchain=null; } if(onchain&&!onchain.registered)return json(res,400,{error:`ID ${id} isn't registered on the smart contract — double-check the number.`}); ma[id]={email:email.slice(0,120),ts:new Date().toISOString()}; saveMemberAlerts(ma); sendEmailRaw(email,`Alerts on for RM Circle position #${id}`,`You're now subscribed to alerts for RM Circle position #${id}.\n\nYou'll get an email when this position is paid, and when it needs an upgrade to catch incoming pay.\n\nSee your position anytime: https://rmcircle.team/my/${id}\nStop these alerts: ${unsubUrl(id)}\n\n— The RM Circle Team`); return json(res,200,{ok:true,subscribed:true,email:maskEmail(email)}); } if(req.method==='GET'&&pathname==='/api/public/moonpay-url'){ // Card on-ramp deep link. With MoonPay partner keys configured (admin // config), returns a SIGNED checkout URL prefilled with the member's own // wallet address + POL amount; otherwise a generic MoonPay buy page. // Zero custody either way — MoonPay is merchant of record, crypto goes // straight to the member's wallet. const q=new URL(req.url,'http://x').searchParams; const addr=(q.get('address')||'').trim(); let pol=Math.round(Number(q.get('pol'))||0); if(!pol||pol<62)pol=Math.max(62,Math.ceil((Number(getConfig().premiumEntryPol)||362)*1.05)+5); if(pol>100000)pol=100000; const c=getConfig(), pk=(c.moonpayPublicKey||'').trim(), sk=(c.moonpaySecretKey||'').trim(); if(pk&&sk&&/^0x[0-9a-fA-F]{40}$/.test(addr)){ const qs='?apiKey='+encodeURIComponent(pk)+'¤cyCode=pol_polygon&walletAddress='+encodeURIComponent(addr)+'"eCurrencyAmount='+pol; const sig=crypto.createHmac('sha256',sk).update(qs).digest('base64'); return json(res,200,{url:'https://buy.moonpay.com/'+qs+'&signature='+encodeURIComponent(sig),signed:true,pol}); } return json(res,200,{url:'https://www.moonpay.com/buy/matic',signed:false,pol}); } // ---- wallet-verified member messaging ---- if(req.method==='POST'&&pathname==='/api/public/msg-challenge'){ const b=await bodyJson(req); if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address))return json(res,400,{error:'Invalid wallet address.'}); return json(res,200,{message:messages.makeChallenge(b.address)}); } if(req.method==='POST'&&pathname==='/api/public/msg-verify'){ const b=await bodyJson(req); if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address)||typeof b.signature!=='string')return json(res,400,{error:'Invalid request.'}); const r=messages.verifyChallenge(b.address,b.signature); if(r.error)return json(res,401,{error:r.error}); return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)}); } if(req.method==='GET'&&pathname==='/api/public/msg-me'){ const s=messages.authFromCookie(req); if(!s)return json(res,401,{error:'Not signed in.'}); return json(res,200,{id:s.id,unread:messages.unreadCount(s.id)}); } if(req.method==='GET'&&pathname==='/api/public/msg-inbox'){ const s=messages.authFromCookie(req); if(!s)return json(res,401,{error:'Not signed in.'}); return json(res,200,messages.inbox(s)); } if(req.method==='POST'&&pathname==='/api/public/msg-send'){ const s=messages.authFromCookie(req); if(!s)return json(res,401,{error:'Not signed in.'}); const b=await bodyJson(req); const r=messages.send(s,b||{}); return json(res,r.error?400:200,r); } if(req.method==='POST'&&pathname==='/api/public/msg-read'){ const s=messages.authFromCookie(req); if(!s)return json(res,401,{error:'Not signed in.'}); const b=await bodyJson(req); return json(res,200,messages.markRead(s,Array.isArray(b&&b.mids)?b.mids.slice(0,100):[])); } if(req.method==='GET'&&pathname==='/api/public/msg-unread'){ const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0); return json(res,200,{count:messages.unreadCount(id)},{'Cache-Control':'no-store'}); } if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){ const c=getConfig(); // DORMANT until config.publicRotationMode='chain': company-wide rotation — // the next open position under publicRotationRootId (default #2), read // straight from the chain. Negotiated with the RM Circle founders // 2026-08-19; do NOT enable until Marty says flip (his team is migrating // links first, and his new right-leg position must be secured first). if((c.publicRotationMode||'queue')==='chain'){ const root=Number(c.publicRotationRootId)||2; const exclude=new Set(String(c.rotationExcludeIds||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0)); const pick=chain.nextOpenPosition(root,exclude); if(pick)return json(res,200,{sponsor:{id:String(pick.id),name:null,directs:pick.directCount,goal:2,level:pick.level,referralUrl:`${c.dappReferralBaseUrl}${encodeURIComponent(pick.id)}`},waitingCount:0,mode:'chain',message:'Company rotation — you join under the next open team position. It advances automatically as positions qualify.'}); } const sponsors=getSponsors(),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/translate')return await handleTranslate(req,res); 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 ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); const gate=loginGate(ip); if(gate.locked)return json(res,429,{error:`Too many attempts. Try again in ${gate.mins} minute${gate.mins===1?'':'s'}.`}); const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'}); const ok=typeof b.password==='string'&&b.password.length===ADMIN_PASSWORD.length&&crypto.timingSafeEqual(Buffer.from(b.password),Buffer.from(ADMIN_PASSWORD)); if(!ok){const g=loginFail(ip);return json(res,401,{error:g.locked?`Too many attempts. Locked for ${g.mins} minutes.`:`Invalid password.${g.left<=3?` ${g.left} attempt${g.left===1?'':'s'} left before lockout.`:''}`});} loginReset(ip); const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});saveSessions();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);saveSessions();}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/messages'){ return json(res,200,{messages:messages.adminList()}); } if(req.method==='GET'&&pathname==='/api/admin/org-share'){ const raw=new URL(req.url,'http://x').searchParams.get('root'); const root=Number(raw||parseOwnerIds()[0]||21); if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'}); return json(res,200,chain.getOrgShare(root)); } if(req.method==='GET'&&pathname==='/api/admin/coaching'){ const raw=new URL(req.url,'http://x').searchParams.get('root'); const root=Number(raw||getConfig().orgRootId||parseOwnerIds()[0]||21); if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'}); const d=chain.getCoachingScan(root,20); if(d.ready){ // decorate with known names from the queue + submissions const names={}; try{for(const s of getSponsors())names[String(s.id)]=s.name;}catch(e){} try{for(const s of readJson(SUBMISSIONS_FILE))if(s.newId&&!names[String(s.newId)])names[String(s.newId)]=s.memberName;}catch(e){} const ownedIds=new Set(parseOwnerIds()); for(const list of [d.atRisk,d.rollForward,d.oneAway])for(const r of list){if(names[String(r.id)])r.name=names[String(r.id)];if(ownedIds.has(Number(r.id)))r.owned=true;} } return json(res,200,d); } if(req.method==='GET'&&pathname==='/api/admin/income'){ const raw=new URL(req.url,'http://x').searchParams.get('ids')||''; const ids=[...new Set(raw.split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0&&n<=281474976710655))].slice(0,12); if(!ids.length)return json(res,400,{error:'Enter one or more numeric IDs (comma-separated).'}); try{ const results=await Promise.race([ Promise.all(ids.map(id=>chain.getIncome(id).catch(()=>({registered:false,id})))), new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),25000)) ]); const rows=[],perId={};let grand=0,grandListed=0; for(const r of results){ if(!r.registered){perId[r.id]={registered:false};continue;} perId[r.id]={registered:true,levelName:r.levelName,tierName:r.tierName,totalEarnedPol:r.totalEarnedPol,count:r.income.length}; grand+=r.totalEarnedPol; for(const p of r.income){rows.push({toId:r.id,fromId:p.fromId,pol:p.pol,ts:p.ts,desc:p.desc});grandListed+=p.pol;} } rows.sort((a,b)=>(b.ts||0)-(a.ts||0)); let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){} let routing=null;try{routing=chain.getOrgRouting(Number(getConfig().orgRootId)||21,ids);}catch(e){} return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds,routing}); }catch(e){return json(res,502,{error:e.message||'Lookup failed'})} } 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},email:{configured:!!getSendgridKey(),from:emailFrom()}});} if(req.method==='POST'&&pathname==='/api/admin/sendgrid-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(SENDGRID_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getSendgridKey()});} if(!/^SG\./.test(key)||key.length<40||/\s/.test(key))return json(res,400,{error:'That does not look like a SendGrid API key (starts with SG.).'}); fs.writeFileSync(SENDGRID_KEY_FILE,key,{mode:0o600}); return json(res,200,{configured:true}); } 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='',email=''}=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.'});if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email).trim()))return json(res,400,{error:'Invalid email address.'});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(),email:String(email||'').trim().slice(0,120)});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','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds'])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.'});if(Object.prototype.hasOwnProperty.call(b,'email')&&b.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(b.email).trim()))return json(res,400,{error:'Invalid email address.'});for(const k of ['name','parentId','directs','level','notes','email'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=k==='email'?String(b[k]||'').trim().slice(0,120):b[k];sponsors[idx].name=String(sponsors[idx].name||'').trim().slice(0,80)||sponsors[idx].name;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`, ``, ``, ``, ``, ``, `` ].join(''); html=html.replace(/]*>/gi,''); html=html.replace(/(]*>)/i,`$1${og}`); res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-cache'})); res.end(html); } 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'}); if(pathname==='/unsubscribe'){ const id=u.searchParams.get('id')||'', t=u.searchParams.get('t')||''; let ok=false; if(/^\d{1,15}$/.test(id)&&t&&t===unsubToken(id)){ const ma=getMemberAlerts(); if(ma[id]){delete ma[id];saveMemberAlerts(ma);} ok=true; } const safeId=/^\d{1,15}$/.test(id)?id:''; const body=`${ok?'Unsubscribed':'Invalid link'}`; res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}));return res.end(body); } { let mj; if((mj=pathname.match(/^\/my\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'my.html'),'my',mj[1]); if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]); } 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 if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}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(`RM Circle Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');}); // Team-activity alerts: any NEW on-chain event at/below config.teamRootId goes // to the Telegram group topic, with the sponsor's contact email when we have it. // Owner upgrade watcher: emails/Telegrams when an owned position (config.ownerIds) // has a payment about to arrive it can't catch yet, so Marty can upgrade in time. const OWNER_ALERTS_FILE = path.join(DATA_DIR, 'owner-alerts.json'); function loadOwnerAlerts(){ try{ return new Set(readJson(OWNER_ALERTS_FILE)); }catch(e){ return new Set(); } } function parseOwnerIds(){ return [...new Set(String(getConfig().ownerIds||'').split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0))].slice(0,12); } // Self-service member alert subscriptions: memberId -> { email, ts } const MEMBER_ALERTS_FILE = path.join(DATA_DIR, 'member-alerts.json'); if (!fs.existsSync(MEMBER_ALERTS_FILE)) fs.writeFileSync(MEMBER_ALERTS_FILE, '{}'); function getMemberAlerts(){ try{ return readJson(MEMBER_ALERTS_FILE)||{}; }catch(e){ return {}; } } function saveMemberAlerts(o){ writeJson(MEMBER_ALERTS_FILE,o); } const ALERT_SECRET = crypto.createHash('sha256').update('rmc-alerts::'+ADMIN_PASSWORD).digest('hex'); function unsubToken(id){ return crypto.createHmac('sha256',ALERT_SECRET).update('unsub:'+String(id)).digest('hex').slice(0,24); } function unsubUrl(id){ return `https://rmcircle.team/unsubscribe?id=${id}&t=${unsubToken(id)}`; } function maskEmail(e){ const i=String(e).indexOf('@'); if(i<1)return '•••'; return e[0]+'•••'+e.slice(i); } // Upgrade-need alerts — EMAIL ONLY — for owner positions (config.ownerAlertEmail) // and for any member who opted in via their dashboard. Runs every 5 min from state. function checkUpgradeAlerts(){ try{ const c=getConfig(); const watch={}; // id -> [{email, owner}] if(c.ownerAlertEmail) for(const id of parseOwnerIds()){(watch[id]=watch[id]||[]).push({email:c.ownerAlertEmail,owner:true});} const ma=getMemberAlerts(); for(const [idStr,rec] of Object.entries(ma)) if(rec&&rec.email){const id=Number(idStr);(watch[id]=watch[id]||[]).push({email:rec.email,owner:false});} const ids=Object.keys(watch).map(Number); if(!ids.length) return; const res=chain.getOwnerUpgradeNeeds(ids); if(!res.ready) return; const alerted=loadOwnerAlerts(); const active=new Set(); for(const n of res.needs){ const who=n.members.map(m=>'#'+m).join(', '); const action=n.reason==='qualify'?`Position #${n.id} needs its 2 directs to catch this.`:`Upgrade position #${n.id} (now ${n.levelName}) to ${n.neededLevelName} to catch it.`; for(const w of (watch[n.id]||[])){ const key=`${w.email}:${n.id}:${n.reason}:${n.neededLevel}`; active.add(key); if(alerted.has(key)) continue; alerted.add(key); const unsub=w.owner?'':`\n\nStop these alerts: ${unsubUrl(n.id)}`; sendEmailRaw(w.email, `RM Circle: upgrade #${n.id} to ${n.neededLevelName} — ${n.amountAtRisk} POL incoming`, `Heads up — position #${n.id} has money about to arrive it can't catch yet.\n\n#${n.id} is at ${n.levelName}. ${who} ${n.members.length===1?'is':'are'} ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that only stops at #${n.id} if it's at ${n.neededLevelName} and qualified.\n\n${action}\n\nDo it before they upgrade, or the payment passes to the next eligible position above (it doesn't come back).${unsub}\n\n— RM Circle auto-watch`); } } let changed=false; for(const k of [...alerted]) if(!active.has(k)){ alerted.delete(k); changed=true; } if(changed||active.size) writeJson(OWNER_ALERTS_FILE,[...alerted]); }catch(e){ console.error('upgrade alert check', e.message); } } setInterval(checkUpgradeAlerts, 5*60*1000).unref(); setTimeout(checkUpgradeAlerts, 30000).unref(); chain.startIndexer(evt=>{ // Any on-chain event makes cached member views stale (the new member, the // sponsor, every upline's team/pipeline/coach data). Events are infrequent; // just drop the whole member cache so dashboards reflect joins/upgrades on // the next fetch instead of up to 2 minutes later. try{memberCache.clear();}catch(e){} try{ const c=getConfig(); // teamRootId accepts a comma list ("21,136") — alerts fire for ANY listed org const roots=String(c.teamRootId||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0); const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id]; if(roots.length&&ids.some(i=>roots.some(r=>chain.isInTeam(i,r)))){ const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''}; let text; if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`; else if(evt.type==='upgraded')text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.`; else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (${evt.gen?`Gen ${evt.gen} `:''}upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`; if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; sendTelegram(text); // recruiting-framed copy of the SAME event to the new-members topic // (social proof, CTA -> home). Fires only when a recruit topic is set. if(c.telegramRecruitTopicId){ const rm=recruitMsg(evt); if(rm) sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:'https://rmcircle.team/?utm_source=tg'}]]}); } // admin email alert — same team-gated events, so deep-leg action still surfaces if(c.teamAlertEmail){ const subj=evt.type==='registered'?`RM Circle team build: #${evt.id} registered under #${evt.referrerId}` :evt.type==='upgraded'?`RM Circle team build: #${evt.id} upgraded to ${evt.levelName}` :`RM Circle team build: #${evt.toId} paid ${evt.pol.toFixed(2)} POL`; sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,'')); } } }catch(e){console.error('team alert error',e.message)} // "you've been paid" email — sponsor-record contact, and self-service subscribers try{ if(evt.type==='payout'){ const sent=new Set(); const sp=getSponsors().find(x=>String(x.id)===String(evt.toId)); if(sp&&sp.email){sendPaidEmail(sp.email,sp.name,evt);sent.add(sp.email.toLowerCase());} const rec=getMemberAlerts()[evt.toId]; if(rec&&rec.email&&!sent.has(rec.email.toLowerCase()))sendPaidEmail(rec.email,'there',evt,unsubUrl(evt.toId)); } }catch(e){console.error('paid email error',e.message)} // Auto-tweet on-chain payout proof to @cryptoteambuild via Blotato (Marty // 2026-08-15). Org-gated to the #21 organization; OFF unless config.tweetEnabled. try{ if(evt.type==='payout'){ const orgRoot=Number(getConfig().orgRootId)||21; if(chain.isInTeam(evt.toId,orgRoot))tweet.queuePayoutTweet(evt,getConfig()); } }catch(e){console.error('tweet hook error',e.message)} // Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration // whose referrer sits in the rotation queue increments that sponsor's directs. // When it reaches 2/2 the sponsor is auto-qualified and the next waiting // position activates — no manual Qualify click (changed from the earlier // manual design so the rotation never sits stuck at a 2/2 sponsor). try{ if(evt.type==='registered'&&evt.referrerId!=null){ let sponsors=getSponsors(); const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId)); if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){ const newDirects=Math.min(2,(Number(sponsors[idx].directs)||0)+1); const s=sponsors[idx]; if(newDirects>=2){ sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'}; sponsors=normalizeStatuses(sponsors); saveSponsors(sponsors); const next=activeSponsor(sponsors); sendTelegram(`✅ AUTO-ADVANCE: queue sponsor #${s.id}${s.name?` (${s.name})`:''} reached 2/2 and is now QUALIFIED.\nRotation moved to ${next?`#${next.id}${next.name?` (${next.name})`:''}`:'— nobody waiting (add the next position to the queue)'}.`); }else{ sponsors[idx].directs=newDirects; saveSponsors(sponsors); sendTelegram(`🤖 AUTO-COUNT: #${evt.id} is a DIRECT for queue sponsor #${s.id}${s.name?` (${s.name})`:''} — now ${newDirects}/2.`); } } } }catch(e){console.error('auto-direct error',e.message)} // Queue level sync: when a queue member upgrades on-chain, their Level in the // rotation queue follows automatically (same op as the admin level dropdown). try{ if(evt.type==='upgraded'&&evt.id!=null){ const sponsors=getSponsors(); const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id)); if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){ sponsors[idx].level=evt.levelName; saveSponsors(sponsors); sendTelegram(`🤖 AUTO-LEVEL: queue sponsor #${sponsors[idx].id}${sponsors[idx].name?` (${sponsors[idx].name})`:''} upgraded on-chain — queue level updated to ${evt.levelName}.`); } } }catch(e){console.error('auto-level error',e.message)} });