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>
This commit is contained in:
martbost
2026-08-19 13:31:31 -05:00
parent e31b01e338
commit 9a1833f2b0
10 changed files with 2188 additions and 4 deletions
+64
View File
@@ -125,6 +125,7 @@
renderGens(d);
renderPipeline(d);
renderCoach(d);
renderMessages(d);
renderAlerts(d);
renderShare(d);
document.getElementById('dLineage').innerHTML=d.uplineChain&&d.uplineChain.length
@@ -158,6 +159,69 @@
}).join('');
el.innerHTML=`<div style="border-top:1px solid var(--line);padding-top:12px"><small style="text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-size:11px">Team depth — members per generation</small><div style="margin-top:8px">${rows}</div><p class="micro" style="margin:8px 0 0">Full generations duplicate: each one can hold twice the last. A generation pays this position at exactly one level — stay qualified and at that level to catch it.</p></div>`;
}
// Wallet-verified messaging: sign-in = one free personal_sign; identity is
// the wallet that owns a position; permissions follow the matrix lines.
function renderMessages(d){
const el=document.getElementById('dMsg'),bell=document.getElementById('msgBell');
if(!el)return;
fetch('/api/public/msg-unread?id='+d.id).then(r=>r.json()).then(u=>{
if(bell&&u&&u.count>0){bell.textContent='🔔 '+u.count+' new';bell.classList.remove('hidden');}
}).catch(()=>{});
loadMsgUI(d);
}
async function loadMsgUI(d){
const el=document.getElementById('dMsg');
let me=null;
try{const r=await fetch('/api/public/msg-me');if(r.ok)me=await r.json();}catch(e){}
if(!me){
el.innerHTML='<p class="micro" style="margin:0 0 10px">Sign in once with the wallet that owns your position — one free signature; it can\'t move funds or approve anything.</p><button id="msgAuthBtn" class="btn btn-primary">🔐 Connect wallet &amp; sign in</button><div id="msgAuthErr" class="micro" style="color:var(--danger);margin-top:8px"></div>';
const b=document.getElementById('msgAuthBtn');if(b)b.addEventListener('click',function(){msgAuth(d);});
return;
}
let data;
try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='<div class="empty">Could not load messages — refresh to retry.</div>';return;}
const mine=Number(me.id)===Number(d.id);
const banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
const rows=(data.inbox||[]).map(m=>`<div class="pp-row" style="padding:9px 12px${m.read?'':';border-color:rgba(240,197,109,.55)'}"><div class="pp-icon">${m.org?'📣':'✉️'}</div><div class="pp-body"><strong>From #${m.fromId}</strong> <span class="pp-meta" style="display:inline">· ${new Date(m.ts).toLocaleString()}${m.org?' · team broadcast':''}${m.read?'':' · <strong style="color:var(--gold)">NEW</strong>'}</span><div style="white-space:pre-wrap;margin-top:4px">${esc(m.body)}</div></div></div>`).join('')||'<div class="empty">No messages yet.</div>';
const sent=(data.sent||[]).slice(0,3).map(m=>`<div class="micro" style="margin:3px 0">→ ${m.org?'whole team':'#'+m.toId} · ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'…':''}</div>`).join('');
el.innerHTML=banner+rows+
(sent?`<div style="margin-top:10px"><span class="micro" style="text-transform:uppercase;letter-spacing:.08em">Recently sent</span>${sent}</div>`:'')+
`<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div style="font-weight:800;margin-bottom:6px">Send a message</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
<input id="msgTo" inputmode="numeric" placeholder="Member #" value="${mine?'':esc(String(d.id))}" style="max-width:110px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px">
<label class="micro" style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" id="msgOrg"> send to my whole team instead</label>
</div>
<textarea id="msgBody" maxlength="1500" rows="3" placeholder="Plain text, up to 1500 characters. You can message your team and your upline." style="width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px"></textarea>
<div style="display:flex;gap:10px;align-items:center;margin-top:8px"><button id="msgSendBtn" class="btn btn-primary">Send →</button><span id="msgStatus" class="micro"></span></div></div>`;
const unreadIds=(data.inbox||[]).filter(m=>!m.read).map(m=>m.mid);
if(unreadIds.length)fetch('/api/public/msg-read',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mids:unreadIds})}).catch(()=>{});
const sb=document.getElementById('msgSendBtn');
if(sb)sb.addEventListener('click',async function(){
const st=document.getElementById('msgStatus');st.textContent='Sending…';sb.disabled=true;
try{
const payload={org:document.getElementById('msgOrg').checked,toId:(document.getElementById('msgTo').value||'').trim(),body:document.getElementById('msgBody').value};
const r=await(await fetch('/api/public/msg-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})).json();
if(r.error){st.textContent=r.error;sb.disabled=false;return;}
st.textContent='Sent ✓';setTimeout(function(){loadMsgUI(d);},700);
}catch(e){st.textContent='Send failed — try again.';sb.disabled=false;}
});
}
async function msgAuth(d){
const err=document.getElementById('msgAuthErr');
try{
const eth=await window.RMCWallet.pick();
if(!eth){err.textContent='No wallet found in this browser. On a phone, open this page inside your wallet app\'s browser (MetaMask or Trust).';return;}
const accs=await eth.request({method:'eth_requestAccounts'});const account=accs[0];
const ch=await(await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json();
if(!ch.message)throw new Error(ch.error||'Could not start sign-in.');
let hex='0x';for(const b of new TextEncoder().encode(ch.message))hex+=b.toString(16).padStart(2,'0');
const sig=await eth.request({method:'personal_sign',params:[hex,account]});
const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
if(!v.ok)throw new Error(v.error||'Verification failed.');
loadMsgUI(d);
}catch(e){if(err)err.textContent=e.message||String(e);}
}
// "Coach your team" — the same triage the team admin runs, scoped to THIS
// position's leg: who below could use a nudge, and exactly what to tell them.
function renderCoach(d){