aboutsummaryrefslogtreecommitdiffstats
path: root/tools/kind0-overwrite-test.js
blob: e791560ad5fa1bc2d7b090962cd42f1eaa17a8d8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// A profile (kind 0) must never be written over one we failed to find.
//
// 2026-10-30: a long-time nostr user signed in to coinos with his key for
// the first time. His kind 0 lived on relays that keep history (ditto,
// snort, oxtr) and nowhere our quick lookup asked, so the payment-address
// publish merged over an empty base and went out as {lud16, nip05} alone:
// name, picture and banner gone from every relay. These scenarios run the
// messages feature against a fake relay network where only an archive holds
// the real profile, and record every event the wallet publishes.
// Run: bun tools/kind0-overwrite-test.js
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';

let fails = 0;
const check = (name, ok, detail = '') => {
  console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
  if (!ok) fails++;
};

globalThis.localStorage = {
  _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
  removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
  get length() { return Object.keys(this._d).length; },
};

// per-relay stores: REQ answered from the socket's own relay, EVENT stored there
const relayStore = {};
const published = []; // { url, ev }
const reqs = []; // { url, filter }
const norm = (u) => String(u).replace(/\/$/, '');
class FakeSocket {
  constructor(url) {
    this.url = norm(url); this.readyState = 0;
    setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
  }
  reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
  send(str) {
    let m; try { m = JSON.parse(str); } catch { return; }
    if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); }
    if (m[0] === 'REQ') {
      const [, sub, ...filters] = m;
      for (const f of filters) reqs.push({ url: this.url, filter: f });
      if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here
      const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
      hit.sort((a, b) => b.created_at - a.created_at);
      for (const e of hit) this.reply(['EVENT', sub, e]);
      this.reply(['EOSE', sub]);
    }
  }
  close() { this.readyState = 3; this.onclose && this.onclose({}); }
}
globalThis.WebSocket = FakeSocket;
globalThis.Image = class { set src(v) { this._src = v; } };
globalThis.fetch = async () => { throw new Error('offline'); };

const { messagesFeature } = await import('../src/features/messages.js');

const ARCHIVE = 'wss://relay.ditto.pub';
const makeFeature = (sk, ui = {}) => {
  const pk = getPublicKey(sk);
  const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
  const walletSk = generateSecretKey();
  const wallet = {
    mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [],
    nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
    loadFeatureState: () => null, saveFeatureState: () => {},
    _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
  };
  const toasts = [];
  const feature = messagesFeature({
    h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null,
    hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
  });
  return { feature, pk, toasts };
};
const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
const kind0s = () => published.filter((p) => p.ev.kind === 0);

console.log('[a first sign-in: the real profile lives only on an archive relay]');
{
  const sk = generateSecretKey();
  const pk = getPublicKey(sk);
  const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
  relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
  const { feature } = makeFeature(sk);
  // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint)
  await feature.addressRenamed(null, '[email protected]');
  const sent = [...new Set(kind0s().map((p) => p.ev.id))];
  check('one kind 0 went out', sent.length === 1, sent.length);
  const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {};
  check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c));
  check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c));
  check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)));

  // the onboarding wizard's suggestion must not rename him either
  published.length = 0;
  await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] });
  check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length);
}

console.log('[a key with no profile anywhere: one deep look, then no waiting]');
{
  const sk = generateSecretKey();
  const pk = getPublicKey(sk);
  const { feature } = makeFeature(sk);
  published.length = 0; reqs.length = 0;
  const t0 = Date.now();
  await feature.publishProfile({ name: 'newbie' });
  const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
  await feature.publishProfile({ about: 'hello' });
  const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
  const last = JSON.parse(kind0s().pop().ev.content);
  check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last));
  check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`);
}

console.log('[already hit: the editor finds the older copy and fills what is empty]');
{
  const sk = generateSecretKey();
  const pk = getPublicKey(sk);
  const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
  relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
  // what the wipe left: the address and a picture he re-uploaded by hand
  const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' };
  for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk));
  const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } };
  const { feature, toasts } = makeFeature(sk, ui);
  const versions = await feature.profileHistory();
  check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' '));
  const older = versions.find((e) => /Nik/.test(e.content));
  const n = feature.fillProfileFrom(older);
  check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit));
  check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | '));
  published.length = 0;
  await feature.saveProfileDraft(ui.profEdit);
  const saved = JSON.parse(kind0s().pop().ev.content);
  check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved));
  check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187);
}

console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen');
process.exit(fails ? 1 : 0);