diff --git a/profiles.js b/profiles.js new file mode 100644 index 0000000..0cb3a55 --- /dev/null +++ b/profiles.js @@ -0,0 +1,172 @@ +// RM Circle member profiles: username + verified email per POSITION. +// +// Why position-keyed and not wallet-keyed: the contract allows one position per +// address, so a Triple Play holder owns three wallets and three positions. The +// person is the EMAIL; one email may hold several positions (that is expected and +// surfaced as "your positions"). Everything else on the site is position-keyed +// too, which is why the existing member-alerts.json seeds straight in. +// +// Identity is only ever written by a session that PROVED ownership of the +// position: a wallet personal_sign (messages.verifyChallenge) or the Telegram +// Mini App bridge. The public /my/ page can never write a profile, because +// anyone can open it. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +let DATA_DIR = null; +let sendEmail = () => {}; +const FILE = () => path.join(DATA_DIR, 'profiles.json'); +const ALERTS = () => path.join(DATA_DIR, 'member-alerts.json'); + +const USER_RE = /^[a-z0-9_]{3,20}$/; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; +const RESERVED = new Set(['admin', 'administrator', 'support', 'rmcircle', 'rm_circle', 'founder', 'founders', + 'official', 'team', 'help', 'moderator', 'mod', 'staff', 'owner', 'ceo', 'system', 'root', 'null', 'undefined']); +const CODE_TTL = 15 * 60 * 1000; +const CODE_COOLDOWN = 60 * 1000; +const CODE_MAX_PER_DAY = 5; +const CODE_MAX_TRIES = 6; + +let db = null; // { byId: { "": profile }, v: 1 } +const codes = new Map(); // positionId -> { code, exp, tries, nextAt, email, sentToday, day } + +function load() { + try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { db = null; } + if (!db || db.v !== 1) db = { v: 1, byId: {} }; + if (!db.byId) db.byId = {}; +} +function save() { try { fs.writeFileSync(FILE(), JSON.stringify(db)); } catch (e) { console.error('profiles save', e.message); } } +function init(opts) { + DATA_DIR = opts.dataDir; + if (opts.sendEmail) sendEmail = opts.sendEmail; + load(); + seedFromAlerts(); +} +// The 40 positions that already gave an email for payout alerts: pre-fill it so +// the gate is one tap for them. NOT marked verified — they never proved the +// address in a flow we control, and a verify code costs them one click. +function seedFromAlerts() { + let a = {}; + try { a = JSON.parse(fs.readFileSync(ALERTS(), 'utf8')); } catch (e) { return; } + let n = 0; + for (const [id, rec] of Object.entries(a || {})) { + const pid = String(Number(id) || 0); + if (pid === '0' || !rec || !EMAIL_RE.test(String(rec.email || ''))) continue; + const p = db.byId[pid]; + if (p && (p.email || p.emailVerified)) continue; + db.byId[pid] = Object.assign({ id: Number(pid), username: null, email: null, emailVerified: false, + telegramId: null, created: Date.now(), updated: Date.now() }, p || {}, + { email: String(rec.email).trim().toLowerCase(), emailVerified: false, seededFrom: 'alerts' }); + n++; + } + if (n) { save(); console.log('profiles: seeded', n, 'emails from member-alerts.json'); } +} + +const norm = id => String(Number(id) || 0); +function get(id) { const p = db.byId[norm(id)]; return p ? Object.assign({}, p) : null; } +function pub(p) { + if (!p) return null; + return { id: p.id, username: p.username || null, email: p.email || null, emailVerified: !!p.emailVerified, + telegramId: p.telegramId || null, complete: isComplete(p) }; +} +function isComplete(p) { return !!(p && p.username && p.email && p.emailVerified); } +function complete(id) { return isComplete(db.byId[norm(id)]); } +function ensure(id) { + const k = norm(id); + if (!db.byId[k]) { db.byId[k] = { id: Number(k), username: null, email: null, emailVerified: false, telegramId: null, created: Date.now(), updated: Date.now() }; save(); } + return db.byId[k]; +} +// what the gate needs to render: the profile plus anything pre-filled for them +function status(id) { + const p = get(id) || { id: Number(norm(id)), username: null, email: null, emailVerified: false }; + return { profile: pub(p), needs: { username: !p.username, email: !(p.email && p.emailVerified) } }; +} + +// ---- username ---- +function usernameTaken(name, exceptId) { + const k = norm(exceptId); + for (const [id, p] of Object.entries(db.byId)) if (id !== k && String(p.username || '').toLowerCase() === name) return true; + return false; +} +function setUsername(id, raw) { + const name = String(raw || '').trim().toLowerCase().replace(/^@/, ''); + if (!USER_RE.test(name)) return { error: 'Usernames are 3 to 20 characters: letters, numbers or underscores.' }; + if (!/[a-z]/.test(name)) return { error: 'Usernames need at least one letter.' }; + if (RESERVED.has(name)) return { error: 'That username is reserved. Pick another.' }; + if (usernameTaken(name, id)) return { error: 'That username is taken. Pick another.' }; + const p = ensure(id); + p.username = name; p.updated = Date.now(); save(); + return { ok: true, profile: pub(p) }; +} +function byUsername(name) { + const n = String(name || '').trim().toLowerCase().replace(/^@/, ''); + for (const p of Object.values(db.byId)) if (String(p.username || '').toLowerCase() === n) return Object.assign({}, p); + return null; +} +function suggest(id) { + const base = 'member' + norm(id); + return usernameTaken(base, id) ? base + crypto.randomBytes(1).toString('hex') : base; +} + +// ---- email verification ---- +function today() { return new Date().toISOString().slice(0, 10); } +function startEmail(id, rawEmail) { + const email = String(rawEmail || '').trim().toLowerCase(); + if (!EMAIL_RE.test(email)) return { error: 'That email address does not look right.' }; + const k = norm(id); + const st = codes.get(k) || { sentToday: 0, day: today() }; + if (st.day !== today()) { st.sentToday = 0; st.day = today(); } + if (st.nextAt && Date.now() < st.nextAt) return { error: 'Code already sent. Give it a minute, then try again.' }; + if (st.sentToday >= CODE_MAX_PER_DAY) return { error: 'Too many codes today. Try again tomorrow, or contact support.' }; + const code = String(Math.floor(100000 + Math.random() * 900000)); + codes.set(k, { code, email, exp: Date.now() + CODE_TTL, tries: 0, nextAt: Date.now() + CODE_COOLDOWN, sentToday: st.sentToday + 1, day: st.day }); + const subject = 'Your RM Circle code: ' + code; + const text = 'Your confirmation code for RM Circle position #' + k + ' is:\n\n ' + code + + '\n\nEnter it on your dashboard to finish setting up your member profile. The code lasts 15 minutes.\n\n' + + 'Why we ask: your email is how your team leader can reach you and how you get a note the moment a payout lands in your wallet. ' + + 'It is never shown to other members and never sold.\n\nIf you did not ask for this, ignore it.'; + try { sendEmail(email, subject, text); } catch (e) { console.error('profiles sendEmail', e.message); } + return { ok: true, sent: true, to: email.replace(/^(.{2}).*(@.*)$/, '$1***$2') }; +} +function verifyEmail(id, rawCode) { + const k = norm(id); + const st = codes.get(k); + if (!st || !st.code) return { error: 'Ask for a fresh code first.' }; + if (st.exp < Date.now()) { codes.delete(k); return { error: 'That code expired. Ask for a fresh one.' }; } + st.tries += 1; + if (st.tries > CODE_MAX_TRIES) { codes.delete(k); return { error: 'Too many tries. Ask for a fresh code.' }; } + if (String(rawCode || '').trim() !== st.code) return { error: 'That code does not match.' }; + const p = ensure(k); + p.email = st.email; p.emailVerified = true; p.emailVerifiedAt = Date.now(); p.updated = Date.now(); + delete p.seededFrom; + codes.delete(k); + save(); + return { ok: true, profile: pub(p) }; +} + +// ---- reach: who can actually be contacted, and how ---- +function contactFor(id) { + const p = db.byId[norm(id)]; + if (!p) return { id: Number(norm(id)), email: null, telegramId: null, reachable: false }; + return { id: p.id, username: p.username || null, email: p.emailVerified ? p.email : null, telegramId: p.telegramId || null, + reachable: !!(p.emailVerified && p.email) }; +} +function setTelegram(id, tgId) { const p = ensure(id); p.telegramId = tgId ? String(tgId) : null; p.updated = Date.now(); save(); return pub(p); } +// every position this email holds (Triple Play and second positions) +function positionsFor(email) { + const e = String(email || '').trim().toLowerCase(); + return Object.values(db.byId).filter(p => p.emailVerified && String(p.email || '') === e).map(p => p.id).sort((a, b) => a - b); +} +function coverage() { + const all = Object.values(db.byId); + return { profiles: all.length, withUsername: all.filter(p => p.username).length, + withEmail: all.filter(p => p.email).length, verified: all.filter(p => p.emailVerified).length, + withTelegram: all.filter(p => p.telegramId).length, + complete: all.filter(isComplete).length }; +} +function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.id - b.id); } + +module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, + startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE }; diff --git a/public/chat.js b/public/chat.js index 16a05cc..284694b 100644 --- a/public/chat.js +++ b/public/chat.js @@ -51,6 +51,8 @@ a:()=>`Yes — every position includes a real members area: The Circle Method (10-lesson recruiting & coaching course + a real cash-out walkthrough) on the training page, the weekly team webinar replays, your live coaching dashboard, promo tools personalized with your invite link, printable playbooks, wallet-verified team messaging, and the Telegram companion bot + Mini App. No passwords — access is proven by the wallet that owns your position (one free signature that can't move funds), and inside the Telegram Mini App it unlocks automatically. Lesson 1 and all the how-it-works videos are public so you can inspect everything before joining.`}, {k:['no money','cant afford','can not afford',"can't afford",'broke','they dont have','waiting on payday','no funds to join'], a:()=>`"No money" is usually one of three things — sort it first. (1) Most often it means "this sounds expensive/complicated": entry is ${pol()} POL${usd()||" — a small one-time amount"} — payable with a regular debit card right on the join page. (2) Sometimes it's a priority thing: send the "Pocket Change" video from Promo Tools and let it do the talking. (3) If they're genuinely broke: tell them honestly "don't join yet" — never rent money, never money they can't afford to lose. Ask "when's payday?" and follow up then — their invite link doesn't expire and the team keeps building publicly while they wait. One hard rule: never pay someone's entry for them.`}, + {k:['username','set up my profile','profile','my email','email address','why do you need my email','asking for my email','confirm my email','verification code','code did not arrive','required profile','member profile'], + a:()=>`When you sign in to your position for the first time (one free wallet signature, or automatically inside the Telegram Mini App), you're asked for two things once: a username and a confirmed email. It takes about a minute and it's required to open the member area. Why: your team leader has no way to reach you otherwise, and your email is how you get a note the moment a payout lands in your wallet. Your email is never shown to other members and never sold, and you can change it any time. The code arrives in seconds — check spam the first time. If you already gave an email for payout alerts, it's pre-filled and you just confirm it. Viewing a position's public page at rmcircle.team/my never asks for anything.`}, {k:['own the level','need to own','have to buy the level','catch without','still at scintilla','upline not upgraded','sponsor not upgraded','only pays 2','only pays two','only 2 payments','seems small','2 then 4','level is your reach'], a:()=>`Two facts that surprise people, both straight from the contract code: (1) To catch your two matrix children's FIRST upgrade (Ascensus), you only need to be qualified with your two directs — you do NOT need to own Ascensus yourself, because the contract checks the catcher against the level the buyer is jumping FROM (Scintilla, which everyone has). Owning levels extends your REACH: catching a Fabrica payment from 2 generations down requires Ascensus, and so on — your level is your reach, which is why the team stays one level ahead of its deepest active layer. There's a 97-second video on exactly this — "Your level is your reach" — on the training page (Video 7). And to watch the whole discipline run on a REAL position (every upgrade funded by the catch before it), see "The Textbook Play". (2) Yes, each depth pays a fixed count — 2, then 4, then 8, one payment per person — but each deeper generation pays twice as much per person at twice the width (full-generation value quadruples per level), generations keep filling over time via recruiting and spillover, and pass-ups from under-leveled members add catches on top. Exact numbers: how-pay-works#exact-costs — mechanical maximums, never promised income. Want it as a printable chart personalized to YOUR position (shows which generations you're catching now vs. which need your next level)? rmcircle.team/generation-pay — enter your ID and print.`}, {k:['5 million','5,090','1.7 million','1,696','running total','total through','fabrica is 8','ascensus is 4','rows shifted','one level off','which level pays','level you hold','they pay you when'], diff --git a/public/my.html b/public/my.html index 92fbc6b..488fc84 100644 --- a/public/my.html +++ b/public/my.html @@ -31,5 +31,5 @@
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 fd9ccb6..72c5fb2 100644 --- a/public/my.js +++ b/public/my.js @@ -419,6 +419,7 @@ const sig=await eth.request({method:'personal_sign',params:[hex,account]}); const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json(); if(!v.ok)throw new Error(v.error||'Verification failed.'); + try{ if(window.RMCProfile)await window.RMCProfile.require(); }catch(ge){} loadMsgUI(d); }catch(e){if(err)err.textContent=e.message||String(e);} } @@ -756,4 +757,7 @@ const id=pathId(); if(id&&!pathHasId)openDashTab=true; // bare /my resolved from storage = returning to your own page if(id)load(id); + // Required member profile (username + verified email). No-ops for a visitor who + // has not proved they own a position: the API 401s and the gate never shows. + try{ if(window.RMCProfile)window.RMCProfile.require(); }catch(e){} })(); diff --git a/public/profile-gate.js b/public/profile-gate.js new file mode 100644 index 0000000..4970c91 --- /dev/null +++ b/public/profile-gate.js @@ -0,0 +1,179 @@ +// RM Circle: required member profile gate (Marty, 2026-09-16). +// +// The member area only opens once a position has a username and a VERIFIED email. +// It fires the moment ownership is proved (wallet personal_sign, or the Telegram +// Mini App bridge) and cannot be dismissed, because the whole point is that a +// leader can reach every member. The public /my/ page is untouched: anyone +// can still read the chain data there, and nobody can write a profile from it. +// +// Usage: await window.RMCProfile.require(); // resolves once the profile is complete +(function () { + 'use strict'; + var back = null, resolveDone = null, state = null; + var GOLD = '#d4af37', TEAL = '#4ed6cb'; + + function el(tag, css, html) { + var e = document.createElement(tag); + if (css) e.style.cssText = css; + if (html != null) e.innerHTML = html; + return e; + } + function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); } + async function api(path, body) { + var r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); + var j = null; try { j = await r.json(); } catch (e) { j = {}; } + if (!r.ok || j.error) throw new Error(j.error || 'Something went wrong. Try again.'); + return j; + } + + function shell() { + back = el('div', 'position:fixed;inset:0;z-index:2147483100;display:flex;align-items:center;justify-content:center;' + + 'padding:20px;background:rgba(3,7,14,.88);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow:auto;'); + back.setAttribute('role', 'dialog'); + back.setAttribute('aria-label', 'Finish setting up your member profile'); + var card = el('div', 'position:relative;width:100%;max-width:440px;max-height:94vh;overflow:auto;border-radius:20px;' + + 'background:#0a1119;border:1px solid rgba(212,175,55,.55);box-shadow:0 24px 70px rgba(0,0,0,.7);' + + 'font-family:system-ui,Segoe UI,Arial,sans-serif;color:#e8eef6;padding:22px 22px 20px;'); + card.id = 'pgCard'; + back.appendChild(card); + document.body.appendChild(back); + return card; + } + + function head(card, step) { + card.innerHTML = ''; + card.appendChild(el('div', 'text-align:center;letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:' + GOLD + ';margin-bottom:8px;', + 'Position #' + esc(state.id) + ' · step ' + step + ' of 2')); + return card; + } + function note(text, color) { + return el('p', 'margin:0 0 14px;font-size:13.5px;line-height:1.6;color:' + (color || '#9fb3c8') + ';', text); + } + function input(id, placeholder, value, type) { + var i = el('input'); + i.id = id; i.type = type || 'text'; i.placeholder = placeholder; i.value = value || ''; + i.autocomplete = type === 'email' ? 'email' : 'off'; + i.style.cssText = 'width:100%;box-sizing:border-box;padding:12px 14px;border-radius:11px;border:1px solid rgba(255,255,255,.16);' + + 'background:rgba(255,255,255,.04);color:#fff;font-size:16px;margin-bottom:10px;'; + return i; + } + function button(label) { + var b = el('button', 'width:100%;padding:13px 16px;border-radius:11px;border:none;cursor:pointer;font-size:15px;font-weight:700;' + + 'background:linear-gradient(180deg,' + GOLD + ',#b8932f);color:#1a1205;', label); + return b; + } + function errLine() { return el('p', 'margin:0 0 10px;font-size:13px;color:#ff8b8b;display:none;'); } + + // ---- step 1: username ---- + function stepUsername() { + var card = head(document.getElementById('pgCard'), 1); + card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Pick your username')); + card.appendChild(note('This is how your team leader and the people in your line see you, instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters.')); + var err = errLine(); card.appendChild(err); + var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || ''); + card.appendChild(i); + var b = button('Save and continue'); + card.appendChild(b); + var go = async function () { + err.style.display = 'none'; b.disabled = true; b.textContent = 'Saving…'; + try { + var r = await api('/api/public/profile/username', { username: i.value }); + state.profile = r.profile; + next(); + } catch (e) { + err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Save and continue'; + } + }; + b.addEventListener('click', go); + i.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } }); + setTimeout(function () { i.focus(); }, 60); + } + + // ---- step 2: email + code ---- + function stepEmail() { + var card = head(document.getElementById('pgCard'), 2); + var prefill = (state.profile && state.profile.email) || ''; + card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Confirm your email')); + card.appendChild(note('Two reasons this is required. Your leader can actually reach you, and you get a note the moment a payout lands in your wallet. ' + + 'It is never shown to other members, never sold, and you can change it any time.' + + (prefill ? ' We already have this one on file for your payout alerts, so just confirm it.' : ''))); + var err = errLine(); card.appendChild(err); + var i = input('pgEmail', 'you@example.com', prefill, 'email'); + card.appendChild(i); + var b = button(prefill ? 'Send me the code' : 'Send me a code'); + card.appendChild(b); + var codeWrap = el('div', 'display:none;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.1);'); + var sentNote = note('', TEAL); codeWrap.appendChild(sentNote); + var ci = input('pgCode', '6-digit code', ''); + ci.inputMode = 'numeric'; ci.maxLength = 6; + codeWrap.appendChild(ci); + var cb = button('Confirm and finish'); + codeWrap.appendChild(cb); + var again = el('p', 'margin:10px 0 0;font-size:12.5px;color:#7f93a8;text-align:center;cursor:pointer;', 'Wrong address? Change it and send a new code.'); + again.addEventListener('click', function () { codeWrap.style.display = 'none'; b.disabled = false; b.textContent = 'Send me a code'; i.focus(); }); + codeWrap.appendChild(again); + card.appendChild(codeWrap); + + b.addEventListener('click', async function () { + err.style.display = 'none'; b.disabled = true; b.textContent = 'Sending…'; + try { + var r = await api('/api/public/profile/email-start', { email: i.value }); + sentNote.textContent = 'Code sent to ' + (r.to || i.value) + '. It lasts 15 minutes. Check spam the first time.'; + codeWrap.style.display = 'block'; b.textContent = 'Code sent'; + setTimeout(function () { ci.focus(); }, 60); + } catch (e) { + err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Send me a code'; + } + }); + var confirm = async function () { + err.style.display = 'none'; cb.disabled = true; cb.textContent = 'Checking…'; + try { + var r = await api('/api/public/profile/email-verify', { code: ci.value }); + state.profile = r.profile; + done(); + } catch (e) { + err.textContent = e.message; err.style.display = 'block'; cb.disabled = false; cb.textContent = 'Confirm and finish'; + } + }; + cb.addEventListener('click', confirm); + ci.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); confirm(); } }); + setTimeout(function () { i.focus(); }, 60); + } + + function done() { + var card = head(document.getElementById('pgCard'), 2); + card.innerHTML = '
' + + '
✅
' + + '

You are all set, @' + esc(state.profile.username) + '

' + + '

Your leader can reach you now, and every payout to your wallet sends you a note.

'; + var b = button('Open my dashboard'); + b.addEventListener('click', close); + card.appendChild(b); + setTimeout(close, 2600); + } + function close() { + if (back && back.parentNode) back.parentNode.removeChild(back); + back = null; + if (resolveDone) { var r = resolveDone; resolveDone = null; r(state && state.profile); } + } + function next() { + if (!state.profile || !state.profile.username) return stepUsername(); + if (!state.profile.emailVerified) return stepEmail(); + return done(); + } + + // Resolves once the profile is complete. Safe to call repeatedly: it returns + // immediately when there is nothing to collect, and never shows for a visitor + // who has not proved they own the position (the API 401s them). + async function require_() { + try { state = await api('/api/public/profile'); } + catch (e) { return null; } // not signed in: nothing to gate + if (state.profile && state.profile.complete) return state.profile; + if (back) return null; // already open + shell(); + return new Promise(function (res) { resolveDone = res; next(); }); + } + async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } } + + window.RMCProfile = { require: require_, status: status }; +})(); diff --git a/server.js b/server.js index 4234990..ae2187e 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,8 @@ const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); const chain = require('./chain'); -const messages = require('./messages'); +const messages = require('./messages'); +const profiles = require('./profiles'); const tweet = require('./tweet'); const PORT = Number(process.env.PORT || 3000); @@ -16,7 +17,8 @@ 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 }); +messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD }); +profiles.init({ dataDir: DATA_DIR, sendEmail: sendEmailRaw }); 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 }); @@ -76,7 +78,7 @@ FACTS: - 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/. - RESILIENCE ("what if the creators disappear / owner loses keys / it falls apart over time"): the contract is autonomous and immutable — NO admin action, heartbeat, or living operator is required for joins, upgrades, matrix placement, or payouts; there is no pause switch and no expiry. Verified on-chain that the founder, development, and fee-receiver wallets are ordinary wallets (EOAs), NOT smart contracts — an ordinary wallet always accepts incoming POL even if its key is lost forever, so a dead or abandoned admin wallet cannot block any member payment (only the project's OWN uncollected fee would sit idle). The contract stores no balance (every payment is delivered in the same transaction). If the owner's key were lost, only the four limited admin powers freeze in place; members are unaffected. Details in section 6 of https://rmcircle.team/contract. - Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.team/start right before joining. -- Site pages: https://rmcircle.team/ (strategy overview + roadmap + live team stats), https://rmcircle.team/start (current sponsor + join steps), https://rmcircle.team/training (THE CIRCLE METHOD — the team's 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), 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= 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). +- 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/ 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= 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/) — 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. @@ -1442,6 +1444,40 @@ async function handleApi(req,res,pathname){ 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/ is public and can not. + if(req.method==='GET'&&pathname==='/api/public/profile'){ + const s=messages.authFromCookie(req); + if(!s)return json(res,401,{error:'Not signed in.'}); + return json(res,200,Object.assign({ok:true,id:s.id,suggest:profiles.suggest(s.id)},profiles.status(s.id))); + } + 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); + 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); + if(r.ok)console.log('profile complete 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.'});