From 83abfea172d09d6e730859f3028f3cb6870136c5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 10 Nov 2026 14:13:23 -0700 Subject: A community's admin taps a message and removes its author, with everything they said. "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). The message sheet gains "Remove and their messages" for the owner on anyone else's message; a first tap asks ("for everyone? Tap again"), the second does it. It is the owner's ban list (CORD-04 vsk 4): one owner-signed edition naming every removed key, which the app, the public /chat page and other Concord clients already fold: those authors' messages, old and new, drop out and they leave the member list. The list is replaced whole, so setBanned asks the relays for the control stream first and builds on the newest edition (banlistHead/makeBanlist chain version and hash), stopping if the relays hand back nothing. The member list shows the admin who was removed, each with Restore. Offered only where the app holds the control stream's signing key (the built-in community and ones founded here). Removed people no longer light a room's unread dot. And the control fold now lets only the owner's editions compete for a head: any member holds the control stream's key in a public community, and a higher-versioned edition of their own used to shadow the owner's ban list, name or channels (dropped, leaving nothing). tools/chat-remove-member-test.js runs it against a relay inside the test Co-Authored-By: Claude Fable 5.2 --- tools/chat-remove-member-test.js | 224 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tools/chat-remove-member-test.js (limited to 'tools') diff --git a/tools/chat-remove-member-test.js b/tools/chat-remove-member-test.js new file mode 100644 index 0000000..1007973 --- /dev/null +++ b/tools/chat-remove-member-test.js @@ -0,0 +1,224 @@ +// 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); -- cgit v1.2.3