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
+13
View File
@@ -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) ## 🔎 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. 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.
@@ -29,6 +29,9 @@ export function buildForwardContent(
} }
delete content['m.relates_to']; 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') { if (typeof content.body === 'string') {
content.body = trimReplyFromBody(content.body); content.body = trimReplyFromBody(content.body);
} }
+4
View File
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => { 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. // Subscribe on mx (MatrixClient) rather than on individual User objects.
// User objects have a default 10-listener limit; the same user can appear // User objects have a default 10-listener limit; the same user can appear
// in many components simultaneously (avatars, member list, etc.) and // in many components simultaneously (avatars, member list, etc.) and
+12
View File
@@ -44,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
import { useSessionSync } from '../../hooks/useSessionSync'; import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog'; import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery'; import { AutoDiscovery } from './AutoDiscovery';
@@ -143,7 +145,17 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => { const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => { useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { 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(); 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(); await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it // The opt-in local search index holds DECRYPTED message plaintext. Wipe it
// on server-forced logout too (token expiry / remote sign-out / password // on server-forced logout too (token expiry / remote sign-out / password
+3 -1
View File
@@ -83,7 +83,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
allParents.forEach((parentId) => { allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId); const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return; 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); newFrom.delete(roomId);
if (newFrom.size === 0) { if (newFrom.size === 0) {
roomToUnread.delete(parentId); 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 // the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction. // wins by construction.
export const getFallbackSession = (): Session | undefined => { 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; if (!persisted) return undefined;
return sessionFromPersisted(persisted); return sessionFromPersisted(persisted);
}; };