aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-23 22:39:04 -0700
committerAdam Soltys <[email protected]>2026-10-23 22:39:04 -0700
commitce96364662451556e9ae6fe8222eba4ea23bd27c (patch)
treeccd16d1487581a405bc24f87352bbacbc959b69d /tools
parentb67ad2026286f6383f24b1aa9694bbeee571276b (diff)
downloadcoinosv3-ce96364662451556e9ae6fe8222eba4ea23bd27c.tar.gz
coinosv3-ce96364662451556e9ae6fe8222eba4ea23bd27c.zip
Amber sign-in on Android works past the first request, and a migrated profile no longer keeps its released placeholder addressHEADmaster
Jarryd (DM, 2026-10-24): on Android he could not sign in with Amber and had to paste his nsec, and his profile page showed the temporary name he had picked while the editor showed his real one. Amber: Chrome lets a page open another app only on the heels of a tap. The Sign-in tap carried the get_public_key request, but the request the login fires once the key is back (decrypt the backup, sign the relay list…) had no tap behind it, so Chrome dropped the nostrsigner: navigation with a console warning the page never sees ("Navigation is blocked") and the card spun forever. Reproduced on Adam's Pixel 9 with Amber 6.6.9 (Amber 4.1.3 did not even parse the web request). Now a request without a user activation behind it waits for one: the card says the signer has another request and offers an "Open signer app" button that launches it from the tap; a launch that leaves the page in front (never hidden) is treated as blocked too and gets the same button. The clipboard is still read on return (that part worked). Profile: the migration released the placeholder name and repointed lud16/nip05 to the new one, while the legacy-avatar import ran at the same moment, read the same old kind 0, and republished it nine seconds later with the placeholder address back ([email protected], dead on both coinos.io and v3). Kind-0 publishes now go one at a time, merge over the newest copy this session sent, and are stamped strictly later than it (relays keep the lower id of two with the same second). The profile page shows the claimed address instead of a same-domain name that is no longer ours, and once the domain confirms the old name is dead it repoints the kind 0 in the background (healStaleAddress), so zaps to the published lightning address reach the wallet again. tools/profile-publish-race-test.js plays the migration's two publishes at once against an in-memory relay. tools/amber-login-test.js and identity-test pass; name-migration-test and avatar-profile-test fail the same way on the previous commit. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/profile-publish-race-test.js95
1 files changed, 95 insertions, 0 deletions
diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js
new file mode 100644
index 0000000..390c01b
--- /dev/null
+++ b/tools/profile-publish-race-test.js
@@ -0,0 +1,95 @@
+// Two kind-0 publishes at once used to clobber each other: both read the
+// same base off the relays, the later one landed without the earlier one's
+// fields. The migration did exactly that — the rename repointed lud16/nip05
+// to the new name while the legacy-avatar import republished the old base
+// nine seconds later, so a user's published lightning address was the
+// placeholder name their rename had just released (Jarryd, 2026-10-24).
+// Now kind-0 publishes go one at a time and merge over what was last sent.
+// Run: bun tools/profile-publish-race-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, 160) : ''}`);
+ 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; },
+};
+
+// One in-memory relay behind every socket: EVENT stores, REQ answers with
+// what matches (newest first) and EOSE. Enough for a kind-0 round trip.
+const store = [];
+const published = [];
+class FakeSocket {
+ constructor(url) {
+ this.url = 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') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ const hit = store.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; } };
+// the legacy account the migration brings along
+globalThis.fetch = async (url) => {
+ if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) };
+ throw new Error('offline');
+};
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const sk = generateSecretKey();
+const pk = getPublicKey(sk);
+const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+let now = Math.floor(Date.now() / 1000) - 100;
+const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk);
+store.push(seed);
+
+const walletSk = generateSecretKey();
+const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+};
+const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+});
+
+console.log('[the migration: rename and avatar import fired together]');
+await Promise.all([
+ feature.addressRenamed('[email protected]', '[email protected]'),
+ feature.adoptLegacyProfile('jarryd'),
+]);
+const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const final = JSON.parse(newest.content);
+const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()];
+check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → '));
+check('the newest kind 0 carries the new address', final.lud16 === '[email protected]' && final.nip05 === '[email protected]', JSON.stringify(final));
+check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner);
+check('...and the name it had', final.name === 'Jarryd');
+
+console.log('[a second rename right behind, before the relays answer]');
+published.length = 0;
+await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), feature.publishProfile({ about: 'hi' })]);
+const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content);
+check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last));
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns');
+process.exit(fails ? 1 : 0);