aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 10:13:48 -0700
committerAdam Soltys <[email protected]>2026-10-09 10:13:48 -0700
commitad028dd1c61e41913e4628046b96bf162bc4ba4a (patch)
tree5aceddeaa0e8352d5b58111adc598c335aa3e8cd /src/features
parent479959cdd77fdd18ca03f63af9b94eae291d0e2a (diff)
downloadcoinosv3-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/features')
-rw-r--r--src/features/messages.js47
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