f28804862b
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1972 lines
163 KiB
JavaScript
1972 lines
163 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 messages = require('./messages');
|
||
const profiles = require('./profiles');
|
||
const placement = require('./placement');
|
||
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 });
|
||
profiles.init({ dataDir: DATA_DIR, sendEmail: sendEmailRaw });
|
||
placement.init({ dataDir: DATA_DIR });
|
||
const suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR });
|
||
const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR });
|
||
const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR });
|
||
const suiteTools = require('./suite-tools');
|
||
const suiteEmail = require('./suite-email');
|
||
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
|
||
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
|
||
// Close out ad campaigns that have delivered what was bought. Banners on this
|
||
// network have no cap of their own, so without this an ad runs until expiry
|
||
// and the impression allowance stops meaning anything. Every 15 minutes, and
|
||
// once shortly after boot.
|
||
setTimeout(function(){ suiteTraffic.sweepCompleted().then(function(r){
|
||
if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)');
|
||
}).catch(function(){}); }, 90 * 1000);
|
||
setInterval(function(){ suiteTraffic.sweepCompleted().then(function(r){
|
||
if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)');
|
||
}).catch(function(){}); }, 15 * 60 * 1000);
|
||
const suiteTextAds = require('./suite-textads');
|
||
const suiteVoice = require('./suite-voice'); suiteVoice.init({ dataDir: DATA_DIR });
|
||
const suiteSplit = require('./suite-split'); suiteSplit.init({ dataDir: DATA_DIR });
|
||
const suiteLeader = require('./suite-leader');
|
||
const suiteFounder = require('./suite-founder'); suiteFounder.init({ dataDir: DATA_DIR });
|
||
const suiteGrants = require('./suite-grants'); suiteGrants.init({ dataDir: DATA_DIR });
|
||
const suiteIntel = require('./suite-intel'); suiteIntel.init({ dataDir: DATA_DIR });
|
||
const suiteBrand = require('./suite-brand'); suiteBrand.init({ dataDir: DATA_DIR });
|
||
const tgbot = require('./tgbot');
|
||
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
|
||
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();
|
||
let a = null; try { const ex = new Set(String(c.rotationExcludeIds||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0)); a = chain.nextOpenPosition(Number(c.publicRotationRootId)||2, ex); } catch (e) {}
|
||
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". Their personal invite link (rmcircle.team/join/<their-id>) NEVER retires: it's a MOVING LINK built into the site — once the member is qualified it automatically routes each new signup to the next position in their leg that needs a direct, so members keep promoting one link forever and their promotion qualifies their team. Their personal effort shifts to helping their 2 get their 2 — a position only qualifies through its own directs. If a signup lands straight on a qualified position's raw ID anyway (the dApp path), 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 at or above the level the buyer is LEAVING (a qualified Scintilla catches their matrix children's Ascensus buys; a Culmen catches an Apex buy from 4 layers down) — 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
|
||
- TWO SHARE LINKS (on the dashboard "Share this position" panel): (1) TEAM LINK = the default rmcircle.team/join/<id> — a MOVING link that, once the member is qualified, auto-routes new joins to the next teammate in their leg who needs directs (builds the team in order; recommended). (2) DIRECT LINK = rmcircle.team/join/<id>?direct=1 — opts OUT of rotation so every join lands DIRECTLY under that member as spillover (builds their own depth). Both pay the member the entry reward on anyone they personally bring. Recommend the Team link (team-first), but the Direct link exists for members who want to stack spillover under themselves. Angle links (?v=pocket|phone|two|graveyard) work on either.
|
||
- 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 qualified AND at or above the level that person is LEAVING (layer 1's Ascensus buy needs you at Scintilla+, layer 3's Culmen buy needs you at Fabrica+, layer 4's Apex buy needs you at Culmen+) — so keep your level at or above 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.
|
||
- THE TRIPLE PLAY (optional fast-start; people ask "should I get one position or three?"): Most start with ONE position — get 2 directs, help them, duplicate (the standard path, all anyone needs). OPTIONAL alternative: buy a top position PLUS its own 2 directs at the same time. Those two count as the top's directs, so the TOP is instantly qualified (2/2) and catching upgrade pass-ups from day one; then you promote only the top position's moving link, which auto-routes new joiners to fill/qualify the positions beneath. Because ONE WALLET ADDRESS CAN ONLY HOLD ONE POSITION (blockchain rule), this needs THREE separate addresses. Foolproof = a separate wallet (or browser profile) per position. You CAN use "Add account" in one MetaMask (Account 1/2/3 are separate addresses), BUT MetaMask's account switch doesn't always follow into a connected site — if the join page still shows the old address the join reverts with "already registered", so you must confirm the NEW address is shown before signing each (or disconnect+reconnect the site with the new account). Separate wallets avoid this. HARD framing to always include: costs ~3x a single entry; ONLY money you can comfortably afford to lose; NEVER borrowed; NEVER front/fund it for someone else (it puts one person in control of positions on another's wallet); it's a faster START, not an income guarantee; and one position is perfectly fine. Never push it — present it and let them choose. UPGRADE STRATEGY for a Triple Play: most members (including the top team builder holding #21) CONCENTRATE UPGRADES ON THE TOP position and leave wallets 2 & 3 lower — because pass-ups the lower positions aren't leveled to catch bubble UP to the first eligible position (the catcher-of-last-resort mechanic), so keeping the TOP most-leveled funnels the deep catches into the top wallet, and since you own all three the money stays in your family; upgrading all three works too but costs 3x the upgrades, so concentrating on the top is leaner. Upgrades always optional/self-paced/funded by earnings, never out of pocket. THE CORONA CATCH (2026-09-16, highlight this): a Corona purchase pays the buyer's 7th upline, so a member's Generation 8 (256 positions) pays its Corona buys (39,770 POL each) to that member's Generation 1, normally two other people; with the Triple Play Generation 1 = the member's own Wallets 2 and 3, so those payments stay in the family — caught directly if those wallets hold Vertex and are qualified (up to 256 × 39,769.75 = 10,181,056 POL at full fill), else passed over to Wallet 1 if it holds Vertex and is qualified. It is the only scenario in which a position collects on the top-level buys of a tree 8 deep beneath it; always caption as mechanical maximum. Full steps: https://rmcircle.team/how-pay-works#triple-play.
|
||
- LEVEL CAP / "does it stop at 8 levels?": there are 8 levels (Scintilla..Corona); Corona is the max LEVEL — you can't upgrade past it. But EARNING is not capped. (1) You catch an upgrade pass-up from every person in your first seven generations as they level up, and those generations hold up to 254 positions that keep filling via recruiting + spillover — one payment per person, but people keep arriving. (2) Pass-ups travel UP past anyone who can't catch them: an upgrade payment rises to the first upline who is qualified (2 directs) AND leveled high enough (level >= the level the buyer is leaving), searching up to the 16-layer MAX_MATRIX_DEPTH; if nobody qualifies in range it goes to the project fee wallet. So a Corona member is the "catcher of last resort" — at max level they're eligible for every pass-up, never the bottleneck, and they catch the ones under-leveled members below them let slip. That's the mechanical reason to keep leveling up. Full explainer: https://rmcircle.team/how-pay-works#cap. Still contract mechanics, never an income promise.
|
||
- 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 — each recommendation has a one-tap "Send this nudge" button that sends a ready-written teach-forward message over the wallet-verified Messages system, delivered on-site and to Telegram if the member linked it), 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/<their-id>.
|
||
- 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 company-rotation sponsor: ${a ? `ID ${a.id}, ${a.directCount}/2 directs` : 'shown on the start page'} — the next open position in the company tree, read live from the chain. It moves the moment that position fills, so always verify on https://rmcircle.team/start right before joining. A member's own invite link is different: it places people inside that member's own team.
|
||
- 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 10-lesson course in 3 modules; it is the MEMBERS-AREA product: Lesson 1 is the free public preview, Lessons 2-10 unlock by signing in with the wallet that owns a position (one free signature, right on the training page) or automatically inside the Telegram Mini App; every lesson's title and description stays visible so prospects can see what's included. 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 — for MEMBERS 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. For someone NOT yet a member, answer the substance directly yourself and mention that the full lesson is included with their position (never send a prospect a locked link as the answer). Deep links: /training#lesson-N — plus 10 how-to videos (incl. "The Textbook Play" — 111s: a top team builder's own position (#21, one of the builders near the top — NOT a program founder) shown as a ledger, every upgrade funded by a prior catch, this week's Apex catch arriving and funding his Apex the same hour; route "does this actually work / has anyone done this" questions here at /training#textbook-play, always with the no-income-promise framing; and Video 7 "Your level is your reach" — 97s on catch eligibility: qualified members catch their two's first upgrades without owning the level, each owned level extends reach one generation deeper, uncatchable payments pass over) — team overview, wallet setup, funding, the new connect-wallet join flow on the site, the dApp backup method, how payments work, a full 14-min Member Dashboard walkthrough, and an 8-min REAL e-gift-card cash-out walkthrough (/training#egift-video, MEMBERS-ONLY like the Method lessons: POL → CWallet → dollar swap → gift card, ending with the virtual card in a mobile wallet ready to tap-to-pay) — + spillover article; the join-funnel and transparency videos are always public), https://rmcircle.team/how-pay-works (the two income streams shown as a pay-flow diagram + Premium/Standard tier comparison + a "What each level opens up" scaling table at /how-pay-works#level-scale showing how each level lets you catch a deeper, wider, higher-paying generation of pass-ups — explicitly framed as the contract's mechanical maximum at full fill, NOT a prediction or income promise; READING THE TABLE (2026-09-16, a leader read it one rung off): each row names the LEVEL YOU HOLD (your reach) and, since the update, also the LEVEL THAT GENERATION BUYS, which is one rung higher — holding Fabrica catches your 3rd generation (8 people) when they buy Culmen at 2,486 each; if someone labels rows by the level being bought their rows shift down one but every number still matches; the right-hand column is one generation only, never a running total: through the Fastigium row the sum is 1,696,429 POL, the Vertex row alone is generation 7 (128 people buying Corona) = 5,090,529 POL; plus a "Does it cap out at 8 levels?" explainer at /how-pay-works#cap, and a contract-exact price table at /how-pay-works#exact-costs read live from getAllCosts(): Premium entry base 343.41 POL — member sends 360.58 incl the 5% admin charge on entries only (public charts round to ~362 as a send buffer), sponsor receives 326.24 (95% of base); upgrades are a separate ladder paid 100% member-to-member up the matrix with zero admin fee — 621.40 to reach level 2, doubling EXACTLY each level (2×621.40=1,242.80 to the penny) so two same-level pass-ups always fund the member's own next upgrade. USE THIS to answer "why is entry a different price than upgrade / is the income chart inflated": they are two different purchases paid to two different people (entry→sponsor, upgrade→upline), income illustrations are built only from the upgrade stream, and every price is a fixed contract constant verifiable on Polygonscan. IMPORTANT: levels can NEVER be skipped — register() has no level parameter (every position enters at Scintilla) and upgrade() takes no target (exactly one rung per call); company charts listing an "entry price" for levels 2-8 show vestigial contract data rows that no function sells — total to hold Culmen = 360.58+621.40+1,242.80+2,485.61 = 4,710.39 POL, climbed one level at a time. CATCH-ELIGIBILITY NUANCE (verified in source): the payout loop pays the first non-skipped upline with directCount≥2 AND level GREATER THAN the buyer's pre-upgrade level minus one — i.e. the catcher's level must be AT OR ABOVE the level the buyer is LEAVING, not the level being bought. So a qualified member still at Scintilla DOES catch their two matrix children's Ascensus purchases in full; owning higher levels extends REACH (Fabrica catch from 2 generations down needs Ascensus, etc. — one structural upline skip per level of depth). Depth pays fixed counts (2/4/8… one payment per person) but per-person size doubles with width (full-generation value quadruples per level), generations keep filling over time, and pass-ups add catches), 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/suite (THE CIRCLE SUITE — LIVE AND OPEN TO EVERY POSITION. Promote it freely, and lead with it whenever anyone asks "what is the actual product" or "what do I actually get" — it is the strongest answer we have: most programs hand you a referral link, this one hands you working software too. The team's marketing-toolkit portal: every paid position is a LICENSE to the toolkit; members connect their position's wallet (one free signature, same sign-in as the training gate and Mini App bridge) and their live contract level lights up their tools on a visible tool wall — Level 1 today includes the Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, Circle Method, Live Dashboard, and the AI coach; higher-level tiles (Copy Engine L2, Page Builder L2, Email Engine + Video Maker L3, Voice Profile + Funnels L4, Traffic Desk L5 (syndicated network display advertising — banner/text placements on the team's own ad network, monthly ad credits, rotator priority, AI campaign packs), Funnel Factory L6 (hosted funnels + Replay Funnels — an evergreen registration page around a recorded team webinar with a timed CTA, NOT live video conferencing/Zoom — + lead CRM), Leader Ops L7, Founder Desk L8) are shown honestly as IN DEVELOPMENT and unlock automatically as members upgrade once shipped; the Suite is included with membership, never sold), https://rmcircle.team/generation-pay (printable Generation Pay chart — the full when-does-each-generation-pay-me table with exact per-person POL amounts; enter a member ID and it personalizes from the live chain: shows the member's tier/level/qualification and marks each generation "catching now" vs "needs Level N"; Gen 8 pays the member's Gen 1, pass-overs only; Standard positions pay half; ROUTE members here for any generation-pay / who-pays-me-when question), 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), REQUIRED MEMBER PROFILE (2026-09-16): the first time someone proves they own a position (wallet personal_sign, or automatically in the Telegram Mini App) they must set a USERNAME and confirm an EMAIL with a 6-digit code before the member area opens; it cannot be skipped. Reason given to members: their leader can reach them, and they get an email the moment a payout lands. The email is never shown to other members and never sold, is changeable, and the 40 positions that already gave an email for payout alerts have it pre-filled to confirm. One email may hold several positions (Triple Play). The PUBLIC page rmcircle.team/my/<id> is unchanged and never asks for anything, and no profile can be written from it. 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 (incl. a 1280×720 Telegram group-ad image with a tap-the-link-below CTA — members pair it with their own Telegram-native invite link in the caption), a Printable Handouts maker at /flyers (linked from /tools#flyers and the dashboard) — five bold full-color half-sheet handout designs with detailed artwork (one per angle: pocket change, two people, phone, side-hustle graveyard, stop waiting) that print two copies per letter page with a cut line so members can print, cut, and hand out stacks, for offline/belly-to-belly promotion (coffee shops, gyms, community & church bulletin boards, laundromats); enter the member ID once and every handout personalizes with the member's own QR code and invite link overlaid on the artwork (angle-matched ?v= links so the landing page continues the hook); print in color for impact and the QR scans in black-and-white too, 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=<hook> 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; NEW Promote-on-Telegram section at /tools#telegram — the member's Telegram-native Mini App invite links (t.me links that open the whole tour INSIDE Telegram; tapping your own link previews the prospect view), paste-ready Telegram group posts + DMs, and the setup message to forward to their team; Text-a-friend section at /tools#text-a-friend — 5 SMS-sized messages (general + one per angle video, matched to that video's landing page) with one-tap share buttons: Text it (opens the phone's messaging app pre-filled), WhatsApp (wa.me pre-fill), Telegram (shares the member's Mini App invite), Copy for Messenger/Instagram DMs; to write promos in their own voice, mybrandedvoice.com; plus an Objection Handling bank at the bottom — truth + ready-to-send reply per objection, incl. the what's-the-product / members-area answer), 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/<id>) — 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.
|
||
- TELEGRAM COMPANION BOT: members can link their position (dashboard → Messages → "Connect Telegram", wallet-verified) to get instant payout DMs, native Telegram delivery of team messages (reply in Telegram to answer — matrix-line rules still apply), joined-on-your-link pings, and their invite/angle links via the "links" command. This finally lets members reach their downline as real people instead of just IDs — while handles stay private (the bot relays). Linked members can also tap the bot's ☰ menu button to open the MINI APP — the full live dashboard, promo tools, and Circle Method training right inside Telegram with zero login (Telegram itself proves who they are). Prospects can JOIN from the Mini App too: a member's Telegram-native invite link (the "links" command in the bot shows it) opens the sponsor's invite page right inside Telegram, and the join itself finishes in the prospect's own wallet app's secure browser — same zero-custody flow as the website. The website stays fully available too; the Mini App is a convenience door, not a replacement.
|
||
- 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, and an 8-min video of a REAL cash-out done on screen at /training#egift-video (recommend it — seeing it done answers most doubts). 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'}
|
||
- PUBLIC PAYMENT PROOF CHANNEL: https://t.me/rmcircleproof (RM Circle Proof). Every on-chain payout is announced there in real time with a Polygonscan link. Linked from the Live payment proof section on the home and join pages. Send people there for payment proof, not to the team help group.
|
||
- 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.
|
||
- "Is there a members area?": YES — a position unlocks: the Circle Method course (10 lessons + the real cash-out walkthrough) in the training members area, the weekly team webinar replays at https://rmcircle.team/replays, the live coaching dashboard at /my/<id>, promo tools personalized with their link at /tools, printable playbooks, wallet-verified team messaging, and the Telegram companion bot + Mini App. Access is by the wallet that owns the position — one free signature, no passwords — or automatic inside the Mini App.
|
||
- "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 in the members area (Lesson 1 is a free preview; the rest unlocks with your position); (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, dedupeKey) {
|
||
const c = getConfig();
|
||
return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup, undefined, dedupeKey);
|
||
}
|
||
// Same bot, any chat: the team forum, or the public payment-proof channel
|
||
// (config.telegramProofChatId — the bot just has to be an admin there).
|
||
// Last-resort duplicate guard, at the point of send.
|
||
//
|
||
// 2026-09-18: members saw the same payout line posted twice in the proof channel and the
|
||
// shared payments topic — "did this person get paid twice?" The money was always right; only
|
||
// the announcement doubled. The indexer now has a durable announce-once key per on-chain
|
||
// event, which is the real fix, but a payment feed is the last place to rely on a single
|
||
// layer: anything upstream that replays an event would put a second receipt in front of
|
||
// members and make them doubt the ledger. So the sender itself refuses to post the same text
|
||
// to the same chat twice within the window. Distinct events never collide — every line
|
||
// carries its own ids, amount and transaction hash.
|
||
const TG_INSTANCE = Math.random().toString(36).slice(2, 8);
|
||
|
||
// Post each EVENT once per chat — permanently, and across processes.
|
||
//
|
||
// The first attempt at this deduped on message TEXT within 15 minutes, which was wrong in
|
||
// both directions: two different registrations produce identical "the team just grew" copy,
|
||
// so a legitimate post got swallowed, while anything with a different tx link slipped past.
|
||
//
|
||
// The key is now the on-chain event itself. It lives in a file on the volume, written before
|
||
// the request goes out, so a replay, a restart mid-tick, or two containers overlapping during
|
||
// a deploy all collapse to a single post. A payment feed that repeats itself makes members
|
||
// doubt the ledger, so this is deliberately belt-and-braces rather than clever.
|
||
const TG_POSTED_FILE = path.join(DATA_DIR, 'tg-posted.json');
|
||
function tgAlreadyPosted(chatId, key) {
|
||
if (!key) return false;
|
||
const k = String(chatId) + '|' + key;
|
||
let rec = {};
|
||
try { rec = JSON.parse(fs.readFileSync(TG_POSTED_FILE, 'utf8')) || {}; } catch (e) { rec = {}; }
|
||
if (rec[k]) return true;
|
||
rec[k] = Date.now();
|
||
const keys = Object.keys(rec);
|
||
if (keys.length > 5000) {
|
||
keys.sort((a, b) => rec[a] - rec[b]).slice(0, keys.length - 4000).forEach(x => delete rec[x]);
|
||
}
|
||
try {
|
||
const tmp = TG_POSTED_FILE + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify(rec));
|
||
fs.renameSync(tmp, TG_POSTED_FILE);
|
||
} catch (e) { console.error('tg-posted write failed', e.message); }
|
||
return false;
|
||
}
|
||
// a stable id for one on-chain event, so the same event never posts to the same chat twice
|
||
function tgEventKey(evt) {
|
||
if (!evt || !evt.tx) return null;
|
||
return evt.type + ':' + evt.tx + ':' + (evt.toId || evt.id || '') + ':' + (evt.fromId || '') + ':' + (evt.level || evt.pol || '');
|
||
}
|
||
function sendTelegramTo(chatId, text, topicId, replyMarkup, parseMode, dedupeKey) {
|
||
const c = getConfig();
|
||
if (!c.telegramBotToken || !chatId) return;
|
||
if (tgAlreadyPosted(chatId, dedupeKey)) {
|
||
console.warn('telegram duplicate suppressed:', dedupeKey, '->', String(chatId));
|
||
return;
|
||
}
|
||
// No link previews: payout/team-build posts carry Polygonscan links, and the
|
||
// preview card tripled the height of every message in the group.
|
||
const payload = { chat_id: chatId, text, disable_web_page_preview: true };
|
||
if (parseMode) payload.parse_mode = parseMode;
|
||
const thread = topicId;
|
||
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=>{
|
||
const body = await r.json().catch(()=>null);
|
||
if(!r.ok || !body || !body.ok) { console.error('telegram sendMessage status', r.status, JSON.stringify(body).slice(0,200)); return; }
|
||
// One concise line per send, kept deliberately: it is what identified a second sender
|
||
// posting into the same channel with the same bot token on 2026-09-18. A payment feed
|
||
// is worth being able to audit.
|
||
console.log('tg-ok', TG_INSTANCE, 'chat=' + chatId, 'message_id=' + (body.result && body.result.message_id));
|
||
})
|
||
.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, no internal/contact detail. CTA destination is
|
||
// config.recruitCtaUrl (e.g. Marty's live dashboard as proof-first landing);
|
||
// defaults to the home page.
|
||
// Company payment-proof channel: ONE compact line per event, team-build style,
|
||
// no hashtags, no join CTA (the pinned daily snapshot carries how-to-join).
|
||
// HTML parse mode so the receipt is a short "Polygonscan" link, not a raw hash.
|
||
function proofMsg(evt) {
|
||
const esc = t => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
const rx = evt.tx ? ` · <a href="https://polygonscan.com/tx/${evt.tx}">Polygonscan ↗</a>` : '';
|
||
if (evt.type === 'payout') {
|
||
const why = evt.kind === 'upline'
|
||
? `${evt.gen ? `Gen ${evt.gen} ` : ''}upgrade pass-up from #${evt.fromId}`
|
||
: `referral reward from #${evt.fromId}`;
|
||
let t = `💸 <b>#${evt.toId}</b> got paid <b>${evt.pol.toFixed(2)} POL</b> · ${esc(why)}${rx}`;
|
||
const tools = evt.kind === 'upline' ? suiteTools.unlockText(0, evt.levelName, getConfig()) : '';
|
||
if (tools) t += `\n🧰 #${evt.fromId} reached ${esc(evt.levelName)} and unlocked ${esc(tools)}`;
|
||
return t;
|
||
}
|
||
if (evt.type === 'upgraded') {
|
||
const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig());
|
||
return `⬆️ <b>#${evt.id}</b> upgraded to <b>${esc(evt.levelName)}</b>${tools ? ` · unlocked ${esc(tools)}` : ''}${rx}`;
|
||
}
|
||
if (evt.type === 'registered') return `📈 <b>#${evt.id}</b> joined under #${evt.referrerId} (${esc(evt.tierName || 'Premium')})${rx}`;
|
||
return '';
|
||
}
|
||
function recruitMsg(evt, homeOverride) {
|
||
const home = String(homeOverride || getConfig().recruitCtaUrl || 'rmcircle.team').replace(/^https?:\/\//, ''), 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') {
|
||
const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig());
|
||
const toolLine = tools ? `\n\nThat upgrade also unlocked ${tools} — every paid level extends the toolkit that comes with your position.` : '';
|
||
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.${toolLine}\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 <no-reply@marketingwithmarty.com>'; }
|
||
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));
|
||
}
|
||
// CTB Rewards featured offer: relay a verified 30s visit so CTB credits the
|
||
// user's coins. Contract per D:\Projects\CTBRewards\FEATURED-OFFER-SPEC.md:
|
||
// POST {clickid, event:'engaged', ts} + X-Offer-Secret; any 200 = done.
|
||
const ctbRelayed = new Set();
|
||
function fireCtbOfferPostback(clickid) {
|
||
const c = getConfig(), url = String(c.ctbOfferPostbackUrl || '').trim(), secret = String(c.ctbOfferSecret || '').trim();
|
||
if (!url || !secret || !/^https:\/\/[a-z0-9.-]+\//i.test(url)) return;
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Offer-Secret': secret },
|
||
body: JSON.stringify({ clickid, event: 'engaged', ts: Math.floor(Date.now() / 1000) })
|
||
})
|
||
.then(async r => {
|
||
const d = await r.json().catch(() => ({}));
|
||
if (r.ok) { recordEvent('ctb-offer-' + (d.status || 'ok'), 'ctbrewards'); console.log('ctb offer postback:', clickid.slice(0, 24), d.status, d.coins || ''); }
|
||
else console.error('ctb offer postback status', r.status, d.status || '');
|
||
})
|
||
.catch(e => console.error('ctb offer 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; }
|
||
// Which link produced this join. In 'chain' mode the company link always offers the
|
||
// SHALLOWEST open position under the root, so a join whose on-chain referrer sits deeper
|
||
// than some other open position cannot have come from it: it came through somebody's
|
||
// personal link. A personal page also tags its submissions 'invite-<id>', which settles it
|
||
// outright. The old queue-membership test is kept only for the legacy 'queue' mode.
|
||
// (2026-09-19: #861's own join under Terry #840 was called a rotation join because Terry
|
||
// had once qualified through the queue, and #861 was swept into a queue that no longer
|
||
// places anyone.)
|
||
const cfgNow = getConfig();
|
||
let joinPath = 'unknown';
|
||
if (onchain && onchain.registered) {
|
||
const R = Number(onchain.referrerId);
|
||
if (/^invite-/.test(source)) joinPath = 'leg';
|
||
else {
|
||
const root = Number(cfgNow.publicRotationRootId) || 2;
|
||
const exclude = new Set(String(cfgNow.rotationExcludeIds || '').split(',').map(n => Number(n.trim())).filter(n => n > 0));
|
||
exclude.add(R);
|
||
const dR = chain.depthFrom(root, R);
|
||
const other = chain.nextOpenPosition(root, exclude);
|
||
joinPath = (dR != null && (!other || other.depth >= dR)) ? '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') {
|
||
// company-link join: announce it; the queue is retired and nothing is added to it
|
||
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(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); }
|
||
// Event pop-up (announce.js) defaults — seeded into config once so the admin
|
||
// panel shows the real state and admins can toggle/edit it without a deploy.
|
||
const ANNOUNCE_DEFAULTS = {
|
||
announceEnabled: true,
|
||
announceId: 'huddle-2026-09-08',
|
||
announceImg: '/huddle-flyer.jpg',
|
||
announceMeetUrl: 'https://meet.google.com/gsw-yhqn-zrc',
|
||
announceEyebrow: 'Team RM Circle · Weekly Huddle',
|
||
announceDateLabel: 'Tuesday, September 8',
|
||
announceTimes: '7:00 PM CST | USA / Canada\n8:00 PM EST |\n8:00 PM AST | Caribbean\n1:00 AM | UK',
|
||
announceExpiresUTC: '2026-09-09T02:30:00Z',
|
||
announceStartUTC: '',
|
||
announceDurationMin: 60,
|
||
// Promo overlay (announce.js, second mode): a flyer shown ONCE PER SESSION to members, with one button.
|
||
// Unlike the event pop-up (once per browser, with times + meet link) this is a plain nudge (Marty, 2026-09-16).
|
||
promoEnabled: true,
|
||
promoId: 'upgrade-2026-09-16',
|
||
promoImg: '/upgrade-flyer.jpg',
|
||
promoEyebrow: 'Your upgrade unlocks your reward',
|
||
promoCtaText: 'See my next step →',
|
||
promoCtaUrl: '/my',
|
||
promoPages: 'my,app',
|
||
promoExpiresUTC: ''
|
||
};
|
||
function seedAnnounceDefaults() {
|
||
try { const c = readJson(CONFIG_FILE) || {}; let ch = false;
|
||
for (const k in ANNOUNCE_DEFAULTS) if (!(k in c)) { c[k] = ANNOUNCE_DEFAULTS[k]; ch = true; }
|
||
if (ch) writeJson(CONFIG_FILE, c);
|
||
} catch (e) {}
|
||
}
|
||
seedAnnounceDefaults();
|
||
function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
|
||
function recordEvent(event, source) {
|
||
if (!['bridge','start','click','training','postback','purchase','join','joinnow','engaged'].includes(event) && !String(event).startsWith('ctb-offer-')) 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);
|
||
}
|
||
|
||
// --- 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<parts.length;k+=2){ arr[Number(parts[k])-1]=String(parts[k+1]).trim(); }
|
||
// per-item acceptance: one malformed item must never poison its batch
|
||
let got=0;
|
||
miss.forEach((idx,j)=>{
|
||
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<miss.length)console.error(`translate ${tl}: ${got}/${miss.length} items parsed`);
|
||
}else{ console.error('translate openrouter status',r.status); }
|
||
}catch(e){ console.error('translate error',e.message); }
|
||
}
|
||
if(!done)miss.forEach(i=>{out[i]=texts[i]});
|
||
}
|
||
return json(res,200,{t:out});
|
||
}
|
||
|
||
// frame-src includes 'self' so our own pages (the Page Builder preview framing
|
||
// a member's /p/<id> page) are not blocked by our own policy.
|
||
const CSP_BASE="default-src 'self'; script-src 'self' https://polhunter.com https://www.networkadspace.com https://networkadspace.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https://coolify-saasytop.nyc3.digitaloceanspaces.com; connect-src 'self' https://polhunter.com; font-src 'self' data:; form-action 'self'; frame-src 'self' https://www.youtube-nocookie.com"; // polhunter.com: the PolHunter embed on /training (2026-09-19)
|
||
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','.vtt':'text/vtt; charset=utf-8','.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,walletNotice:c.walletNotice||''});
|
||
}
|
||
if(req.method==='GET'&&pathname==='/api/announce'){
|
||
const c=getConfig();
|
||
return json(res,200,{
|
||
enabled: c.announceEnabled===undefined ? true : !!c.announceEnabled,
|
||
id: c.announceId||'', img: c.announceImg||'', meetUrl: c.announceMeetUrl||'',
|
||
eyebrow: c.announceEyebrow||'', dateLabel: c.announceDateLabel||'',
|
||
times: c.announceTimes||'', expiresUTC: c.announceExpiresUTC||'',
|
||
startUTC: c.announceStartUTC||'', durationMin: Number(c.announceDurationMin)||60,
|
||
promo: { enabled: !!c.promoEnabled, id: c.promoId||'', img: c.promoImg||'', eyebrow: c.promoEyebrow||'', ctaText: c.promoCtaText||'', ctaUrl: c.promoCtaUrl||'/my', pages: c.promoPages||'my,app', expiresUTC: c.promoExpiresUTC||'' }
|
||
});
|
||
}
|
||
// calendar file for the event pop-up (Apple Calendar / Outlook); Google uses a render URL built client-side
|
||
if(req.method==='GET'&&pathname==='/announce.ics'){
|
||
const c=getConfig();const st=Date.parse(c.announceStartUTC||'');
|
||
if(!st)return json(res,404,{error:'No event start set.'});
|
||
const dur=(Number(c.announceDurationMin)||60)*60000;
|
||
const fmt=d=>new Date(d).toISOString().replace(/[-:]|\.\d{3}/g,'');
|
||
const esc=v=>String(v||'').replace(/\\/g,'\\\\').replace(/;/g,'\\;').replace(/,/g,'\\,').replace(/\r?\n/g,'\\n');
|
||
const title=c.announceEyebrow||'Team RM Circle · Weekly Huddle';
|
||
const desc=(c.announceMeetUrl?'Join: '+c.announceMeetUrl+'\n':'')+String(c.announceTimes||'').replace(/\|/g,' ');
|
||
const ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//RM Circle//Event//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','BEGIN:VEVENT',
|
||
'UID:'+(c.announceId||'event')+'@rmcircle.team','DTSTAMP:'+fmt(Date.now()),'DTSTART:'+fmt(st),'DTEND:'+fmt(st+dur),
|
||
'SUMMARY:'+esc(title),'DESCRIPTION:'+esc(desc),'LOCATION:'+esc(c.announceMeetUrl||''),
|
||
...(c.announceMeetUrl?['URL:'+c.announceMeetUrl]:[]),
|
||
'BEGIN:VALARM','TRIGGER:-PT30M','ACTION:DISPLAY','DESCRIPTION:'+esc(title),'END:VALARM','END:VEVENT','END:VCALENDAR'].join('\r\n')+'\r\n';
|
||
res.writeHead(200,{'Content-Type':'text/calendar; charset=utf-8','Content-Disposition':'attachment; filename="rm-circle-huddle.ics"','Cache-Control':'no-store'});
|
||
return res.end(ics);
|
||
}
|
||
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)}`;
|
||
// Where a member's team link sends the next join: the nearest position in THEIR OWN
|
||
// leg that still needs its two, in tree order ("your downline, in order", the words
|
||
// the training video uses). The walk starts at the member's own children, so it can
|
||
// never leave the leg. The old rotation queue used to be consulted first here; it no
|
||
// longer places anyone, so it no longer steers this either (2026-09-19).
|
||
if(r.registered&&r.subtree){
|
||
try{
|
||
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);}
|
||
const pick=bfs.find(n=>(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{
|
||
// Whole leg qualified: the join lands on the member themselves as depth. It does NOT
|
||
// fall through to the COMPANY rotation — that would hand a member's own referral to
|
||
// a position they have never met. "Team rotation" means rotation inside their own
|
||
// downline, and nothing else. (Marty, 2026-09-18; this fall-through is what offered
|
||
// Terry #840's prospects company position #148.)
|
||
r.joinTarget={id,referralUrl:r.referralUrl,reason:'spillover'};
|
||
}
|
||
}
|
||
// Positions that default to DIRECT placement. A moving link is team-first:
|
||
// it routes new joins to whoever needs directs next, so the position whose
|
||
// link was actually clicked earns nothing. For a position being deliberately
|
||
// built out that is backwards - this flips the default so /join/<id> behaves
|
||
// as ?direct=1 unless ?direct=0 is passed explicitly.
|
||
try{
|
||
const pl=placement.get(id,getConfig());
|
||
r.directDefault = pl.mode==='direct';
|
||
r.placement = pl; // mode + whether they chose it + which sponsor it came from
|
||
}catch(e){ r.directDefault = false; }
|
||
|
||
// 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};
|
||
// What the upgrade actually BUYS. The panel used to show a price with no
|
||
// reason attached, which is how 53 qualified members read it and closed
|
||
// the tab. Owning level L catches generation L, so this rung opens the
|
||
// generation numbered nextLevel — and because upgrade costs double every
|
||
// rung, the first catch there is exactly twice what this rung costs.
|
||
try{
|
||
const opensGen=lvl+1;
|
||
const perCatch=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl])||0;
|
||
const share=chain.getOrgShare(id);
|
||
const genSize=(share&&share.ready&&share.found&&Array.isArray(share.genCounts))
|
||
? (share.genCounts[opensGen-1]||0) : null;
|
||
r.nextStep.opens={gen:opensGen,perCatch,genSize};
|
||
}catch(e){}
|
||
}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=await messages.verifyChallenge(b.address,b.signature,b.id);
|
||
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)});
|
||
}
|
||
// Positions on the team root's own upline - i.e. everyone above us, up to and
|
||
// including the contract root. Fixed at registration, so cache indefinitely.
|
||
let _suiteUpCache=null, _suiteUpKey='';
|
||
async function suiteRootUpline(roots){
|
||
const key=roots.join(',');
|
||
if(_suiteUpCache&&_suiteUpKey===key)return _suiteUpCache;
|
||
const out=[];
|
||
for(const r of roots){
|
||
try{
|
||
const rd=await chain.memberPublic(r);
|
||
if(rd&&Array.isArray(rd.uplineChain))rd.uplineChain.forEach(x=>{const n=Number(x);if(n&&!out.includes(n))out.push(n);});
|
||
}catch(e){}
|
||
}
|
||
if(out.length){_suiteUpCache=out;_suiteUpKey=key;}
|
||
return out;
|
||
}
|
||
|
||
// Sign out / switch position. Needed because one person can hold several
|
||
// positions (the Triple Play we actively recommend), and without this the
|
||
// 30-day cookie pins them to whichever one they signed in as first.
|
||
if(req.method==='POST'&&pathname==='/api/public/signout'){
|
||
try{ messages.clearSession(req); }catch(e){}
|
||
return json(res,200,{ok:true},{'Set-Cookie':messages.clearCookie()});
|
||
}
|
||
|
||
// ── Circle Suite entitlement helper (shared by suite-me and the tools) ────
|
||
async function suiteEntitlement(req){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return {error:'Not signed in.',code:401};
|
||
const cached=memberCache.get(s.id);
|
||
let d;
|
||
if(cached&&Date.now()-cached.ts<120000)d=cached.data;
|
||
else{
|
||
d=await Promise.race([chain.memberPublic(s.id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('timeout')),20000))]);
|
||
memberCache.set(s.id,{ts:Date.now(),data:d});
|
||
}
|
||
if(!d||!d.registered)return {error:'Position not found.',code:404};
|
||
const cfg=getConfig();
|
||
const roots=String(cfg.teamRootId||cfg.orgRootId||'21').split(',').map(x=>Number(x.trim())).filter(Boolean);
|
||
const chainIds=Array.isArray(d.uplineChain)?d.uplineChain.map(Number):[];
|
||
// The org test only looks DOWNWARD: you are in if a root sits in your upline.
|
||
// The people ABOVE our root - the founders and contract owners, up through
|
||
// position #2 and the root account - can never pass that, yet the Suite is
|
||
// as much theirs as ours. Walk each root's own upline and entitle those
|
||
// positions too. Registration fixes a position's upline permanently, so this
|
||
// is computed once and memoised rather than hit on every request.
|
||
const upIds=await suiteRootUpline(roots);
|
||
const inOrg=roots.some(r=>Number(d.id)===r||chainIds.includes(r))||upIds.includes(Number(d.id));
|
||
const allow=String(cfg.suiteAllowlist||'').split(',').map(x=>Number(x.trim())).filter(Boolean);
|
||
const beta=allow.length>0;
|
||
const allowed=!beta||allow.includes(Number(d.id));
|
||
|
||
// Admin-only tool preview. `suiteLevelOverride` is "id:level,id:level" and
|
||
// ONLY raises what the tool wall unlocks, so the owner can walk every tier
|
||
// without buying upgrades to test his own product. It touches nothing
|
||
// financial: payouts, the contract and every number shown to a member still
|
||
// come from the real level, which is kept on trueLevel. We clone rather than
|
||
// mutate because `d` is the shared member cache.
|
||
let dd=d;
|
||
const ovMap={};
|
||
String(cfg.suiteLevelOverride||'').split(',').forEach(pair=>{
|
||
const [k,v]=String(pair).split(':').map(x=>Number(String(x).trim()));
|
||
if(k&&v>=1&&v<=8)ovMap[k]=v;
|
||
});
|
||
const ov=ovMap[Number(d.id)];
|
||
if(ov&&ov>Number(d.level||0)){
|
||
dd=Object.assign({},d,{level:ov,levelName:LEVELS[ov-1]||d.levelName,
|
||
trueLevel:Number(d.level||0),trueLevelName:d.levelName,levelOverridden:true});
|
||
}
|
||
return {d:dd,inOrg,beta,allowed};
|
||
}
|
||
|
||
// A level override decides what a position can SEE. It must never decide what
|
||
// a position can SPEND: ad impressions and grant pools are real inventory on
|
||
// a real network. So anything that costs something is metered on trueLevel.
|
||
function spendLevel(e){ return (e.d && e.d.trueLevel != null) ? e.d.trueLevel : (e.d ? e.d.level : 1); }
|
||
function isPreview(e){ return !!(e.d && e.d.levelOverridden); }
|
||
|
||
// ── Page Builder ─────────────────────────────────────────────────────────
|
||
// Level 6 extra pages: /p/<id>/<slug>. Falls back to the member's main page
|
||
// rather than 404ing, for the same reason /p/<id> falls back to /join/<id>.
|
||
if(req.method==='GET'&&/^\/p\/\d{1,15}\/[a-z0-9-]{1,24}$/.test(pathname)){
|
||
const parts=pathname.split('/');
|
||
const pid=parts[2], slug=parts[3];
|
||
const rec=suitePages.load(pid,slug);
|
||
if(!rec||!rec.copy){
|
||
res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'}));
|
||
return res.end();
|
||
}
|
||
// Co-branding (L6) is resolved here, against the live contract level, so a
|
||
// position that falls below Fastigium reverts to team identity by itself.
|
||
try{
|
||
const owner=await chain.memberPublic(rec.id).catch(()=>null);
|
||
if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; }
|
||
}catch(err){}
|
||
const html=suitePages.render(rec);
|
||
res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'}));
|
||
return res.end(html);
|
||
}
|
||
|
||
if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
|
||
const pid=pathname.split('/')[2];
|
||
const rec=suitePages.load(pid);
|
||
// No custom page built yet? NEVER dead-end — especially not with JSON. Any
|
||
// /p/<id> link may already be on a banner, a flyer, or in someone's DMs, so
|
||
// it must always land somewhere useful: the member's own invite page.
|
||
if(!rec||!rec.copy){
|
||
res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'}));
|
||
return res.end();
|
||
}
|
||
// Co-branding (L6) is resolved here, against the live contract level, so a
|
||
// position that falls below Fastigium reverts to team identity by itself.
|
||
try{
|
||
const owner=await chain.memberPublic(rec.id).catch(()=>null);
|
||
if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; }
|
||
}catch(err){}
|
||
const html=suitePages.render(rec);
|
||
// Member pages must ALWAYS be viewable inside a frame — the builder previews
|
||
// them, and members share them into contexts that embed. Never send
|
||
// X-Frame-Options here, and keep frame-ancestors open.
|
||
res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'}));
|
||
return res.end(html);
|
||
}
|
||
if(req.method==='GET'&&pathname==='/api/public/suite-page'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||
const pages=suitePages.list(e.d.id);
|
||
const cap=suitePages.pageLimit(e.d.level);
|
||
return json(res,200,{pages:pages,page:suitePages.load(e.d.id),meter:gate,
|
||
angles:suitePages.ANGLES,level:e.d.level,id:e.d.id,
|
||
cap:cap,held:pages.length,canAddMore:pages.length<cap});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-page'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
const b=await bodyJson(req)||{};
|
||
|
||
// Deleting one of their own pages.
|
||
if(b.remove!==undefined){
|
||
const slug=String(b.remove||'');
|
||
if(!slug)return json(res,400,{error:'The main page cannot be deleted — rebuild it instead.'});
|
||
const gone=suitePages.remove(e.d.id,slug);
|
||
return json(res,gone?200:404,gone
|
||
?{removed:slug,pages:suitePages.list(e.d.id),held:suitePages.list(e.d.id).length,cap:suitePages.pageLimit(e.d.level)}
|
||
:{error:'That page is not there.'});
|
||
}
|
||
|
||
const input={
|
||
name:String(b.name||'').trim().slice(0,60),
|
||
audience:String(b.audience||'').trim().slice(0,300),
|
||
story:String(b.story||'').trim().slice(0,1200),
|
||
angle:suitePages.ANGLES[b.angle]?b.angle:'overview'
|
||
};
|
||
if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up — try again shortly.'});
|
||
|
||
// Two different limits, and they used to contradict each other: the monthly
|
||
// BUILD quota is how often the writer may run, the HOLD cap is how many
|
||
// pages the level lets them keep. Rebuilding an existing page spends a
|
||
// build but does not need hold room; adding a new one needs both.
|
||
const existing=suitePages.list(e.d.id);
|
||
const cap=suitePages.pageLimit(e.d.level);
|
||
const replacing=b.replace!==undefined?String(b.replace||''):null;
|
||
const isNew=replacing===null;
|
||
if(isNew&&existing.length>=cap){
|
||
return json(res,429,{error:cap<=1
|
||
? 'Your level allows one page. Rebuild the one you have, or upgrade to hold more.'
|
||
: 'You are holding all '+cap+' pages your level allows. Delete one, or upgrade to hold more.',
|
||
cap:cap,held:existing.length});
|
||
}
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||
if(!gate.allowed){
|
||
return json(res,429,{error:gate.reason==='locked'
|
||
? 'The Page Builder unlocks at Ascensus (level 2). Your next upgrade opens it.'
|
||
: 'You have used all '+gate.limit+' page builds this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||
}
|
||
try{
|
||
const copy=await suitePages.generate(input);
|
||
// The first page a member builds stays at /p/<id> — that address is on
|
||
// banners, flyers and in people's DMs, so it must never move. Extra pages
|
||
// get a slug from the headline the writer produced, so the address
|
||
// describes the page without asking anyone to invent one.
|
||
const hasMain=existing.some(function(x){return !x.slug});
|
||
let slug;
|
||
if(replacing!==null) slug=replacing;
|
||
else if(!hasMain) slug='';
|
||
else slug=suitePages.slugFromTitle(e.d.id,copy.headline,input.angle);
|
||
|
||
const rec=suitePages.save(e.d.id,{id:e.d.id,slug:slug||undefined,name:input.name,angle:input.angle,
|
||
audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug||undefined);
|
||
suiteMeter.record(e.d.id,'page',1);
|
||
const url='https://rmcircle.team/p/'+e.d.id+(slug?'/'+slug:'');
|
||
const after=suitePages.list(e.d.id);
|
||
return json(res,200,{page:rec,url:url,slug:slug||'',pages:after,held:after.length,cap:cap,
|
||
canAddMore:after.length<cap,meter:suiteMeter.check(e.d.id,e.d.level,'page')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-email'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
const b=await bodyJson(req)||{};
|
||
const kind=String(b.kind||'welcome');
|
||
const brief=String(b.brief||'').trim().slice(0,1200);
|
||
if(!suiteEmail.KINDS[kind])return json(res,400,{error:'Unknown sequence type.'});
|
||
if(!suiteAI.configured())return json(res,503,{error:'The Email Engine is warming up — try again shortly.'});
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'email');
|
||
if(!gate.allowed){
|
||
return json(res,429,{error:gate.reason==='locked'
|
||
? 'The Email Engine unlocks at Fabrica (level 3). Your next upgrades open it.'
|
||
: 'You have used all '+gate.limit+' email generations this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||
}
|
||
try{
|
||
const link='https://rmcircle.team/join/'+e.d.id;
|
||
const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)});
|
||
suiteMeter.record(e.d.id,'email',emails.length||1);
|
||
return json(res,200,{emails:emails,meter:suiteMeter.check(e.d.id,e.d.level,'email')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
// ── Video Maker ──────────────────────────────────────────────────────────
|
||
if(req.method==='GET'&&/^\/api\/public\/suite-video-file\/\d{1,15}\/[a-z]{2,12}$/.test(pathname)){
|
||
const parts=pathname.split('/'); const vid=parts[4], vangle=parts[5];
|
||
const ex=suiteVideo.existing(vid,vangle);
|
||
if(!ex)return json(res,404,{error:'No video yet.'});
|
||
res.writeHead(200,{'Content-Type':'video/mp4','Content-Length':ex.bytes,
|
||
'Content-Disposition':'attachment; filename="rmcircle-'+vangle+'-'+vid+'.mp4"','Cache-Control':'private, max-age=60'});
|
||
return require('fs').createReadStream(ex.file).pipe(res);
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-video'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
const b=await bodyJson(req)||{};
|
||
const angle=suiteVideo.ANGLES[b.angle]?b.angle:'overview';
|
||
const name=String(b.name||'').trim().slice(0,42);
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'video');
|
||
if(!gate.allowed){
|
||
return json(res,429,{error:gate.reason==='locked'
|
||
? 'The Video Maker unlocks at Fabrica (level 3). Your next upgrades open it.'
|
||
: 'You have used all '+gate.limit+' video renders this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||
}
|
||
const link='https://rmcircle.team/join/'+e.d.id+(angle!=='overview'?'?v='+angle:'');
|
||
try{
|
||
const r=await suiteVideo.render({id:e.d.id,angle:angle,name:name,link:link});
|
||
suiteMeter.record(e.d.id,'video',1);
|
||
return json(res,200,{url:'/api/public/suite-video-file/'+e.d.id+'/'+angle,mb:Math.round(r.bytes/104857.6)/10,
|
||
meter:suiteMeter.check(e.d.id,e.d.level,'video')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
// ── Webinar replays (members area) ───────────────────────────────────────
|
||
// The list lives in DATA_DIR/replays.json so adding the weekly replay never
|
||
// needs a deploy. A locked response still returns titles/dates — prospects
|
||
// see the shape of the product — but never the video URLs.
|
||
if(req.method==='GET'&&pathname==='/api/public/replays'){
|
||
let list=[]; try{ list=readJson(path.join(DATA_DIR,'replays.json'))||[]; }catch(e){}
|
||
const member=messages.authFromCookie(req);
|
||
if(!member)return json(res,200,{locked:true,replays:list.map(r=>({title:r.title,date:r.date,minutes:r.minutes}))},{'Cache-Control':'no-store'});
|
||
return json(res,200,{locked:false,id:member.id,replays:list},{'Cache-Control':'no-store'});
|
||
}
|
||
|
||
// ── Traffic Desk ─────────────────────────────────────────────────────────
|
||
if(req.method==='GET'&&pathname==='/api/public/suite-traffic'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
|
||
const st=suiteTraffic.status(e.d.id,spendLevel(e));
|
||
let live=[];
|
||
try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){}
|
||
// Tell the UI whether a personal page actually exists, so it can't be
|
||
// offered as an ad destination before it's been built.
|
||
const pg=suitePages.load(e.d.id);
|
||
const hasPage=!!(pg&&pg.copy);
|
||
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage,
|
||
textAngles:suiteTextAds.angles(),textMeter:suiteMeter.check(e.d.id,e.d.level,'textad'),writerReady:suiteAI.configured()});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(!suiteTraffic.configured())return json(res,503,{error:'The ad network bridge is warming up — try again shortly.'});
|
||
const b=await bodyJson(req)||{};
|
||
try{
|
||
const pgRec=suitePages.load(e.d.id);
|
||
const entry=await suiteTraffic.launch({
|
||
id:e.d.id, level:spendLevel(e), size:b.size, creative:b.creative,
|
||
kind:b.kind==='text'?'text':'banner',
|
||
subject:b.subject, lines:b.lines,
|
||
impressions:b.impressions, target:b.target, angle:b.angle,
|
||
hasPage:!!(pgRec&&pgRec.copy),
|
||
name:String(b.name||'').slice(0,60)
|
||
});
|
||
return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,spendLevel(e))});
|
||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-textads'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(!suiteAI.configured())return json(res,503,{error:'The writer is warming up — try again shortly.'});
|
||
const b=await bodyJson(req)||{};
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'textad');
|
||
if(!gate.allowed){
|
||
return json(res,429,{error:'You have used all '+gate.limit+' text-ad batches for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||
}
|
||
try{
|
||
const r=await suiteTextAds.generate({angle:String(b.angle||'general'),count:5});
|
||
suiteMeter.record(e.d.id,'textad',1);
|
||
return json(res,200,{angle:r.angle,variants:r.variants,meter:suiteMeter.check(e.d.id,e.d.level,'textad')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
// -- Voice Profile (level 4) ----------------------------------------------
|
||
if(pathname==='/api/public/suite-voice'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<4)return json(res,403,{error:'The Voice Profile unlocks at Culmen (level 4).',minLevel:4});
|
||
if(req.method==='GET'){
|
||
return json(res,200,{profile:suiteVoice.load(e.d.id)||suiteVoice.blank(),fields:suiteVoice.FIELDS,level:e.d.level,id:e.d.id});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
if(b.clear){ suiteVoice.clear(e.d.id); return json(res,200,{profile:suiteVoice.blank(),cleared:true}); }
|
||
return json(res,200,{profile:suiteVoice.save(e.d.id,b),saved:true});
|
||
}
|
||
}
|
||
|
||
// -- Split Tester (level 5) -----------------------------------------------
|
||
if(pathname==='/api/public/suite-split'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<suiteSplit.MIN_LEVEL)return json(res,403,{error:'The Split Tester unlocks at Apex (level 5).',minLevel:suiteSplit.MIN_LEVEL});
|
||
if(req.method==='GET'){
|
||
let r=[]; try{ r=await suiteSplit.results(e.d.id); }catch(err){}
|
||
const pg=suitePages.load(e.d.id);
|
||
return json(res,200,{tests:r,level:e.d.level,id:e.d.id,
|
||
traffic:suiteTraffic.status(e.d.id,spendLevel(e)),hasPage:!!(pg&&pg.copy),
|
||
minPerArm:suiteSplit.MIN_PER_ARM,maxArms:suiteSplit.MAX_ARMS,
|
||
textAngles:suiteTextAds.angles(),
|
||
textMeter:suiteMeter.check(e.d.id,e.d.level,'textad')});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
if(b.stop){
|
||
try{ return json(res,200,{stopped:await suiteSplit.stop(e.d.id,String(b.stop))}); }
|
||
catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
const pg=suitePages.load(e.d.id);
|
||
try{
|
||
const t=await suiteSplit.launch({id:e.d.id,level:spendLevel(e),arms:b.arms,
|
||
impressions:b.impressions,target:b.target,angle:b.angle,hasPage:!!(pg&&pg.copy)});
|
||
return json(res,200,{test:t,traffic:suiteTraffic.status(e.d.id,spendLevel(e))});
|
||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
// -- Funnel Factory: extra pages (level 6) --------------------------------
|
||
if(pathname==='/api/public/suite-funnel'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<6)return json(res,403,{error:'The Funnel Factory unlocks at Fastigium (level 6).',minLevel:6});
|
||
if(req.method==='GET'){
|
||
return json(res,200,{pages:suitePages.list(e.d.id),angles:suitePages.ANGLES,level:e.d.level,id:e.d.id,
|
||
meter:suiteMeter.check(e.d.id,e.d.level,'page')});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
if(b.remove){
|
||
const gone=suitePages.remove(e.d.id,String(b.remove));
|
||
return json(res,gone?200:404,gone?{removed:b.remove,pages:suitePages.list(e.d.id)}:{error:'That page is not there.'});
|
||
}
|
||
const slug=suitePages.slugify(b.slug||b.name||'');
|
||
if(!slug)return json(res,400,{error:'Give the page a short name.'});
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||
if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' page builds this month. It resets on the 1st.',meter:gate});
|
||
const input={id:e.d.id,name:String(b.name||'').slice(0,80),audience:String(b.audience||'').slice(0,400),
|
||
story:String(b.story||'').slice(0,900),angle:suitePages.ANGLES[b.angle]?b.angle:'overview'};
|
||
try{
|
||
const copy=await suitePages.generate(input);
|
||
suitePages.save(e.d.id,{id:e.d.id,slug:slug,name:input.name,angle:input.angle,audience:input.audience,
|
||
story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug);
|
||
suiteMeter.record(e.d.id,'page',1);
|
||
return json(res,200,{pages:suitePages.list(e.d.id),url:'https://rmcircle.team/p/'+e.d.id+'/'+slug,
|
||
meter:suiteMeter.check(e.d.id,e.d.level,'page')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
// -- Leader Ops (level 7) -------------------------------------------------
|
||
if(pathname==='/api/public/suite-leader'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<suiteLeader.MIN_LEVEL)return json(res,403,{error:'Leader Ops unlocks at Vertex (level 7).',minLevel:suiteLeader.MIN_LEVEL});
|
||
if(req.method==='GET'){
|
||
return json(res,200,{scan:suiteLeader.scan(e.d.id,12),level:e.d.level,id:e.d.id,writerReady:suiteAI.configured()});
|
||
}
|
||
if(req.method==='POST'){
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'copy');
|
||
if(!gate.allowed)return json(res,429,{error:'That is this month of generation allowance used up.',meter:gate});
|
||
try{
|
||
const r=await suiteLeader.digest(e.d.id,{maxItems:12});
|
||
suiteMeter.record(e.d.id,'copy',1);
|
||
return json(res,200,{scan:r.scan,text:r.text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
// -- Founder Desk (level 8) -----------------------------------------------
|
||
if(pathname==='/api/public/suite-founder'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<suiteFounder.MIN_LEVEL)return json(res,403,{error:'The Founder Desk unlocks at Corona (level 8).',minLevel:suiteFounder.MIN_LEVEL});
|
||
if(req.method==='GET'){
|
||
return json(res,200,{pack:suiteFounder.loadPack(e.d.id),key:suiteFounder.keyInfo(e.d.id),
|
||
level:e.d.level,id:e.d.id,writerReady:suiteAI.configured(),
|
||
meter:suiteMeter.check(e.d.id,e.d.level,'founder')});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
if(b.issueKey){ return json(res,200,{key:suiteFounder.issueKey(e.d.id),info:suiteFounder.keyInfo(e.d.id)}); }
|
||
if(b.revokeKey){ suiteFounder.revokeKey(e.d.id); return json(res,200,{info:suiteFounder.keyInfo(e.d.id)}); }
|
||
if(!suiteAI.configured())return json(res,503,{error:'The writer is warming up - try again shortly.'});
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'founder');
|
||
if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' campaign packs this month. It resets on the 1st.',meter:gate});
|
||
try{
|
||
const pack=await suiteFounder.buildPack({id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)});
|
||
suiteMeter.record(e.d.id,'founder',1);
|
||
return json(res,200,{pack:pack,meter:suiteMeter.check(e.d.id,e.d.level,'founder')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
// -- Founder API v1: key-authenticated, read-mostly ------------------------
|
||
if(pathname.startsWith('/api/suite/v1/')){
|
||
const auth=String(req.headers['authorization']||'');
|
||
const raw=auth.replace(/^Bearer\s+/i,'').trim();
|
||
const memberId=suiteFounder.memberForKey(raw);
|
||
if(!memberId)return json(res,401,{error:'Provide your Founder Desk API key as: Authorization: Bearer <key>'});
|
||
let md=null;
|
||
try{ md=await chain.memberPublic(memberId); }catch(err){}
|
||
if(!md||!md.registered)return json(res,404,{error:'Position not found.'});
|
||
if(pathname==='/api/suite/v1/me'&&req.method==='GET'){
|
||
return json(res,200,{id:memberId,level:md.level,levelName:md.levelName,tier:md.tier,
|
||
directCount:md.directCount,qualified:(md.directCount||0)>=2,
|
||
totalEarnedPol:md.totalEarnedPol,link:'https://rmcircle.team/join/'+memberId});
|
||
}
|
||
if(pathname==='/api/suite/v1/team'&&req.method==='GET'){
|
||
return json(res,200,{scan:suiteLeader.scan(memberId,25)});
|
||
}
|
||
if(pathname==='/api/suite/v1/generate'&&req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
const kind=String(b.kind||'post');
|
||
if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown kind. One of: '+Object.keys(suiteAI.KINDS).join(', ')});
|
||
const brief=String(b.brief||'').trim().slice(0,1200);
|
||
if(brief.length<3)return json(res,400,{error:'Send a brief describing what the piece is about.'});
|
||
const gate=suiteMeter.check(memberId,md.level,'copy');
|
||
if(!gate.allowed)return json(res,429,{error:'Monthly generation allowance used.',meter:gate});
|
||
try{
|
||
const text=await suiteAI.generate(kind,brief,{link:'https://rmcircle.team/join/'+memberId,id:memberId,voice:suiteVoice.promptBlock(memberId)});
|
||
suiteMeter.record(memberId,'copy',1);
|
||
return json(res,200,{text:text,meter:suiteMeter.check(memberId,md.level,'copy')});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
return json(res,404,{error:'Unknown endpoint. Available: GET /api/suite/v1/me, GET /api/suite/v1/team, POST /api/suite/v1/generate'});
|
||
}
|
||
|
||
// -- Team Grants (level 7) -------------------------------------------------
|
||
if(pathname==='/api/public/suite-grants'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(req.method==='GET'){
|
||
// Recipients see what they were given even below level 7 - that is the
|
||
// half of the feature that makes being helped visible.
|
||
return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level,isPreview(e)),tools:suiteGrants.tools(),
|
||
minLevel:suiteGrants.MIN_LEVEL,level:e.d.level,id:e.d.id});
|
||
}
|
||
if(req.method==='POST'){
|
||
if(Number(e.d.level)<suiteGrants.MIN_LEVEL)return json(res,403,{error:'Team Grants unlock at Vertex (level 7).',minLevel:suiteGrants.MIN_LEVEL});
|
||
const b=await bodyJson(req)||{};
|
||
const to=Number(b.to)||0;
|
||
if(!to)return json(res,400,{error:'Enter the position number you want to help.'});
|
||
// Only into your OWN organisation, checked against the contract.
|
||
let inTeam=false;
|
||
try{ inTeam=chain.isInTeam(to,Number(e.d.id)); }catch(err){}
|
||
if(!inTeam)return json(res,400,{error:'Position #'+to+' is not in your organisation. You can only grant to people below you.'});
|
||
try{
|
||
const rec=suiteGrants.grant({by:e.d.id,to:to,tool:String(b.tool||''),n:b.n,level:e.d.level,preview:isPreview(e)});
|
||
return json(res,200,{granted:rec,status:suiteGrants.status(e.d.id,e.d.level,isPreview(e))});
|
||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
// -- Network Intelligence (level 8) ---------------------------------------
|
||
if(req.method==='GET'&&pathname==='/api/public/suite-intel'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<suiteIntel.MIN_LEVEL)return json(res,403,{error:'Network Intelligence unlocks at Corona (level 8).',minLevel:suiteIntel.MIN_LEVEL});
|
||
try{
|
||
const rep=await suiteIntel.report();
|
||
return json(res,200,{report:rep,readout:suiteIntel.readout(rep)});
|
||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
// Tool walkthrough videos, served from the data volume rather than the image.
|
||
// 45MB of MP4 in the repo would ride along on every single deploy, and this
|
||
// way a video can be re-cut and dropped in without a rebuild.
|
||
//
|
||
// Range support is not optional here: without it the browser cannot seek, so
|
||
// the scrub bar looks present but does nothing.
|
||
if((req.method==='GET'||req.method==='HEAD')&&/^\/tv\/[a-z0-9-]{1,60}\.(mp4|jpg)$/.test(pathname)){
|
||
const isImg=/\.jpg$/.test(pathname);
|
||
const name=pathname.slice(4);
|
||
const vfile=path.join(DATA_DIR,'tool-videos',name);
|
||
let st=null;
|
||
try{ st=fs.statSync(vfile); }catch(err){ return json(res,404,{error:'No walkthrough for that tool yet.'}); }
|
||
const range=isImg?null:req.headers.range;
|
||
const base={'Content-Type':isImg?'image/jpeg':'video/mp4','Accept-Ranges':isImg?'none':'bytes',
|
||
'Cache-Control':'public, max-age=3600'};
|
||
if(range){
|
||
const m=/bytes=(\d*)-(\d*)/.exec(range)||[];
|
||
const start=m[1]?parseInt(m[1],10):0;
|
||
const end=m[2]?parseInt(m[2],10):st.size-1;
|
||
if(start>=st.size||end>=st.size||start>end){
|
||
res.writeHead(416,securityHeaders(Object.assign({},base,{'Content-Range':'bytes */'+st.size})));
|
||
return res.end();
|
||
}
|
||
res.writeHead(206,securityHeaders(Object.assign({},base,{
|
||
'Content-Range':'bytes '+start+'-'+end+'/'+st.size,'Content-Length':(end-start+1)})));
|
||
return fs.createReadStream(vfile,{start,end}).pipe(res);
|
||
}
|
||
res.writeHead(200,securityHeaders(Object.assign({},base,{'Content-Length':st.size})));
|
||
if(req.method==='HEAD')return res.end();
|
||
return fs.createReadStream(vfile).pipe(res);
|
||
}
|
||
|
||
// -- Co-branded pages (level 6) -------------------------------------------
|
||
if(pathname==='/api/public/suite-brand'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
if(Number(e.d.level)<suiteBrand.MIN_LEVEL)return json(res,403,{error:'Co-branded pages unlock at Fastigium (level 6).',minLevel:suiteBrand.MIN_LEVEL});
|
||
if(req.method==='GET'){
|
||
return json(res,200,{brand:suiteBrand.load(e.d.id)||suiteBrand.blank(),accents:suiteBrand.accents(),
|
||
pages:suitePages.list(e.d.id),level:e.d.level,id:e.d.id});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
if(b.clear){ return json(res,200,{brand:suiteBrand.clear(e.d.id),cleared:true}); }
|
||
return json(res,200,{brand:suiteBrand.save(e.d.id,b),saved:true});
|
||
}
|
||
}
|
||
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
const b=await bodyJson(req)||{};
|
||
try{
|
||
const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id));
|
||
return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,spendLevel(e))});
|
||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
|
||
if(req.method==='GET'&&pathname==='/api/public/suite-meters'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
|
||
return json(res,200,{level:e.d.level,meters:suiteMeter.meters(e.d.id,e.d.level)});
|
||
}
|
||
|
||
if(req.method==='POST'&&pathname==='/api/public/suite-generate'){
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||
const b=await bodyJson(req)||{};
|
||
const kind=String(b.kind||'post');
|
||
const brief=String(b.brief||'').trim().slice(0,1200);
|
||
if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown copy type.'});
|
||
if(brief.length<3)return json(res,400,{error:'Tell the engine what the piece is about.'});
|
||
if(!suiteAI.configured())return json(res,503,{error:'The Copy Engine is warming up — try again shortly.'});
|
||
const gate=suiteMeter.check(e.d.id,e.d.level,'copy');
|
||
if(!gate.allowed){
|
||
return json(res,429,{error:gate.reason==='locked'
|
||
? 'The Copy Engine unlocks at Ascensus (level 2). Your next upgrade opens it.'
|
||
: 'You have used all '+gate.limit+' Copy Engine generations for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||
}
|
||
const link='https://rmcircle.team/join/'+e.d.id;
|
||
try{
|
||
const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)});
|
||
suiteMeter.record(e.d.id,'copy',1);
|
||
return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')});
|
||
}catch(err){
|
||
return json(res,502,{error:String(err.message||err)});
|
||
}
|
||
}
|
||
|
||
if(req.method==='GET'&&pathname==='/api/public/suite-me'){
|
||
// One source of truth for entitlement. This route used to carry its own
|
||
// copy of the org/allowlist logic, which is exactly how the level override
|
||
// came to work on every tool page but not on the wall that links to them.
|
||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500}));
|
||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||
const d=e.d;
|
||
return json(res,200,{id:d.id,tier:d.tier,tierName:d.tierName,level:d.level,levelName:d.levelName,
|
||
directCount:d.directCount,inOrg:e.inOrg,beta:e.beta,allowed:e.allowed,
|
||
levelOverridden:!!d.levelOverridden,trueLevel:d.trueLevel});
|
||
}
|
||
|
||
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.'});
|
||
// Whether this position already has a Telegram chat attached. Without it
|
||
// the panel keeps offering "Connect Telegram" to people who are already
|
||
// connected, which reads as the link having silently failed.
|
||
let tgLinked=false;
|
||
try{ tgLinked=!!tgbot.memberChat(s.id); }catch(err){}
|
||
return json(res,200,Object.assign({},messages.inbox(s),{tgLinked:tgLinked}));
|
||
}
|
||
if(req.method==='POST'&&pathname.startsWith('/api/tg-hook/')){
|
||
if(pathname.slice('/api/tg-hook/'.length)!==tgbot.webhookSecret())return json(res,404,{error:'Not found'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
if(b)tgbot.handleUpdate(b);
|
||
return json(res,200,{ok:true});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/tg-link'){
|
||
const s2=messages.authFromCookie(req);
|
||
if(!s2)return json(res,401,{error:'Not signed in.'});
|
||
const r=tgbot.makeLinkCode(s2.id);
|
||
if(!r.url)return json(res,200,{error:'The Telegram bot is warming up — try again in a minute.'});
|
||
return json(res,200,{url:r.url,linked:!!tgbot.memberChat(s2.id)});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/tg-webapp-auth'){
|
||
// Telegram Mini App auth bridge: signed initData (HMAC-verified against the
|
||
// companion bot token) proves the Telegram account; the wallet-verified
|
||
// link in tg-links.json maps it to a member — so linked members land on
|
||
// their dashboard with zero login. Never creates links, only reads them.
|
||
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
||
if(memberLookupLimited(ip))return json(res,429,{error:'Too many requests — wait a minute.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
if(!b||typeof b.initData!=='string')return json(res,400,{error:'Invalid request.'});
|
||
const v=tgbot.verifyInitData(b.initData);
|
||
if(v.error)return json(res,401,{error:'Could not verify the Telegram launch data — close and reopen the app.'});
|
||
const memberId=tgbot.chatMember(v.userId);
|
||
if(!memberId)return json(res,200,{ok:true,linked:false});
|
||
const tok=messages.mintSession(memberId);
|
||
if(!tok)return json(res,500,{error:'Session error — try again.'});
|
||
return json(res,200,{ok:true,linked:true,id:memberId},{'Set-Cookie':messages.sessionCookie(tok)});
|
||
}
|
||
// ---- member profile: username + verified email, REQUIRED before the member
|
||
// area opens. Only a session that PROVED ownership (wallet personal_sign or the
|
||
// Telegram Mini App bridge) can read or write one; /my/<id> is public and can not.
|
||
// How many people in MY org can receive a message. Session required, and the
|
||
// ids are filtered to the caller's own team, so this leaks nothing upward or sideways.
|
||
if(req.method==='GET'&&pathname==='/api/public/reach'){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return json(res,200,{ok:true,signedIn:false});
|
||
let ids=[];
|
||
try{
|
||
const raw=String(new URL(req.url,'http://x').searchParams.get('ids')||'');
|
||
ids=raw.split(',').map(Number).filter(n=>Number.isInteger(n)&&n>0).slice(0,3000).filter(n=>n!==s.id&&chain.isInTeam(n,s.id));
|
||
}catch(e){}
|
||
return json(res,200,Object.assign({ok:true,signedIn:true},profiles.reachFor(ids)));
|
||
}
|
||
if(req.method==='GET'&&pathname==='/api/public/profile'){
|
||
const s=messages.authFromCookie(req);
|
||
// 200 with signedIn:false, not 401: a shared /my/<id> link is opened by people
|
||
// who are not members, and a 401 there prints a console error that reads like a broken page.
|
||
if(!s)return json(res,200,{ok:true,signedIn:false});
|
||
return json(res,200,Object.assign({ok:true,id:s.id,suggest:profiles.suggest(s.id)},profiles.status(s.id)));
|
||
}
|
||
if(req.method==='GET'&&pathname==='/api/public/placement'){
|
||
const pid=new URL(req.url,'http://x').searchParams.get('id');
|
||
if(!/^[0-9]{1,15}$/.test(String(pid||'')))return json(res,400,{error:'id required'});
|
||
return json(res,200,placement.get(pid,getConfig()));
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/placement'){
|
||
const ps=messages.authFromCookie(req);
|
||
if(!ps)return json(res,401,{error:'Not signed in.'});
|
||
const pb=await bodyJson(req).catch(()=>null);
|
||
const pr=placement.set(ps.id,pb&&pb.mode);
|
||
return json(res,pr.error?400:200,pr);
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/profile/username'){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return json(res,401,{error:'Not signed in.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
const r=profiles.setUsername(s.id,b&&b.username);
|
||
return json(res,r.error?400:200,r);
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/profile/email-start'){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return json(res,401,{error:'Not signed in.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
const r=profiles.startEmail(s.id,b&&b.email);
|
||
if(r.ok&&!IS_PROD&&r.devCode===undefined)r.devCode=profiles.peekCode(s.id); // local testing only; never in production
|
||
return json(res,r.error?400:200,r);
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/profile/email-verify'){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return json(res,401,{error:'Not signed in.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
const r=profiles.verifyEmail(s.id,b&&b.code);
|
||
// The invitation promises "a note the moment a payout lands". The payout mailer and
|
||
// the upgrade alerts read member-alerts.json, NOT profiles.json, so a member who only
|
||
// ever completed the new profile was getting nothing and we were breaking that promise
|
||
// (found 2026-09-17). Mirror the verified address across so every existing alert path,
|
||
// including the unsubscribe link, just works.
|
||
if(r.ok&&r.profile&&r.profile.email){
|
||
try{ const ma=getMemberAlerts(); ma[s.id]={email:r.profile.email,ts:new Date().toISOString()}; saveMemberAlerts(ma); }
|
||
catch(e){ console.error('alert mirror',e.message); }
|
||
}
|
||
if(r.ok)console.log('profile complete for position #'+s.id);
|
||
return json(res,r.error?400:200,r);
|
||
}
|
||
// Opting back out. We tell members "removable any time" in three places, so this has to
|
||
// exist and has to clear BOTH stores, or they keep getting email after opting out.
|
||
if(req.method==='POST'&&pathname==='/api/public/profile/remove'){
|
||
const s=messages.authFromCookie(req);
|
||
if(!s)return json(res,401,{error:'Not signed in.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
const what=String(b&&b.what||'');
|
||
if(!['email','username','all'].includes(what))return json(res,400,{error:'Say what to remove.'});
|
||
const r=profiles.remove(s.id,what);
|
||
if(r.ok&&(what==='email'||what==='all')){
|
||
try{ const ma=getMemberAlerts(); if(ma[s.id]){delete ma[s.id];saveMemberAlerts(ma);} }
|
||
catch(e){ console.error('alert unmirror',e.message); }
|
||
}
|
||
if(r.ok)console.log('profile removed ('+what+') for position #'+s.id);
|
||
return json(res,r.error?400:200,r);
|
||
}
|
||
if(req.method==='GET'&&pathname==='/api/admin/profiles'){
|
||
if(!requireAdmin(req,res))return;
|
||
return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});
|
||
}
|
||
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||{});
|
||
if(!r.error&&b&&!b.org&&b.toId)try{tgbot.notifyMessage(s.id,Number(b.toId),String(b.body||''))}catch(e){}
|
||
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.'});
|
||
}
|
||
return json(res,404,{error:'Company rotation is not configured (publicRotationMode must be chain).'});
|
||
}
|
||
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);
|
||
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);
|
||
// CTB Rewards featured offer: a 30s-dwell "engaged" event carrying a
|
||
// ctb1_ clickid is relayed server-to-server so CTB can credit the coins.
|
||
// CTB is idempotent per clickid; the local Set just cuts repeat traffic.
|
||
if(b.event==='engaged'){
|
||
const cid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
|
||
if(cid.startsWith('ctb1_')&&!ctbRelayed.has(cid)){
|
||
ctbRelayed.add(cid); if(ctbRelayed.size>2000)ctbRelayed.clear();
|
||
fireCtbOfferPostback(cid);
|
||
}
|
||
}
|
||
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(pathname==='/api/admin/grant'){
|
||
if(req.method==='GET'){
|
||
return json(res,200,{tools:suiteGrants.tools(),log:suiteGrants.adminLog(60)});
|
||
}
|
||
if(req.method==='POST'){
|
||
const b=await bodyJson(req)||{};
|
||
const to=Number(b.to)||0;
|
||
if(!to)return json(res,400,{error:'Enter the position number.'});
|
||
// Confirm the position actually exists before crediting it — a typo would
|
||
// otherwise sit in the ledger crediting nobody.
|
||
let md=null;
|
||
try{ md=await chain.memberPublic(to); }catch(err){}
|
||
if(!md||!md.registered)return json(res,404,{error:'Position #'+to+' is not registered on-chain.'});
|
||
try{
|
||
const rec=suiteGrants.adminGrant({to:to,tool:String(b.tool||''),n:b.n,note:b.note});
|
||
return json(res,200,{granted:rec,levelName:md.levelName,log:suiteGrants.adminLog(60)});
|
||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||
}
|
||
}
|
||
|
||
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(),level,sortOrder:maxOrder+10,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});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','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','telegramEchoChatId','telegramEchoTopicId','telegramEchoEvents','telegramTeamEvents','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin','promoEnabled','promoId','promoImg','promoEyebrow','promoCtaText','promoCtaUrl','promoPages','promoExpiresUTC'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];if('announceDurationMin' in next)next.announceDurationMin=Math.max(15,Number(next.announceDurationMin)||60);next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
||
}
|
||
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(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','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;saveSponsors(sponsors);return json(res,200,{sponsors});}
|
||
if(req.method==='DELETE'&&!action){sponsors.splice(idx,1);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'});
|
||
}
|
||
|
||
// Serve /my/:id and /join/:id with personalized Open Graph / Twitter tags so a
|
||
// shared personal referral link previews nicely (social scrapers don't run JS,
|
||
// so the per-ID meta must be in the static HTML). Strips any baked-in og/twitter
|
||
// tags and injects a fresh, ID-specific set using the branded card image.
|
||
function serveMemberPage(req,res,file,kind,id){
|
||
let html; try{ html=fs.readFileSync(file,'utf8'); }catch(e){ return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); }
|
||
const base='https://rmcircle.team';
|
||
const ANGLE_OG={
|
||
pocket:{t:'You already spent it this week.',d:'The pocket-change illusion, called out in 105 seconds. Watch.',i:'/v/og-angle-pocket.jpg'},
|
||
phone:{t:"Everybody's making money on your phone. Except you.",d:'90 seconds that flips it. Watch.',i:'/v/og-angle-phone.jpg'},
|
||
two:{t:"You know two people. That's all this takes.",d:'The #1 excuse, retired in 90 seconds. Watch.',i:'/v/og-angle-two.jpg'},
|
||
graveyard:{t:'Your side-hustle graveyard has enough tenants.',d:"This one's built different. 90 seconds. Watch.",i:'/v/og-angle-graveyard.jpg'},
|
||
stopwaiting:{t:'Stop waiting. Start receiving.',d:'The whole thing, explained in one short video. Watch.',i:'/og-card.jpg'}
|
||
};
|
||
const angleKey=kind==='join'?new URL(req.url,'http://x').searchParams.get('v'):null;
|
||
const angle=angleKey?ANGLE_OG[angleKey]:null;
|
||
const url=kind==='join'?`${base}/join/${id}${angle?`?v=${angleKey}`:''}`:`${base}/my/${id}`;
|
||
const title=angle?angle.t:(kind==='join'
|
||
? `You're invited to the RM Circle team by Member #${id}`
|
||
: `Member #${id}'s RM Circle team — live on the blockchain`);
|
||
const desc=angle?angle.d:(kind==='join'
|
||
? `A team-first crypto build where every payout is verified on Polygon. See Member #${id}'s real on-chain position and join their team. No income is guaranteed.`
|
||
: `Member #${id}'s live position, team, and payments — read straight from the blockchain. Get your 2, help your 2. No income is guaranteed.`);
|
||
const img=angle?`${base}${angle.i}`:`${base}/og-card.jpg`;
|
||
const og=[
|
||
`<meta property="og:type" content="website">`,
|
||
`<meta property="og:site_name" content="RM Circle Team Build">`,
|
||
`<meta property="og:title" content="${title}">`,
|
||
`<meta property="og:description" content="${desc}">`,
|
||
`<meta property="og:url" content="${url}">`,
|
||
`<meta property="og:image" content="${img}"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">`,
|
||
`<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${title}"><meta name="twitter:description" content="${desc}"><meta name="twitter:image" content="${img}">`
|
||
].join('');
|
||
html=html.replace(/<meta\s+(?:property="og:[^"]*"|name="twitter:[^"]*")[^>]*>/gi,'');
|
||
html=html.replace(/(<head[^>]*>)/i,`$1${og}`);
|
||
const head={'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-cache'};
|
||
// Remember who invited this visitor. Terry hit this on 2026-09-18: a prospect opens
|
||
// /join/840, taps "Training" in the nav, and by the time they reach /start the site has
|
||
// forgotten him and offers the COMPANY rotation position instead — his referral, handed
|
||
// to a stranger. The invite is the referral, so it has to outlive the one page it landed
|
||
// on. 30 days, Lax so it survives a normal click-through from anywhere.
|
||
if(kind==='join'&&/^\d{1,15}$/.test(String(id))){
|
||
const attrs='; Path=/; Max-Age=2592000; SameSite=Lax'+(IS_PROD?'; Secure':'');
|
||
const ck=['rmc_ref='+id+attrs];
|
||
// ?direct=1/0 is the inviter's explicit choice about placement, so it has to travel with
|
||
// the referral. Without it a prospect who wandered off the invite page would come back
|
||
// through the moving link instead — the opposite of what the shared link asked for.
|
||
const dq=new URL(req.url,'http://x').searchParams.get('direct');
|
||
if(dq==='1'||dq==='0')ck.push('rmc_direct='+dq+attrs);
|
||
head['Set-Cookie']=ck;
|
||
}
|
||
res.writeHead(200,securityHeaders(head));
|
||
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);
|
||
// /p/<id> = member Page Builder pages: dynamic HTML, so it must reach the
|
||
// API handler rather than the static-file path (which 404s it).
|
||
if(pathname==='/health'||pathname==='/announce.ics'||pathname.startsWith('/api/')||pathname.startsWith('/tv/')||/^\/p\/\d{1,15}(\/[a-z0-9-]{1,24})?$/.test(pathname))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'});
|
||
// Members-area gate: the Circle Method lessons 2-10 + the e-gift cash-out
|
||
// walkthrough are the PRODUCT — their video FILES require a wallet-verified
|
||
// member session (web sign-in or the Mini App auth bridge). Lesson 1, all
|
||
// posters, and every join-funnel/transparency video stay public on purpose:
|
||
// Lesson 1 is the free preview, the rest is the trust engine.
|
||
if((/^\/v\/rmc-method-m\d+l\d+-[0-9a-f]+\.mp4$/.test(pathname)&&!/^\/v\/rmc-method-m1l1-/.test(pathname))||/^\/v\/rmc-stay-consistent-[0-9a-f]+\.mp4$/.test(pathname)||pathname==='/training/egift_cards.mp4'){
|
||
if(!messages.authFromCookie(req))return json(res,403,{error:'Members area — sign in on the training page with the wallet that owns your position, or open it through the Telegram Mini App.'},{'Cache-Control':'no-store'});
|
||
}
|
||
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=`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${ok?'Unsubscribed':'Invalid link'}</title><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg"><div class="login-panel" style="margin:12vh auto;text-align:center"><h1>${ok?'You are unsubscribed ✓':'Invalid or expired link'}</h1><p style="color:var(--muted)">${ok?`Position #${safeId} will no longer receive alert emails. You can turn them back on anytime from your dashboard.`:'We could not process that unsubscribe link. You can manage alerts from your dashboard.'}</p><a class="btn btn-primary" href="/my/${safeId}">Open dashboard</a></div></body></html>`;
|
||
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==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.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==='/presentation'||pathname==='/presentation/'||pathname==='/webinar'||pathname==='/webinar/')file=path.join(PUBLIC_DIR,'presentation.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==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.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==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/replays'||pathname==='/replays/')file=path.join(PUBLIC_DIR,'replays.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/wallet-check'||pathname==='/wallet-check/')file=path.join(PUBLIC_DIR,'wallet-check.html');else if(pathname==='/suite/brand'||pathname==='/suite/brand/')file=path.join(PUBLIC_DIR,'suite-brand.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.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{tgbot.notifyEvent(evt);}catch(e){}
|
||
try{
|
||
const c=getConfig();
|
||
const EK=tgEventKey(evt); // one id for this on-chain event, so no feed can post it twice
|
||
// A new position starts on its sponsor's CURRENT setting - a one-time copy, so a team
|
||
// duplicates its leader without anyone already in it being moved later.
|
||
if(evt.type==='registered'&&evt.referrerId){ try{ placement.inherit(evt.id,evt.referrerId,c); }catch(e){} }
|
||
// 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);
|
||
// A brand-new member has no uplineId yet at the moment the 'registered'
|
||
// event fires: the indexer wires the matrix in a SEPARATE pass after the
|
||
// log loop. So isInTeam(newId) walks nothing, returns false, and the
|
||
// announcement is silently dropped — while the payout it triggered goes
|
||
// out, because that references existing members whose uplines are known.
|
||
// That was the "payout with no joiner beside it" Marty kept seeing.
|
||
// The referrer is always already wired, and if they are in the org so is
|
||
// anyone joining under them, so test them too.
|
||
const ids=evt.type==='payout'?[evt.toId,evt.fromId]
|
||
:evt.type==='registered'?[evt.id,evt.referrerId]
|
||
:[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'){const tl=suiteTools.unlockText(evt.level,evt.levelName,getConfig());text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.${tl?`\n🧰 Unlocked: ${tl}.`:''}`;}
|
||
else {
|
||
// On an upgrade pass-up, say what the BUYER unlocked. This is the exact
|
||
// moment "your level is your reach" is provable — someone below climbed,
|
||
// and that is why this payment landed here. Upgrades are rare next to
|
||
// payouts, so without this the toolkit is almost never mentioned.
|
||
// Entry rewards get no tool line: nobody changed level.
|
||
const upTools = evt.kind==='upline' ? suiteTools.unlockText(0,evt.levelName,getConfig()) : '';
|
||
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(upTools) text+=`\n🧰 #${evt.fromId} reached ${evt.levelName} and unlocked ${upTools}.`;
|
||
}
|
||
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
|
||
// team-build topic: telegramTeamEvents 'all' (default) | 'no-payouts' (payout lines live in
|
||
// the shared payments topic instead) | 'none' (Marty 2026-09-12: nothing but the daily
|
||
// snapshot, which the cron posts to this topic on its own).
|
||
{ const tm=String(c.telegramTeamEvents||'all'); if(tm!=='none' && !(evt.type==='payout' && tm==='no-payouts')) sendTelegram(text, undefined, undefined, EK&&('team:'+EK)); }
|
||
// 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){ const cta='https://'+String(c.recruitCtaUrl||'rmcircle.team').replace(/^https?:\/\//,''); sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:cta+(cta.includes('?')?'&':'?')+'utm_source=tg'}]]}, EK&&('recruit:'+EK)); } }
|
||
// company payment-proof channel: the recruiting copy plus the Polygonscan
|
||
// receipt, posted by the SAME bot to a separate chat. Payouts only unless
|
||
// telegramProofEvents is 'all'. Fires only when a proof chat id is set.
|
||
// telegramProofEvents: 'payouts' | 'payouts+upgrades' (default) | 'all'
|
||
if(c.telegramProofChatId){
|
||
const mode=String(c.telegramProofEvents||'payouts+upgrades');
|
||
const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all');
|
||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramProofChatId, pm, c.telegramProofTopicId||null, null, 'HTML', EK&&('proof:'+EK)); }
|
||
}
|
||
// Echo feed: the same proof line into a shared cross-program payments topic
|
||
// (config.telegramEchoTopicId in the team forum, or telegramEchoChatId for another
|
||
// chat). InstantAdPay posts its payouts into the same topic, so each line is
|
||
// prefixed with the program name. telegramEchoEvents: 'payouts' (default) |
|
||
// 'payouts+upgrades' | 'all'.
|
||
if(c.telegramEchoTopicId||c.telegramEchoChatId){
|
||
const mode=String(c.telegramEchoEvents||'payouts');
|
||
const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all');
|
||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} <b>RM Circle</b> \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML', EK&&('echo:'+EK)); }
|
||
}
|
||
// 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)}
|
||
});
|