9a1833f2b0
- 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>
162 lines
7.2 KiB
JavaScript
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 };
|