2f4b70c886
Three things, one of which we were quietly getting wrong. REAL OPT-OUT. We told members "removable any time" in three separate places and there was no way to remove anything. Same class of failure as the dead "Add mine" button: copy written, mechanism never built. The profile card now offers Remove username, Remove email and Remove everything, and profiles.remove() clears the value while never touching the position. Adding it again later works exactly as before, so opting out is not a one-way door. It is a two-step inline confirm, not a native confirm() dialog. Browsers with "suppress dialogs" switched on return false, which would have made Remove look broken in precisely the way Add mine was broken. First tap arms and explains the consequence, second tap does it, and it disarms itself after six seconds. THE PROMISE WE WERE BREAKING. The payout mailer and the upgrade alerts read member-alerts.json, NOT profiles.json. So a member who completed the new profile got NOTHING, while the invitation card promised "a note the moment POL lands in your wallet". Verifying a profile email now mirrors into member-alerts.json so every existing alert path works, including the unsubscribe link, and removing the email clears both stores so opting out actually stops the email. MANSON'S HUGE ASTERISK. He asked for it to be bigger and bolder so nobody can say they did not see it, and on a decentralized build that burden is ours, not the member's. One gold badge now appears on the dashboard invitation, inside the dialog on every step, in the inbox banner and on the profile card itself: "100% OPTIONAL - never required", with the plain statement that the position, the payouts, the team and everything on the page work exactly the same without it, nothing on chain depends on it, and it can be removed again any time. gate-e2e is 52 assertions, up from 38. The new ones prove one tap does NOT remove anything, the second tap does, the server agrees the value is gone, an email-only removal leaves the username alone, and the invitation reappears afterwards so the whole thing is reversible. profiles-unit 28, signin-fallback 7, captions-e2e 158 all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
11 KiB
JavaScript
212 lines
11 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) };
|
|
}
|
|
|
|
// ---- 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 };
|