fix(audit): low-tail cleanup — session/logout/unread/presence/forward
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user