diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 10:13:48 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 10:13:48 -0700 |
| commit | ad028dd1c61e41913e4628046b96bf162bc4ba4a (patch) | |
| tree | 5aceddeaa0e8352d5b58111adc598c335aa3e8cd /src | |
| parent | 479959cdd77fdd18ca03f63af9b94eae291d0e2a (diff) | |
| download | coinosv3-ad028dd1c61e41913e4628046b96bf162bc4ba4a.tar.gz coinosv3-ad028dd1c61e41913e4628046b96bf162bc4ba4a.zip | |
Accounts: switching no longer leaks the previous identity's notifications and DMs into the next. A notifications query (up to 5 s on the relays) or a wrap mid-decrypt that was still out at the switch landed in the NEW account's list and was saved there under its key — a throwaway account showed adam's zaps, reactions and conversations for good. A session counter bumped in stop() is checked after every await on both paths; notifications only take events p-tagging this identity, and an incoming DM must come from or name it. Healing: notification caches saved before this (no notifsV 2) are dropped once and refetched; a cached DM thread holding a row from someone who is neither us nor the peer is swept. tools/account-switch-leak-test.js (fails on the old code: the reaction and the thread both leak)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 47 |
1 files changed, 39 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index ac25b54..2097a5a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -565,6 +565,12 @@ export function messagesFeature(ctx) { // ---- shared runtime ----------------------------------------------------- + // Bumped on every stop(): an account switch. Async work started under the + // previous identity (a relay query, a wrap mid-decrypt) checks it after + // each await and drops what it got — it used to land in the NEXT account's + // state and be saved there, so a throwaway account showed adam's DMs and + // notifications for good. + let session = 0; const rooms = new Map(); // cid -> room runtime const threads = new Map(); // peerPk -> Map(rumorId -> { rumor, mine }) // DM reactions, keyed by the reacted message's id — no thread needed, so a @@ -2911,9 +2917,11 @@ export function messagesFeature(ctx) { // true: opened. false: not ours. 'later': a remote signer never answered, // so nothing is known yet — the wrap must be tried again, uncounted. async function openInboxWrap(wrap, heal = false) { + const g = session; for (const d of dmDecryptors()) { let got; - try { got = await unwrapDMAny(wrap, d); } catch { return 'later'; } + try { got = await unwrapDMAny(wrap, d); } catch { return g === session ? 'later' : 'stale'; } + if (g !== session) return 'stale'; // opened with the previous account's key if (!got) continue; if (heal && ![7, 14, 15].includes(got.rumor.kind)) return true; if (got.rumor.kind === 14 || got.rumor.kind === 15) { @@ -2922,6 +2930,9 @@ export function messagesFeature(ctx) { // sent-copy authored by any of them must thread under the RECIPIENT, // or every welcome DM the registrar sends lands in a self-thread. const mine = isMe(got.author); + // a message that neither comes from nor names this identity isn't + // this account's conversation, however it reached us + if (!mine && !(got.rumor.tags || []).some((x) => x[0] === 'p' && isMe(x[1]))) return true; const to = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; const peer = mine ? (to || got.peer || got.author) : got.author; noteEmoji(got.rumor); @@ -2964,6 +2975,7 @@ export function messagesFeature(ctx) { } seenWraps.add(wrap.id); const r = await openInboxWrap(wrap, heal); + if (r === 'stale') return; if (r === true) { rememberWrap(wrap.id); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); @@ -3003,6 +3015,7 @@ export function messagesFeature(ctx) { // tried. Stop here — the slow retry picks the rest up. if (complete && !decryptorsComplete()) break; const r = await openInboxWrap(p.wrap, p.heal); + if (r === 'stale') break; // the account changed under this pass if (r === true) { pendingWraps.delete(id); rememberWrap(id); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } @@ -3049,6 +3062,14 @@ export function messagesFeature(ctx) { swept = true; continue; } + // a row from someone who is neither us nor the peer: another + // identity's conversation, saved here by an account switch mid-decrypt + // (fixed — see session). The whole thread goes; ours refetches. + if (list.some((m) => !isMe(m.from) && m.from !== peer)) { + delete s.dms[peer]; + swept = true; + continue; + } for (const m of list) threadOf(peer).set(m.id, { rumor: rumorFromRow(m), mine: isMe(m.from) }); } bumpMsgRev(); @@ -12931,6 +12952,7 @@ export function messagesFeature(ctx) { const NOTIF_KINDS = [1, COMMENT, 6, 7, ...ZAP_KINDS]; const NOTIF_KEEP = 150; let notif = null; // { status, items } + const NOTIF_V = 2; let notifAt = 0, notifUnsub = null; const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; @@ -12941,7 +12963,9 @@ export function messagesFeature(ctx) { function notifNow() { if (!notif) { const s = st(); - const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : []; + // v2: lists saved before the account-switch fix may hold another + // identity's rows under this one's key — dropped once, refetched + const stored = (s.notifsV === NOTIF_V && s.notifsPk === identityPk()) ? (s.notifs || []) : []; notif = { status: stored.length ? 'ready' : 'loading', items: stored }; // Reply events and the notes reactions/zaps point at ride beside their // notification rows. Seed the in-memory note caches synchronously, so @@ -12988,7 +13012,7 @@ export function messagesFeature(ctx) { const notes = {}; for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); const s2 = st(); - s2.notifs = notif.items; s2.notifsPk = identityPk(); + s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V; s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); save(s2); } @@ -13055,10 +13079,12 @@ export function messagesFeature(ctx) { return null; } function mergeNotifs(evs) { + // only what names this identity + evs = (evs || []).filter((ev) => ev && (ev.tags || []).some((x) => x[0] === 'p' && isMe(x[1]))); const c = notifNow(); const known = new Set(c.items.map((x) => x.id)); - for (const ev of evs || []) noteForSpam(ev); - const add = (evs || []).map((ev) => ((ev.kind === 1 || ev.kind === COMMENT) && hidden(ev) ? null : notifItem(ev))) + for (const ev of evs) noteForSpam(ev); + const add = evs.map((ev) => ((ev.kind === 1 || ev.kind === COMMENT) && hidden(ev) ? null : notifItem(ev))) .filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) return false; warmNotifFaces(add); @@ -13087,14 +13113,18 @@ export function messagesFeature(ctx) { if (!my.length) { if (notif) notif.status = 'ready'; return; } if (!force && Date.now() - notifAt < 30_000) return; notifAt = Date.now(); + const g = session; try { const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000); + if (g !== session) return; // asked for the previous account if (mergeNotifs(evs)) scheduleRepaint(); } catch {} finally { - if (notif) notif.status = 'ready'; - scheduleRepaint(); + if (g === session) { + if (notif) notif.status = 'ready'; + scheduleRepaint(); + } } - watchNotifs(); + if (g === session) watchNotifs(); } function watchNotifs() { stopNotifWatch(); @@ -13610,6 +13640,7 @@ export function messagesFeature(ctx) { // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear(); + session++; zapWho.clear(); whoOpenIds.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next |
