From 0f5630e6d03953bc0bedc5bd48c3bfbe0ba439e6 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 14 Aug 2026 14:45:57 -0500 Subject: [PATCH] Disclaimer page, self-service member alerts, and admin login lockout - /disclaimer: independent-resource, affiliate, earnings, risk, and not-advice disclosures; linked from all footers. - Self-service alerts: members opt in with their email on /my/:id to get "you've been paid" + "upgrade needed" emails for their own position (same watcher as the owner alerts, extended). Signed unsubscribe link (/unsubscribe?id=&t=HMAC), on-chain-registration check, rate-limited, masked-email status, confirmation email. - Admin login: timing-safe compare + per-IP lockout (8 fails -> 15 min, escalating). Previously unlimited/brute-forceable. Co-Authored-By: Claude Fable 5 --- public/contract.html | 2 +- public/disclaimer.html | 28 ++++++++++ public/index.html | 2 +- public/join.html | 2 +- public/my.html | 3 +- public/my.js | 29 ++++++++++ public/start.html | 2 +- public/training.html | 2 +- server.js | 117 +++++++++++++++++++++++++++++++++-------- 9 files changed, 159 insertions(+), 28 deletions(-) create mode 100644 public/disclaimer.html diff --git a/public/contract.html b/public/contract.html index 21189c9..6986c41 100644 --- a/public/contract.html +++ b/public/contract.html @@ -27,5 +27,5 @@

Review method: complete verified source (Sourcify exact-match, creation + runtime bytecode) read against the deployed contract on 2026-08-13; payout math cross-checked against live on-chain transactions. Reviewed independently by this team's tooling — not by the contract's developers.

-
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose.
+
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose.
diff --git a/public/disclaimer.html b/public/disclaimer.html new file mode 100644 index 0000000..7f981ce --- /dev/null +++ b/public/disclaimer.html @@ -0,0 +1,28 @@ +Disclaimers | Crypto Team Build + + + + +
+
+
Please read

Disclaimers & Disclosures

This page governs your use of this website. By using it you acknowledge you've read and understood the following. It is written plainly and on purpose — nothing here is hidden.

+ +

Informational & educational only

This is an independent team resource for members and prospective members of the RM Circle Premium team build. Everything on it is provided for informational and educational purposes only. The live figures, dashboards, payment feeds, matrix views, and member pages display public information read directly from the Polygon blockchain — data that is already public and that anyone can view independently on a blockchain explorer. We present it in a more readable form; we do not create, control, or guarantee it.

+ +

Not affiliated — we don't own the contract or the blockchain

This website is not operated by, endorsed by, or officially affiliated with the creators, owners, or operators of the RM Circle smart contract, the RM Circle dApp (thermcircle.com), the Crypto Team Build Network, Polygon, or any blockchain. We do not own or control the smart contract, the blockchain, your wallet, or your funds. We are independent participants who built these tools to help our own team understand and follow the program. The smart contract is a public, third-party program that operates on its own, exactly as its code dictates — see our plain-language review at /contract.

+ +

Earnings disclaimer — no income is promised

Nothing here is a promise, projection, or guarantee of income. Any amounts shown — including historical payments and "pipeline" or "incoming" figures — are either records of past on-chain events or amounts that would be paid by the contract's rules if specific future actions occur. They are not predictions of what you will earn, and they are not typical results.

The majority of participants in programs like this do not profit, and many lose their entire contribution. Whether anyone earns anything depends entirely on their own effort, the actions of others, the smart contract's rules, and the market value of POL — all outside our control and yours. Past results never indicate future results. Do not participate expecting to make money.

+ +

Affiliate & material-connection disclosure

The people who operate this website participate in the RM Circle program themselves and hold positions in it. We may benefit financially — through the smart contract's referral and matrix payments — when others join or upgrade, including through links, QR codes, or the sponsor rotation on this site. In other words, we have a direct financial interest in your participation. We disclose this openly so you can weigh it. Assume any link that leads to joining benefits a member of our team.

+ +

Cryptocurrency & smart-contract risk

Participation requires sending cryptocurrency (POL) to a smart contract on a public blockchain. This is high-risk. Blockchain transactions are irreversible. Smart contracts can contain bugs or behave in unexpected ways. Token values are volatile and can fall sharply. You can lose some or all of the funds you commit. Never send funds you cannot afford to lose entirely, and never share your wallet's Secret Recovery Phrase or private keys with anyone — including anyone claiming to represent this team.

+ +

Not financial, legal, or tax advice

Nothing on this website is financial, investment, legal, or tax advice, and nothing here is a solicitation or offer of a security. Cryptocurrency and program participation may be regulated or restricted where you live, and rules vary by country and state. You are responsible for determining whether participation is legal for you and for your own compliance and taxes. Consult your own qualified, independent professional advisors before participating. Do not participate if it is not permitted in your jurisdiction.

+ +

No warranty; accuracy

This site is provided "as is," with no warranties of any kind. Although data is read from the blockchain, we don't guarantee it is complete, current, or error-free — blockchain nodes can lag, and displays can contain mistakes. Always verify anything important yourself directly on the blockchain (Polygonscan ↗) before acting. To the fullest extent permitted by law, the operators of this site are not liable for any loss arising from your use of it or your participation in any program.

+ +

By continuing to use this website you accept these disclaimers. If you do not agree, please do not use the site. Questions can go to the team's Telegram group linked on the site.

+
+
+
Independent, unaffiliated team resource. Informational and educational only. Not an offer, solicitation, or guarantee of income. Cryptocurrency participation carries risk of total loss.
+ diff --git a/public/index.html b/public/index.html index a39d2ad..541fdd0 100644 --- a/public/index.html +++ b/public/index.html @@ -13,5 +13,5 @@
Depth over width

2 → 4 → 8 → 16 → 32

The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.

2
4
8
16
Team principle: once your two are in place, retire your link and shift to helping them get their two — their own directs are the only thing that qualifies their positions to catch payments. And if an extra signup comes through your link anyway, it's a bonus, not a problem: it still pays your position the entry reward and spills downward to fill an open slot in your leg. Spillover never qualifies the people below, though — so the team effort always moves down.
Live payment proof

Real payouts, straight from the blockchain.

Every payment in this program happens on a public smart contract on Polygon — nobody can fake, hide, or edit it. Below are the latest member payouts, read live from the contract. Tap any row to verify the transaction yourself on Polygonscan.

Reading the blockchain…
Data is read directly from the RM Circle smart contract (0x33Bd…2DAF) on Polygon Mainnet. Member numbers are on-chain IDs, not names. Past payouts are not a promise of future results. How the contract works — and why the rules can't change →
Ready to start?

See the current team placement.

The onboarding page automatically shows the sponsor position the team is currently helping. Always use the sponsor shown there instead of an old screenshot or saved link.

Open Getting Started Instructions →
-
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.
+
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.
diff --git a/public/join.html b/public/join.html index b0451f2..0a2e425 100644 --- a/public/join.html +++ b/public/join.html @@ -26,5 +26,5 @@
What happens next: get your 2 directs to qualify, then retire your link and help your 2 get their 2 — that's the whole system. You'll get your own invite page just like this one the moment you're in.
Risk reminder: participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.
-
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.
+
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.
diff --git a/public/my.html b/public/my.html index 8a4a143..7a9945b 100644 --- a/public/my.html +++ b/public/my.html @@ -9,6 +9,7 @@

Your team

Your position's matrix — the rollup line on each card counts everyone underneath, all the way down. Click a position to drill into that leg. Open slots are where the next placements land.

✓ qualified (2/2 directs) · P Premium · S Standard · ⬇ everyone below that position (all generations) and the POL they've earned · ↧ spillover = placed there by upline activity; only members who join with a position's own ID count toward its 2/2

Your pipeline

Money forming below you. Each generation in your leg pays your position at exactly one level — when a member's level catches up to their depth, their next upgrade comes to you.

+

Email me my alerts

Get an email the moment this position is paid, and when it needs an upgrade to catch incoming pay — so you never miss one. Opt in with your email; unsubscribe anytime.

Share this position

Just joined under this position?

Welcome to the team! Enter the new member ID the RM Circle dApp gave you — we'll verify it on the blockchain and let the team know you're in.

Payments received

Every payment your position has received, straight from the smart contract.

@@ -16,5 +17,5 @@
Team reminder: once your two directs are placed, retire your link and help your two get their two — always send new members through the current team sponsor page.
-
All figures are read live from the RM Circle smart contract on Polygon and are historical facts, not a promise of future results. Participation involves cryptocurrency and smart-contract risk. Never use funds you cannot afford to lose.
+
All figures are read live from the RM Circle smart contract on Polygon and are historical facts, not a promise of future results. Participation involves cryptocurrency and smart-contract risk. Never use funds you cannot afford to lose.
diff --git a/public/my.js b/public/my.js index f626e7c..439b2f6 100644 --- a/public/my.js +++ b/public/my.js @@ -100,6 +100,7 @@ ?`
${inc.map(p=>``).join('')}
WhenFrom memberForAmount
${date(p.ts)}#${p.fromId}${esc(p.desc)}${fmt(p.pol)} POL
` :'
No payments yet — they appear here the moment they land on-chain.
'; renderPipeline(d); + renderAlerts(d); renderShare(d); document.getElementById('dLineage').innerHTML=d.uplineChain&&d.uplineChain.length ?`#${d.id} → ${d.uplineChain.map(i=>'#'+i).join(' → ')} (root)` @@ -204,6 +205,34 @@ L.push(`See it live and get a red alert the moment you'd miss one: rmcircle.saasy.top/my/${d.id}`); return L.join('\n'); } + async function renderAlerts(d){ + const el=document.getElementById('dAlerts'); + if(!el)return; + let st={subscribed:false}; + try{st=await (await fetch('/api/public/alert-status?id='+d.id)).json();}catch(e){} + function subscribedView(email){ + el.innerHTML=`

Alerts ON for ${esc(email||'your email')}

`; + document.getElementById('alertOff').addEventListener('click',async()=>{ + try{await fetch('/api/public/alert-signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id,email:''})});renderAlerts(d);}catch(e){document.getElementById('alertMsg').textContent='Try again.';} + }); + } + function offView(){ + el.innerHTML=`
`; + document.getElementById('alertForm').addEventListener('submit',async e=>{ + e.preventDefault(); + const email=e.currentTarget.elements.email.value.trim(),msg=document.getElementById('alertMsg'); + msg.style.color='var(--muted)';msg.textContent='Turning on…'; + try{ + const r=await fetch('/api/public/alert-signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id,email})}); + const j=await r.json(); + if(!r.ok)throw new Error(j.error||'Failed'); + msg.style.color='var(--ok)';msg.textContent='Done — check your inbox for a confirmation.'; + setTimeout(()=>renderAlerts(d),1200); + }catch(x){msg.style.color='var(--danger)';msg.textContent=x.message;} + }); + } + if(st.subscribed)subscribedView(st.email);else offView(); + } function renderShare(d){ const el=document.getElementById('dShare'); if(!el)return; diff --git a/public/start.html b/public/start.html index bbd8b50..77fdcc2 100644 --- a/public/start.html +++ b/public/start.html @@ -15,5 +15,5 @@
What happens next: get your 2 directs to qualify, then retire your link and help your 2 get their 2 — their own directs are what qualify them. If an extra referral happens anyway, it's a bonus: it still pays you and spills down your leg. Upgrade with earned POL when practical, and stay ahead of your team's levels so payments never pass you by.
Risk reminder: participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.
RM Circle Premium Team Build Roadmap — core strategy, step-by-step guide, premium levels, and duplication formula
The RM Circle is a team build project of the Crypto Team Build Network. This roadmap is the plan every member follows — tap to view full size.
- + diff --git a/public/training.html b/public/training.html index f35b281..fddf976 100644 --- a/public/training.html +++ b/public/training.html @@ -26,5 +26,5 @@
Risk reminder: participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.
- + diff --git a/server.js b/server.js index 4d19147..a19e061 100644 --- a/server.js +++ b/server.js @@ -58,6 +58,26 @@ function memberLookupLimited(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); @@ -100,10 +120,11 @@ function sendEmailRaw(toEmail, subject, 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) { +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 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.saasy.top/training\n\n— The RM Circle Team\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 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.saasy.top/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) { @@ -337,6 +358,27 @@ async function handleApi(req,res,pathname){ if(req.method==='GET'&&pathname==='/api/public/payouts'){ return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'}); } + if(req.method==='GET'&&pathname==='/api/public/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.saasy.top/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/current-sponsor'){ const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'}); return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'}); @@ -353,7 +395,13 @@ async function handleApi(req,res,pathname){ const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true}); } if(req.method==='POST'&&pathname==='/api/admin/login'){ - const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'}); + const 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'){ @@ -434,8 +482,16 @@ const server=http.createServer(async(req,res)=>{ const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname); if(pathname==='/health'||pathname.startsWith('/api/'))return await handleApi(req,res,pathname); if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'}); + if(pathname==='/unsubscribe'){ + const id=u.searchParams.get('id')||'', t=u.searchParams.get('t')||''; + let ok=false; + if(/^\d{1,15}$/.test(id)&&t&&t===unsubToken(id)){ const ma=getMemberAlerts(); if(ma[id]){delete ma[id];saveMemberAlerts(ma);} ok=true; } + const safeId=/^\d{1,15}$/.test(id)?id:''; + const body=`${ok?'Unsubscribed':'Invalid link'}`; + res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}));return res.end(body); + } let file; - if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{ + if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});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); @@ -449,35 +505,49 @@ server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on 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); } -function checkOwnerUpgrades(){ +// 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.saasy.top/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 for both owner positions (email+Telegram) and any member +// who opted in via their dashboard (email only). Runs every 5 min from state. +function checkUpgradeAlerts(){ try{ - const c=getConfig(); const ids=parseOwnerIds(); + 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(); + const alerted=loadOwnerAlerts(); const active=new Set(); const tgDone=new Set(); for(const n of res.needs){ - const key=`${n.id}:${n.reason}:${n.neededLevel}`; active.add(key); - if(alerted.has(key)) continue; - alerted.add(key); 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.`; - if(c.ownerAlertEmail){ - sendEmailRaw(c.ownerAlertEmail, + 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 — one of your positions has money about to arrive that it can't catch yet.\n\nPosition #${n.id} is at ${n.levelName}. Member(s) ${who} are ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that payment 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 you (it doesn't come back). Your positions: https://rmcircle.saasy.top/admin\n\n— RM Circle auto-watch`); + `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`); + if(w.owner&&!tgDone.has(n.id+':'+n.neededLevel)){ tgDone.add(n.id+':'+n.neededLevel); sendTelegram(`⏫ UPGRADE #${n.id} SOON: ${who} one upgrade from paying ~${n.amountAtRisk} POL. #${n.id} is ${n.levelName} — needs ${n.neededLevelName}${n.reason==='qualify'?' + 2 directs':''}. Upgrade before they do.`); } } - sendTelegram(`⏫ UPGRADE #${n.id} SOON: ${who} one upgrade from paying ~${n.amountAtRisk} POL. #${n.id} is ${n.levelName} — needs ${n.neededLevelName}${n.reason==='qualify'?' + 2 directs':''}. Upgrade before they do.`); } 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('owner upgrade check', e.message); } + }catch(e){ console.error('upgrade alert check', e.message); } } -setInterval(checkOwnerUpgrades, 5*60*1000).unref(); -setTimeout(checkOwnerUpgrades, 30000).unref(); +setInterval(checkUpgradeAlerts, 5*60*1000).unref(); +setTimeout(checkUpgradeAlerts, 30000).unref(); chain.startIndexer(evt=>{ try{ const c=getConfig(); @@ -500,11 +570,14 @@ chain.startIndexer(evt=>{ } } }catch(e){console.error('team alert error',e.message)} - // "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated) + // "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); + 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-count directs (Marty 2026-08-14, "prevent manual effort"): a new