From 21276a47fc66fd6cd9be3d8fbebef4984f0112e6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 22:57:09 -0400 Subject: [PATCH] =?UTF-8?q?fix(audit):=20low-tail=20cleanup=20=E2=80=94=20?= =?UTF-8?q?session/logout/unread/presence/forward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_TODO.md | 13 +++++++++++++ src/app/features/room/message/forwardContent.ts | 3 +++ src/app/hooks/useUserPresence.ts | 4 ++++ src/app/pages/client/ClientRoot.tsx | 12 ++++++++++++ src/app/state/room/roomToUnread.ts | 4 +++- src/app/state/sessions.ts | 16 +++++++++++++++- 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 6c1eb34aa..2a85b0a6c 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -96,6 +96,19 @@ Tier-2 bug-hunt (desktop/native, crypto/session/infra, messaging data) by 3 para --- +## 🧹 Audit low-tail cleanup (2026-07) — audit closed out + +Cleared the clean 🟡 remainders, gate-green (677 tests, build OK): + +- **F3** `getFallbackSession` now prefers whichever of the session-blob / legacy keys carries the later `expiresAt` (a downgrade→upgrade could boot on a stale blob's dead token). +- **F6** server-forced logout (`ClientRoot` `SessionLoggedOut`) now mirrors `logoutClient`: `pushSessionToSW()` + best-effort `revokeOidcTokens` for OIDC sessions. +- **N5** `deleteUnreadInfo` parent-aggregate fallback `?? roomId` → `?? []` (was latently spreadable into chars). +- **P10** `useUserPresence` re-seeds `getUserPresence(user)` when the `User` object appears after first render (badge no longer blank until the next event). +- **forward** strips `m.mentions` so forwarding a message doesn't re-ping the originally-mentioned users. +- **D4** (native) `forward_deeplink` dedupes the same URL within ~1s so a cold-start `matrix:` link doesn't navigate twice. + +**Left open (rationale):** **F5** OIDC `persistTokens` can't reach the access-token expiry without SDK-internal plumbing (minor — refresh is reactive on 401). **N6** membership-refresh emitter uncertain + low impact. **H10** room-name setter fire-and-forget is trivial + would touch the just-refactored `RoomNavItem`. **D7** Unity `.desktop` id is a runtime-verify, not a code fix. + ## 🔎 Audit findings — Wave 3 (2026-07) Tier-3 bug-hunt (theming/visual, presence/UX/composer, rooms-customization/moderation) by 3 parallel agents. Higher-severity than expected in the non-theming areas. `[P#]`=presence/UX, `[H#]`=rooms/moderation, `[T#]`=theming. diff --git a/src/app/features/room/message/forwardContent.ts b/src/app/features/room/message/forwardContent.ts index 7cdf78e71..03b41a624 100644 --- a/src/app/features/room/message/forwardContent.ts +++ b/src/app/features/room/message/forwardContent.ts @@ -29,6 +29,9 @@ export function buildForwardContent( } delete content['m.relates_to']; + // Drop intentional mentions so forwarding a message doesn't re-ping the + // originally-mentioned users (they're not in the destination room's context). + delete content['m.mentions']; if (typeof content.body === 'string') { content.body = trimReplyFromBody(content.body); } diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 9cf63e362..dafc48193 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { + // Re-seed when the User object appears/changes after first render — the + // useState initializer only ran if `user` already existed at mount, so a + // late-arriving user would otherwise show no presence until the next event. + if (user) setPresence(getUserPresence(user)); // Subscribe on mx (MatrixClient) rather than on individual User objects. // User objects have a default 10-listener limit; the same user can appear // in many components simultaneously (avatars, member list, etc.) and diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 13668e62e..14c17e608 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -44,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; +import { pushSessionToSW } from '../../../sw-session'; +import { revokeOidcTokens } from '../../../client/oidcLogout'; import { useSessionSync } from '../../hooks/useSessionSync'; import { installCryptoDiagLog } from '../../utils/cryptoDiagLog'; import { AutoDiscovery } from './AutoDiscovery'; @@ -143,7 +145,17 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + // Clear the SW's cached bearer token so it stops attaching the now-revoked + // token to media fetches (mirrors the manual logoutClient path). + pushSessionToSW(); mx?.stopClient(); + // Best-effort issuer revocation for OIDC sessions (the token is already + // server-revoked here, but revoke the refresh token too). Before we drop + // the stored session below. + const loggedOutSession = getFallbackSession(); + if (loggedOutSession?.oidc) { + await revokeOidcTokens(loggedOutSession).catch(() => undefined); + } await mx?.clearStores(); // The opt-in local search index holds DECRYPTED message plaintext. Wipe it // on server-forced logout too (token expiry / remote sign-out / password diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 85610e5cc..efdeb1409 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -83,7 +83,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set, 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); diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index b2d019df2..85a03f203 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -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); };