fix(audit): low-tail cleanup — session/logout/unread/presence/forward
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 14s

Clears the clean 🟡 remainders from the feature audit (gate-green, 677 tests):
- F3: getFallbackSession prefers the session-blob/legacy source with the later
  expiresAt (a downgrade→upgrade could boot on a stale blob's dead token).
- F6: server-forced logout (SessionLoggedOut) now mirrors logoutClient —
  pushSessionToSW() + best-effort revokeOidcTokens for OIDC sessions (the search
  plaintext wipe was already added).
- N5: deleteUnreadInfo parent fallback `?? roomId` → `?? []` (latently spread the
  roomId string into chars).
- P10: useUserPresence re-seeds when the User object appears after first render.
- forward: strip m.mentions so forwarding doesn't re-ping the original mentions.

Left open: F5 (OIDC expiry not reachable in persistTokens), N6/H10/D7 (minor /
runtime-verify). See LOTUS_TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:57:09 -04:00
parent b7788cc79c
commit 21276a47fc
6 changed files with 50 additions and 2 deletions
+3 -1
View File
@@ -83,7 +83,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return;
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
// `from` is always a Set for parent aggregates; the fallback must be an
// iterable of ids, NOT the roomId string (which would spread into chars).
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
newFrom.delete(roomId);
if (newFrom.size === 0) {
roomToUnread.delete(parentId);
+15 -1
View File
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
// the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction.
export const getFallbackSession = (): Session | undefined => {
const persisted = readSessionBlob() ?? readLegacyKeys();
const blob = readSessionBlob();
const legacy = readLegacyKeys();
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
// pre-blob build's token refresh writes only the legacy keys, so a
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
// booting on a dead token. Whichever has the later expiresAt wins.
let persisted = blob ?? legacy;
if (
blob &&
legacy &&
typeof legacy.expiresAt === 'number' &&
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
) {
persisted = legacy;
}
if (!persisted) return undefined;
return sessionFromPersisted(persisted);
};