// A community's admin taps a message and removes its author, with everything // they said: one owner-signed ban-list edition (CORD-04 vsk 4) that every // client folds. "can i have some control as a chat group admin to click on a // message and see an option to remove the user and all their messages from // the group?" (Adam, 2026-11-10). // // Everything runs against a relay inside this process: the page's sockets all // go there, so nothing reaches a live room. // // Run: bun tools/chat-remove-member-test.js import puppeteer from './puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; import { parseInviteLink, openInviteBundle, channelStream, channelEpoch, controlKey, wrapRumor, openWrap, msTags, foldControl, banlistHead, makeBanlist, } from '../src/concord.js'; import { hexToBytes } from '@noble/hashes/utils'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; // ---- a relay in a few lines: stores what it's given, serves it by filter ---- const events = new Map(); const socks = new Map(); // ws -> Map(subId -> filters) const matches = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until) && Object.entries(f).every(([k, v]) => k[0] !== '#' || e.tags.some((t) => t[0] === k.slice(1) && v.includes(t[1]))); const store = (e) => { if (events.has(e.id)) return; events.set(e.id, e); for (const [ws, subs] of socks) for (const [id, fs] of subs) if (fs.some((f) => matches(f, e))) ws.send(JSON.stringify(['EVENT', id, e])); }; const RELAY_PORT = 27811, RELAY = `ws://127.0.0.1:${RELAY_PORT}`; const relay = Bun.serve({ port: RELAY_PORT, hostname: '127.0.0.1', fetch(req, srv) { return srv.upgrade(req) ? undefined : new Response('relay'); }, websocket: { open(ws) { socks.set(ws, new Map()); }, close(ws) { socks.delete(ws); }, message(ws, raw) { let m; try { m = JSON.parse(raw); } catch { return; } if (m[0] === 'EVENT') { store(m[1]); ws.send(JSON.stringify(['OK', m[1].id, true, ''])); } else if (m[0] === 'REQ') { const fs = m.slice(2); socks.get(ws).set(m[1], fs); for (const f of fs) { const hit = [...events.values()].filter((e) => matches(f, e)).sort((a, b) => b.created_at - a.created_at); for (const e of f.limit ? hit.slice(0, f.limit) : hit) ws.send(JSON.stringify(['EVENT', m[1], e])); } ws.send(JSON.stringify(['EOSE', m[1]])); } else if (m[0] === 'CLOSE') socks.get(ws)?.delete(m[1]); else if (m[0] === 'COUNT') ws.send(JSON.stringify(['COUNT', m[1], { count: 0 }])); else if (/^NEG-/.test(m[0])) ws.send(JSON.stringify(['NEG-ERR', m[1], 'blocked: unsupported'])); }, }, }); const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.setViewport({ width: 1100, height: 850 }); page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; }); await page.evaluateOnNewDocument((relayUrl) => { const Real = window.WebSocket; window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relayUrl : url, protocols); }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); localStorage.setItem('btc-wallet-network', 'regtest'); // the invite link is "copied": keep it where the test can read it try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (v) => { window.__clip = v; }, readText: async () => window.__clip || '' }, configurable: true }); } catch {} }, RELAY); const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v); const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(300); } }; const bodyText = () => page.evaluate(() => document.body.innerText); const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms); const logText = () => page.evaluate(() => document.querySelector('.chat-log')?.innerText || ''); const say = async (text) => { await page.waitForSelector('#msg-draft', { timeout: 15000 }); await page.evaluate((v) => { const e = document.querySelector('#msg-draft'); const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e; ta.focus(); ta.value = v; ta.dispatchEvent(new Event('input', { bubbles: true, composed: true })); }, text); await sleep(150); await page.keyboard.press('Enter'); }; const tapBubble = (text) => page.evaluate((x) => { const b = [...document.querySelectorAll('.chat-log .chat-bubble')].find((n) => n.innerText.includes(x)); if (b) b.click(); return !!b; }, text); const sheet = () => page.evaluate(() => [...document.querySelectorAll('.msg-sheet .msg-sheet-item')].map((b) => b.innerText.trim())); try { await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' }); await sleep(400); await click('button', 'Create a new wallet'); await sleep(300); await click('button', 'Have an existing seed'); await sleep(300); await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('button', 'Open wallet'); await waitText('receive', 15000); await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); await sleep(800); await click('button', 'Create'); await sleep(300); await setInput('input[placeholder*="name" i]', 'removal test'); await sleep(200); await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); }); await waitText('removal test', 25000); await click('.item', 'removal test'); await page.waitForSelector('#msg-draft', { timeout: 15000 }); await say('the admin says hello'); check('the admin posts in their community', await until(async () => (await logText()).includes('the admin says hello'))); // the join material, the way anyone gets it: the invite link await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); await sleep(300); await click('button', 'Copy invite link'); const link = await until(() => page.evaluate(() => window.__clip || null), 10000); const parsed = parseInviteLink(link || ''); const bundleEv = parsed && [...events.values()].find((e) => e.kind === 33301 && e.pubkey === parsed.signerPk); const jm = bundleEv && openInviteBundle(bundleEv, parsed.token); check('invite link opens the community', !!(jm && jm.community_root), link ? link.slice(0, 50) + '…' : 'no link'); if (!jm) throw new Error('no join material'); await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); const ch = jm.channels[0]; const stream = channelStream(jm, ch); const post = async (sk, text) => { const { created_at, ms } = msTags(Date.now()); const rumor = { kind: 9, pubkey: getPublicKey(sk), content: text, tags: [['channel', ch.id], ['epoch', String(channelEpoch(jm, ch))], ms], created_at }; store(await wrapRumor(rumor, sk, stream)); }; const spammer = generateSecretKey(), friend = generateSecretKey(); await post(spammer, 'Can I have some free sats'); await sleep(30); await post(friend, 'good evening all'); await sleep(30); await post(spammer, 'Any donation can help'); check('members\' messages arrive', await until(async () => { const x = await logText(); return x.includes('free sats') && x.includes('good evening') && x.includes('donation'); })); console.log('\n[the sheet]'); await tapBubble('the admin says hello'); await sleep(300); let items = await sheet(); check('own message: no remove option', items.length > 0 && !items.some((x) => /remove/i.test(x)), items.join(' / ')); await page.keyboard.press('Escape'); await page.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click()); await sleep(300); await tapBubble('free sats'); await sleep(300); items = await sheet(); check('someone else\'s message: "Remove … and their messages"', items.some((x) => /Remove .+ and their messages$/.test(x)), items.join(' / ')); await click('.msg-sheet-item', 'and their messages'); await sleep(300); items = await sheet(); check('first tap only asks', items.some((x) => /tap again/i.test(x)) && (await logText()).includes('free sats'), items.join(' / ')); await click('.msg-sheet-item', 'tap again'); const gone = await until(async () => { const x = await logText(); return !x.includes('free sats') && !x.includes('donation'); }, 15000); check('second tap: every message of theirs is gone', !!gone); const after = await logText(); check('everyone else\'s stay', after.includes('good evening') && after.includes('the admin says hello')); console.log('\n[what other clients see]'); const control = controlKey(hexToBytes(jm.community_root), jm.community_id, jm.root_epoch || 0); const entries = () => [...events.values()].filter((e) => e.kind === 1059 && e.pubkey === control.pk).map((w) => openWrap(w, control)).filter(Boolean); let folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); check('the relay holds an owner-signed ban list naming them', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); // a member (not the owner) signing a higher version can't undo it const head = banlistHead(entries(), { ownerHex: jm.owner, cid: jm.community_id }); const forged = { ...makeBanlist({ ...head, version: head.version + 5 }, [], Date.now()), pubkey: getPublicKey(spammer) }; store(await wrapRumor(forged, spammer, control, { plaintext: true })); folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); check('a member\'s own "empty list" edition changes nothing', folded.banned.has(getPublicKey(spammer))); await post(spammer, 'me again, free sats?'); await post(friend, 'still here'); await until(async () => (await logText()).includes('still here')); check('their new messages never show', !(await logText()).includes('me again')); console.log('\n[after a reload, and a second removal]'); await page.reload({ waitUntil: 'domcontentloaded' }); await until(async () => (await logText()).includes('good evening'), 25000); await sleep(1500); check('still gone after a reload', !/free sats|donation|me again/.test(await logText()) && (await logText()).includes('good evening')); const second = generateSecretKey(); await post(second, 'buy my course'); await until(async () => (await logText()).includes('buy my course')); await tapBubble('buy my course'); await sleep(300); await click('.msg-sheet-item', 'and their messages'); await sleep(300); await click('.msg-sheet-item', 'tap again'); await until(async () => !(await logText()).includes('buy my course'), 15000); folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); check('the second removal keeps the first', folded.banned.has(getPublicKey(spammer)) && folded.banned.has(getPublicKey(second)) && folded.banned.size === 2, `${folded.banned.size} on the list`); console.log('\n[putting someone back]'); await page.evaluate(() => document.querySelector('.chat-head .col.clickable')?.click()); await sleep(500); const hasList = await until(async () => (await bodyText()).includes('Removed from this community'), 5000); check('the member list shows who was removed (to the admin)', !!hasList); await page.evaluate(() => { const rows = [...document.querySelectorAll('.chat-members .item')].filter((r) => [...r.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Restore')); rows.at(-1)?.querySelector('button')?.click(); }); const back = await until(async () => (await logText()).includes('buy my course'), 15000); check('Restore brings their messages back', !!back); folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); check('and the list keeps the other', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); } catch (e) { console.log(' error:', e.message); ok = false; } finally { await browser.close(); server.stop(true); relay.stop(true); } console.log(ok ? '\nPASS' : '\nFAIL'); process.exit(ok ? 0 : 1);