diff --git a/chain.js b/chain.js index b9e0f23..e973757 100644 --- a/chain.js +++ b/chain.js @@ -667,6 +667,15 @@ function getOrgShare(rootId) { }; } +// Wallet address -> position id (for wallet-verified messaging sign-in). +// One position per address by contract design. +function memberIdByAccount(address) { + if (!state || !address) return null; + const a = String(address).toLowerCase(); + for (const [id, m] of Object.entries(state.members)) if (m.account === a) return Number(id); + return null; +} + // Company-rotation pick: breadth-first (matrix order, left→right) first // position under `rootId` that still needs directs — the "next open team // position" for the public rotation when publicRotationMode==='chain'. @@ -735,4 +744,4 @@ async function getIncome(id) { }; } -module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getCoachingScan, getMatrixTree, isInTeam, nextOpenPosition, balanceOf, CONTRACT }; +module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getCoachingScan, getMatrixTree, isInTeam, nextOpenPosition, memberIdByAccount, balanceOf, CONTRACT }; diff --git a/messages.js b/messages.js new file mode 100644 index 0000000..8fc236c --- /dev/null +++ b/messages.js @@ -0,0 +1,161 @@ +// RM Circle — wallet-verified member messaging. +// Identity = the wallet that owns a position (proved by a free personal_sign, +// verified server-side via vendored keccak256 + secp256k1 recovery — the two +// files in ./vendor are pinned; see the commit that added them). +// Permission = the matrix: you may message your own downline (direct or +// broadcast) and your own upline chain. Nothing else. Admin can review all +// messages (disclosed in the UI). +'use strict'; +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const secp = require('./vendor/secp256k1.js'); +const sha3 = require('./vendor/sha3.js'); +const keccak256 = sha3.keccak256; + +secp.utils.hmacSha256Sync = (key, ...msgs) => { + const h = crypto.createHmac('sha256', Buffer.from(key)); + msgs.forEach(m => h.update(Buffer.from(m))); + return Uint8Array.from(h.digest()); +}; + +const SESSION_TTL = 30 * 24 * 3600 * 1000; +const CHALLENGE_TTL = 10 * 60 * 1000; +const MAX_BODY = 1500; +const MAX_STORE = 5000; +const DAILY_DIRECT = 30, DAILY_BROADCAST = 3; + +let DATA_DIR = null, chain = null, IS_PROD = false; +let sessions = new Map(); // token -> {address, id, expires} +const challenges = new Map(); // addressLower -> {message, exp} + +const MSG_FILE = () => path.join(DATA_DIR, 'messages.json'); +const SESS_FILE = () => path.join(DATA_DIR, 'msg-sessions.json'); +function readJson(f, fb) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return fb; } } +function writeJson(f, v) { fs.writeFileSync(f, JSON.stringify(v)); } +function loadSessions() { const o = readJson(SESS_FILE(), {}); sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); } +function saveSessions() { writeJson(SESS_FILE(), Object.fromEntries(sessions)); } +function getMessages() { return readJson(MSG_FILE(), []); } +function saveMessages(m) { writeJson(MSG_FILE(), m.slice(-MAX_STORE)); } + +function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; loadSessions(); } + +// ---- crypto ---- +function personalDigest(msg) { + const m = Buffer.from(msg, 'utf8'); + const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); + return Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); +} +function recoverAddress(msg, signature) { + const raw = Buffer.from(String(signature).replace(/^0x/, ''), 'hex'); + if (raw.length !== 65) throw new Error('Bad signature length'); + let v = raw[64]; if (v >= 27) v -= 27; + if (v !== 0 && v !== 1) throw new Error('Bad signature recovery byte'); + const pub = secp.recoverPublicKey(personalDigest(msg), raw.slice(0, 64), v, false); + return '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40); +} + +// ---- auth ---- +const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; +function makeChallenge(address) { + const a = address.toLowerCase(); + const nonce = crypto.randomBytes(16).toString('hex'); + const message = `RM Circle member sign-in\n\nWallet: ${a}\nNonce: ${nonce}\n\nSigning is free, proves you own this wallet to rmcircle.team, and cannot move funds or approve anything.`; + challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL }); + return message; +} +function verifyChallenge(address, signature) { + const a = address.toLowerCase(); + const ch = challenges.get(a); + if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' }; + let rec; + try { rec = recoverAddress(ch.message, signature); } catch (e) { return { error: 'Invalid signature: ' + e.message }; } + if (rec !== a) return { error: 'Signature does not match this wallet.' }; + challenges.delete(a); + const id = chain.memberIdByAccount(a); + if (!id) return { error: 'No RM Circle position is registered to this wallet.' }; + const token = crypto.randomBytes(32).toString('hex'); + sessions.set(token, { address: a, id, expires: Date.now() + SESSION_TTL }); + saveSessions(); + return { token, id }; +} +function authFromCookie(req) { + const m = /(?:^|;\s*)ctb\.msid=([^;]+)/.exec(req.headers.cookie || ''); + if (!m) return null; + const s = sessions.get(decodeURIComponent(m[1])); + if (!s || s.expires < Date.now()) return null; + return s; +} +function sessionCookie(token) { + return `ctb.msid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; +} + +// ---- permissions ---- +function canMessage(fromId, toId) { + if (fromId === toId) return false; + return chain.isInTeam(toId, fromId) || chain.isInTeam(fromId, toId); +} +function isRecipient(msg, myId) { + if (msg.toId === myId) return true; + if (msg.org && msg.fromId !== myId && chain.isInTeam(myId, msg.fromId)) return true; + return false; +} + +// ---- operations ---- +function sanitizeBody(b) { + return String(b || '').replace(/\r/g, '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(0, MAX_BODY); +} +function send(sess, body) { + const text = sanitizeBody(body.body); + if (!text) return { error: 'Write a message first.' }; + const org = !!body.org; + const msgs = getMessages(); + const dayAgo = Date.now() - 24 * 3600 * 1000; + const mine = msgs.filter(m => m.fromId === sess.id && m.ts > dayAgo); + if (org && mine.filter(m => m.org).length >= DAILY_BROADCAST) return { error: `Broadcast limit reached (${DAILY_BROADCAST}/day).` }; + if (!org && mine.filter(m => !m.org).length >= DAILY_DIRECT) return { error: `Daily message limit reached (${DAILY_DIRECT}/day).` }; + let toId = 0; + if (!org) { + toId = Number(body.toId); + if (!Number.isInteger(toId) || toId < 1) return { error: 'Enter the member ID to send to.' }; + if (!canMessage(sess.id, toId)) return { error: `#${toId} is not in your team or upline - messaging follows your matrix lines only.` }; + } + const rec = { mid: crypto.randomBytes(8).toString('hex'), fromId: sess.id, toId, org, body: text, ts: Date.now(), read: {} }; + msgs.push(rec); + saveMessages(msgs); + return { ok: true, mid: rec.mid }; +} +function inbox(sess) { + const msgs = getMessages(); + const forMe = [], sent = []; + for (const m of msgs) { + if (m.fromId === sess.id) sent.push(m); + else if (isRecipient(m, sess.id)) forMe.push(m); + } + const shape = (m, mine) => ({ mid: m.mid, fromId: m.fromId, toId: m.toId || null, org: !!m.org, body: m.body, ts: m.ts, read: mine ? undefined : !!(m.read && m.read[sess.id]), mine }); + return { + id: sess.id, + inbox: forMe.slice(-100).reverse().map(m => shape(m, false)), + sent: sent.slice(-30).reverse().map(m => shape(m, true)) + }; +} +function markRead(sess, mids) { + const msgs = getMessages(); + let changed = false; + for (const m of msgs) { + if (mids.includes(m.mid) && isRecipient(m, sess.id) && !(m.read && m.read[sess.id])) { + m.read = m.read || {}; m.read[sess.id] = Date.now(); changed = true; + } + } + if (changed) saveMessages(msgs); + return { ok: true }; +} +function unreadCount(id) { + if (!Number.isInteger(id) || id < 1) return 0; + return getMessages().filter(m => isRecipient(m, id) && !(m.read && m.read[id])).length; +} +function adminList() { + return getMessages().slice(-300).reverse().map(m => ({ mid: m.mid, fromId: m.fromId, toId: m.toId || null, org: !!m.org, body: m.body, ts: m.ts, readCount: Object.keys(m.read || {}).length })); +} + +module.exports = { init, makeChallenge, verifyChallenge, authFromCookie, sessionCookie, send, inbox, markRead, unreadCount, adminList, ADDR_RE }; diff --git a/public/admin.html b/public/admin.html index bb44b23..7f3e99d 100644 --- a/public/admin.html +++ b/public/admin.html @@ -6,6 +6,7 @@
New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.
| When | Name / Handle | New ID | Joined under | Source | On-chain |
|---|
How your team — rooted at your top ID — stacks up against the entire RM Circle smart contract. Live on-chain.
Live triage of your whole org: who to nudge, what to tell them, and how much POL is on the line. Computed fresh from the chain index on every refresh.
Wallet-verified member-to-member messages, newest first. Admin can review for abuse — members are told this in the UI.
| When | From | To | Message | Read by |
|---|---|---|---|---|
| Loading… | ||||
Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.
Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.
The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.
Your position's matrix — the rollup line on each card counts everyone underneath, all the way down. Click a position to drill into that leg. Open slots are where the next placements land.
✓ qualified (2/2 directs) · P Premium · S Standard · ⬇ everyone below that position (all generations) and the POL they've earned · ↧ spillover = placed there by upline activity; only members who join with a position's own ID count toward its 2/2
Money forming below you. Each generation in your leg pays your position at exactly one level — when a member's level catches up to their depth, their next upgrade comes to you.
Wallet-verified messaging along your matrix lines — your upline and your team can reach you here, and you can reach them. No email needed; your wallet is your identity. Messages are member-to-member; the team admin can review them for abuse.
Get an email the moment this position is paid, and when it needs an upgrade to catch incoming pay — so you never miss one. Opt in with your email; unsubscribe anytime.
Welcome to the team! Enter the new member ID the RM Circle dApp gave you — we'll verify it on the blockchain and let the team know you're in.