Files
rm-circle-team-router/messages.js
T
martbost 9a1833f2b0 Wallet-verified member messaging (matrix-lines permissions, admin visibility)
- messages.js: challenge/personal_sign/recover auth (vendored pinned
  js-sha3 0.9.3 + noble-secp256k1 1.7.1, server-side only; self-tested
  positive + tamper cases), 30d HttpOnly sessions, message store on the
  volume, matrix-line permissions (your downline direct or broadcast, your
  upline chain - nothing else, so spam is impossible by construction),
  daily rate limits (30 direct / 3 broadcasts), 1500-char plain text
- chain.js: memberIdByAccount (wallet -> position for sign-in)
- API: msg-challenge/-verify/-me/-inbox/-send/-read public + msg-unread
  (count only, no auth) + admin/messages (full visibility, disclosed to
  members in the UI)
- Dashboard: Messages card with unread bell, one-tap wallet sign-in,
  inbox with auto-read, compose with to-ID or whole-team broadcast
- Admin: Member Messages review table
- Chatbot canned answer + AI system prompt updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:31:31 -05:00

162 lines
7.2 KiB
JavaScript

// 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 };