aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 09:17:48 -0700
committerAdam Soltys <[email protected]>2026-10-02 09:17:48 -0700
commit65aa6c93153b28554127627dde2883a0685eeaff (patch)
treed0e7f8a3d06e1ab89e97a34deec1ed7f8f457cbf /tools
parent686e2d391d3a963535d5deec7043999b9b92b19b (diff)
downloadcoinosv3-65aa6c93153b28554127627dde2883a0685eeaff.tar.gz
coinosv3-65aa6c93153b28554127627dde2883a0685eeaff.zip
Never write a follow list over one we failed to find
A kind 3 is replaceable: publishing one built on a list we didn't find wipes the real one wherever it lands. 2026-10-02, an Amethyst user with 398 follows (kept on her own relays and an archive) signed in to coinos for the first time; the starter-follows seed asked only our relays, saw nothing, and published Adam + coinos over her list. - deepContactList: our relays, the profile indexes, the archives, the person's own NIP-65 write relays, and Primal's signed copy (verified). - Starter follows only for a key with no follow list AND no profile anywhere ('new' meant new to coinos, not new to nostr). - A follow, follow-many or mute made while we have never seen the list does the deep lookup first and builds on what it finds. - Settings > Nostr > Follow list: find older copies on history-keeping relays and restore one as a union with the current list (nobody is removed), for anyone already hit. - tools/follows-overwrite-test.js: fake relay network, only an archive holds the list; fails on the old code with the exact two-person event. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo
Diffstat (limited to 'tools')
-rw-r--r--tools/follows-overwrite-test.js176
1 files changed, 176 insertions, 0 deletions
diff --git a/tools/follows-overwrite-test.js b/tools/follows-overwrite-test.js
new file mode 100644
index 0000000..8ed2a82
--- /dev/null
+++ b/tools/follows-overwrite-test.js
@@ -0,0 +1,176 @@
+// A follow list must never be written over one we failed to find.
+//
+// 2026-10-02: a long-time nostr user (398 follows, kept on her own relays
+// and an archive) signed in to coinos for the first time; the starter-follows
+// seed asked only our relays, found nothing, and published a two-person list
+// that replaced hers everywhere. These scenarios run the messages feature
+// against an in-page relay network where only an archive relay holds the
+// real list, and record every event the wallet publishes.
+// Run: bun tools/follows-overwrite-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { bytesToHex } from '@noble/hashes/utils';
+
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');"));
+const entry = `
+import { messagesFeature } from './src/features/messages.js';
+import { hexToBytes } from '@noble/hashes/utils';
+${dom}
+window.makeFeature = (skHex) => {
+ const store = {};
+ const ui = { screen: 'wallet' };
+ const wallet = { xpub: 'w-' + skHex.slice(0, 6), loaded: true, nostrRelays: () => [],
+ nostr: { sk: hexToBytes(skHex), pk: window.pkOf(skHex) },
+ loadFeatureState: (k, d) => (k in store ? store[k] : d), saveFeatureState: (k, v) => { store[k] = v; },
+ registerCacheExtension() {}, _cacheKey: () => 'k' };
+ const feature = messagesFeature({ h, ui, wallet, render() {}, toast: (m) => window.toasts.push(m), hook: () => null,
+ brandHeader: () => h('div', {}, '') });
+ return feature;
+};
+`;
+const bundle = await Bun.build({ entrypoints: ['ow-entry'], target: 'browser', plugins: [{ name: 'ow', setup(build) {
+ build.onResolve({ filter: /^ow-entry$/ }, () => ({ path: 'entry', namespace: 'ow' }));
+ build.onLoad({ filter: /.*/, namespace: 'ow' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()).replace("id: 'messages',",
+ "id: 'messages', testFollow: (pk) => toggleFollow(pk), testHistory: () => findFollowHistory(), testRestore: (e) => restoreFollows(e), testFollowing: () => [...followsNow().set],") }));
+} }] });
+const js = await bundle.outputs[0].text();
+
+// The relay network, in the page: per-URL stores, REQ answered from them,
+// every EVENT recorded (and stored, like a relay would).
+const fakeNet = `
+window.toasts = [];
+window.published = [];
+window.relayStore = {};
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id));
+class FakeWS {
+ constructor(url) {
+ this.url = String(url).replace(/\\/$/, ''); this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5);
+ }
+ send(raw) {
+ const m = JSON.parse(raw);
+ const reply = (x) => setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5);
+ if (m[0] === 'REQ') {
+ if (m[2] && m[2].cache) { reply(['EOSE', m[1]]); return; } // Primal's cache: knows nothing here
+ const evs = (window.relayStore[this.url] || []);
+ for (const f of m.slice(2)) for (const e of evs) if (match(f, e)) reply(['EVENT', m[1], e]);
+ reply(['EOSE', m[1]]);
+ } else if (m[0] === 'EVENT') {
+ window.published.push({ url: this.url, ev: m[1] });
+ (window.relayStore[this.url] ||= []).push(m[1]);
+ reply(['OK', m[1].id, true, '']);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); }
+ addEventListener(t, fn) { this['on' + t] = fn; }
+ removeEventListener() {}
+}
+FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3;
+window.WebSocket = FakeWS;
+`;
+const html = `<!doctype html><html><head><meta charset="utf-8"></head><body><div id="app"></div><script>${fakeNet}</script><script type="module">${js}</script></body></html>`;
+const server = Bun.serve({ port: 0, 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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const key = () => { const sk = generateSecretKey(); return { sk, hex: bytesToHex(sk), pk: getPublicKey(sk) }; };
+const someone = () => getPublicKey(generateSecretKey());
+const contactList = (k, pks, created_at, client) => finalizeEvent({ kind: 3, created_at, content: '', tags: [...pks.map((p) => ['p', p]), ...(client ? [['client', client]] : [])] }, k.sk);
+const STARTERS = ['98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', '72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb'];
+
+async function freshPage(store) {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => { errors.push(e.message); console.log('PAGEERR', e.message.slice(0, 300)); });
+ page.on('console', (m) => { if (m.type() === 'error') console.log('CONSOLE', m.text().slice(0, 300)); });
+ await page.goto(server.url.origin, { waitUntil: 'load' });
+ await page.waitForFunction(() => window.makeFeature, { timeout: 10000 });
+ await page.evaluate((s) => { window.relayStore = s; }, store);
+ return { page, errors };
+}
+const kind3s = (page) => page.evaluate(() => window.published.filter((p) => p.ev.kind === 3).map((p) => ({ url: p.url, tags: p.ev.tags })));
+
+try {
+ // 1. An existing nostr user, list only on an archive relay → no starter seed.
+ {
+ const annie = key();
+ const real = [someone(), someone(), someone()];
+ const { page, errors } = await freshPage({ 'wss://nostr21.com': [contactList(annie, real, 1780000000, 'Amethyst')] });
+ await page.evaluate((h) => { window.pkOf = () => null; }, annie.hex);
+ await page.evaluate((h, pk) => { window.pkOf = () => pk; window.f = window.makeFeature(h); window.f.identitySignedInNew(); }, annie.hex, annie.pk);
+ await sleep(12000);
+ const pubs = await kind3s(page);
+ assert.equal(pubs.length, 0, 'a key with a follow list anywhere gets no starter list: ' + JSON.stringify(pubs).slice(0, 200));
+ console.log('✓ signing in with a list kept only on an archive relay publishes no starter follows');
+
+ // ...and following someone builds on the archived list, not on nothing
+ const extra = someone();
+ await page.evaluate((pk) => window.f.testFollow(pk), extra);
+ await sleep(500);
+ const after = await kind3s(page);
+ assert(after.length > 0, 'the follow was published');
+ const pks = new Set(after[0].tags.filter((t) => t[0] === 'p').map((t) => t[1]));
+ for (const p of real) assert(pks.has(p), 'the archived follows are kept: missing ' + p.slice(0, 8));
+ assert(pks.has(extra) && pks.size === 4, 'plus the new one: ' + pks.size);
+ assert.deepEqual(errors, []);
+ console.log('✓ a follow made before the list was ever seen is added to the archived list (4 = 3 + 1)');
+ await page.close();
+ }
+
+ // 2. A key with a profile but no follow list → still no starter list.
+ {
+ const k = key();
+ const prof = finalizeEvent({ kind: 0, created_at: 1780000000, content: JSON.stringify({ name: 'old hand' }), tags: [] }, k.sk);
+ const { page } = await freshPage({ 'wss://purplepag.es': [prof] });
+ await page.evaluate((h, pk) => { window.pkOf = () => pk; window.f = window.makeFeature(h); window.f.identitySignedInNew(); }, k.hex, k.pk);
+ await sleep(12000);
+ assert.equal((await kind3s(page)).length, 0, 'a key with a profile anywhere gets no starter list');
+ console.log('✓ a key with a profile but no follow list gets no starter follows either');
+ await page.close();
+ }
+
+ // 3. A key with no trace anywhere → the starter follows, as before.
+ {
+ const k = key();
+ const { page } = await freshPage({});
+ await page.evaluate((h, pk) => { window.pkOf = () => pk; window.f = window.makeFeature(h); window.f.identitySignedInNew(); }, k.hex, k.pk);
+ await sleep(12000);
+ const pubs = await kind3s(page);
+ assert(pubs.length > 0, 'a brand-new key gets its starter list');
+ assert.deepEqual(pubs[0].tags.filter((t) => t[0] === 'p').map((t) => t[1]).sort(), [...STARTERS].sort());
+ console.log('✓ a brand-new key still gets the starter follows');
+ await page.close();
+ }
+
+ // 4. Restore: the current list is the two-person replacement, an older copy
+ // survives on an archive → restoring puts everyone back, nobody removed.
+ {
+ const k = key();
+ const real = Array.from({ length: 5 }, someone);
+ const kept = someone();
+ const { page, errors } = await freshPage({
+ 'wss://nostr21.com': [contactList(k, real, 1780000000, 'Amethyst')],
+ 'wss://relay.damus.io': [contactList(k, [...STARTERS, kept], 1790000000)],
+ });
+ await page.evaluate((h, pk) => { window.pkOf = () => pk; window.f = window.makeFeature(h); }, k.hex, k.pk);
+ const versions = await page.evaluate(async () => (await window.f.testHistory()).map((e) => ({ n: e.tags.filter((t) => t[0] === 'p').length, e })));
+ assert.equal(versions.length, 2, 'both copies found: ' + versions.map((v) => v.n));
+ const old = versions.find((v) => v.n === 5).e;
+ await page.evaluate((e) => window.f.testRestore(e), old);
+ await sleep(500);
+ const pubs = await kind3s(page);
+ const pks = new Set(pubs[pubs.length - 1].tags.filter((t) => t[0] === 'p').map((t) => t[1]));
+ for (const p of [...real, ...STARTERS, kept]) assert(pks.has(p), 'restored list keeps ' + p.slice(0, 8));
+ assert.equal(pks.size, 8);
+ assert(await page.evaluate(() => window.toasts.some((t) => /Restored 5/.test(t))), 'says how many came back');
+ assert.deepEqual(errors, []);
+ console.log('✓ restoring an archived copy merges it into the current list (5 back, 3 kept)');
+ await page.close();
+ }
+ console.log('\n✅ follow lists are never written over unseen');
+} finally {
+ await browser.close();
+ server.stop(true);
+}