// 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) }; } // ---- opt out: take a detail back off ---- // We tell members "removable any time" in three places. That has to be true, and it has // to be reversible: removing clears the value, never the position (Marty + Manson, // 2026-09-17). The caller is responsible for also clearing member-alerts.json, because // the payout mailer reads that file, not this one. function remove(id, what) { const p = db.byId[norm(id)]; if (!p) return { ok: true, profile: pub({ id: Number(norm(id)) }), removed: [] }; const removed = []; if (what === 'email' || what === 'all') { if (p.email || p.emailVerified) removed.push('email'); p.email = null; p.emailVerified = false; delete p.emailVerifiedAt; delete p.seededFrom; codes.delete(norm(id)); // kill any half-finished code flow too } if (what === 'username' || what === 'all') { if (p.username) removed.push('username'); p.username = null; } if (what === 'all') { if (p.telegramId) removed.push('telegram'); p.telegramId = null; } if (!removed.length && what !== 'all' && what !== 'email' && what !== 'username') return { error: 'Nothing to remove.' }; p.updated = Date.now(); save(); return { ok: true, profile: pub(p), removed }; } // ---- 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); } // Reach for one leader's org: how many of the positions below them have given a // way to be contacted. This is the number that makes coverage a leader's own // problem rather than a rule imposed on members. function reachFor(ids) { const list = (ids || []).map(Number).filter(Boolean); const reachable = []; for (const id of list) { const p = db.byId[norm(id)]; if (p && p.emailVerified && p.email) reachable.push(id); } return { total: list.length, reachable: reachable.length, ids: reachable.slice(0, 2000) }; } 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); } // local testing only: the server exposes this outside production, never on the live site function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; } module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor, remove, startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };