7d07fc01f1
Marty, 2026-09-16: leaders can write but 94% of positions cannot receive (47 of 771 have ever signed in to messaging, 482 messages sit 85% unread). profiles.js stores username + verified email per POSITION (one wallet holds one position, so a Triple Play holder has three; the person is the email and one email may hold several positions). Seeds the 40 emails already on file from member-alerts.json, pre-filled but unverified so confirming costs one tap. Writes are only ever accepted from a session that PROVED ownership: wallet personal_sign (messages.verifyChallenge) or the Telegram Mini App bridge. The public /my/<id> page is untouched and cannot write a profile, verified by test: all four endpoints 401 unauthenticated while /my/21 stays 200. Endpoints GET /api/public/profile, POST .../username, .../email-start, .../email-verify, plus GET /api/admin/profiles for coverage. Email codes: 6 digits, 15 min, 60s cooldown, 5/day, 6 tries. profile-gate.js is a two-step modal that cannot be dismissed, fired on dashboard boot (covers the Mini App landing) and right after a wallet sign-in. Chatbot canned answer + AI prompt updated. 28 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
8.9 KiB
JavaScript
173 lines
8.9 KiB
JavaScript
// 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/<id> 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: { "<positionId>": 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 };
|