Required member profile: username + verified email per position, gated at proof of ownership

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>
This commit is contained in:
martbost
2026-09-16 16:56:35 -05:00
parent 8521f7a3f2
commit 7d07fc01f1
6 changed files with 397 additions and 4 deletions
+172
View File
@@ -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/<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 };