aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 22:28:53 -0700
committerAdam Soltys <[email protected]>2026-08-03 22:28:53 -0700
commit75ec27747ec05da8990208d67052ad11d25f3c44 (patch)
tree0d404cd0e8c9ecce57ba3c55359e17efa0d4b799 /tools
parent1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3 (diff)
downloadcoinosv3-75ec27747ec05da8990208d67052ad11d25f3c44.tar.gz
coinosv3-75ec27747ec05da8990208d67052ad11d25f3c44.zip
Speak as the account you logged in with, or not at all
Logging in with a remote signer and reloading left the app showing your avatar and profile while signing everything with the wallet's own key — so chat messages arrived under a different npub with a stranger's punk on them. identity() had a fallback: when the login signer wasn't available it quietly used the wallet key instead. That fallback is gone. If you logged in as a nostr account, the app speaks as that account or it refuses and says so. Refusing is only reasonable if the signer usually comes back, so a login now persists what it takes to be that identity again. A remote signer stores its NIP-46 client key and pointer — the signer authorised that client key, not one page load, so rebuilding from it resumes the session without prompting. A pasted key stores the key, which is already the seed of everything here since the wallet's mnemonic derives from it. Resume is single-flight with a pause after failure, because identity() is asked on every send and a signer that isn't answering must not mean a relay connection per keystroke. Existing remote-signer logins predate the stored session, so they need one more sign-in to become resumable. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'tools')
-rw-r--r--tools/identity-test.js89
1 files changed, 89 insertions, 0 deletions
diff --git a/tools/identity-test.js b/tools/identity-test.js
new file mode 100644
index 0000000..179d640
--- /dev/null
+++ b/tools/identity-test.js
@@ -0,0 +1,89 @@
+// Who the chat speaks as. If you logged in with a nostr account, everything
+// this app signs must be signed by THAT account — the alternative is what
+// people actually saw: their own avatar in the header, and their messages
+// arriving under the wallet key's pubkey, looking like a stranger.
+// Run: bun tools/identity-test.js
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { messagesFeature } from '../src/features/messages.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 120) : ''}`);
+ 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; },
+};
+globalThis.fetch = async () => { throw new Error('offline'); };
+
+const walletSk = generateSecretKey();
+const walletPk = getPublicKey(walletSk);
+const loginSk = generateSecretKey();
+const loginPk = getPublicKey(loginSk);
+
+// Build the feature with a given login situation and see which key it would
+// sign a profile with — publishProfile is the shortest path through identity().
+function build({ login, resume }) {
+ const store = {};
+ const signed = [];
+ const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: walletPk, ck: new Uint8Array(32) },
+ loadFeatureState: (n, fb) => JSON.parse(store[n] || 'null') || fb,
+ saveFeatureState: (n, s) => { store[n] = JSON.stringify(s); },
+ _cacheKey: () => 'identity-test', saveCache: () => {},
+ registerCacheExtension: () => {}, registerLoadHook: () => {},
+ };
+ const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }),
+ ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
+ hook: (name) => {
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'nostrLoginResume') return resume;
+ return null;
+ },
+ });
+ return { feature, signed };
+}
+
+console.log('[logged in with nostr, signer live]');
+{
+ const signer = { pubkey: loginPk, signEvent: async (e) => ({ ...e, pubkey: loginPk, id: 'x', sig: 'y' }) };
+ const { feature } = build({ login: { pubkey: loginPk, signer }, resume: null });
+ let signedBy = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { signedBy = e.message; }
+ // it fails at the relay (offline), but only AFTER choosing an identity —
+ // what matters is that it never refused for want of one
+ check('does not refuse for want of an identity', !/signer isn't connected/i.test(signedBy || ''), signedBy || 'reached the relay');
+}
+
+console.log('\n[logged in with nostr, signer lost to a reload]');
+{
+ const { feature } = build({ login: { pubkey: loginPk, signer: null }, resume: null });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('refuses rather than signing as someone else', /signer isn't connected/i.test(err || ''), err || 'no error!');
+}
+
+console.log('\n[logged in with nostr, signer resumes]');
+{
+ const signer = { pubkey: loginPk, signEvent: async (e) => ({ ...e, pubkey: loginPk, id: 'x', sig: 'y' }) };
+ const { feature } = build({ login: { pubkey: loginPk, signer: null }, resume: Promise.resolve(signer) });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('a resumed signer is accepted', !/signer isn't connected/i.test(err || ''), err || 'reached the relay');
+}
+
+console.log('\n[no nostr login at all]');
+{
+ const { feature } = build({ login: null, resume: null });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('the wallet key still speaks for a plain seed wallet', !/signer isn't connected/i.test(err || ''), err || 'reached the relay');
+}
+
+console.log(fails ? `\n${fails} failing` : '\nall passing');
+process.exit(fails ? 1 : 0);