From 8af155d08fb81b4cf70d413f6ce6aef60fedece6 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:53:38 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20federation=20DM=20identity=20resolution?= =?UTF-8?q?=20=E2=80=94=20cross-instance=20isSelf()=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a cross-instance self-ID registry to identity.ts so isSelf() can recognize the current user's Snowflake IDs from all connected instances. Previously, federated DMs showed the user themselves as the other party because remote-instance IDs didn't match the home user ID. - Register user IDs from every WS ready event (home + remote) - Clear the registry on session reset (login/logout/register/delete) - Fix isSelf() username comparison to parse both sides as federated - Replace naive ID check in MessageList WelcomeHeader with isSelf() --- .../web/src/components/chat/MessageList.tsx | 3 ++- packages/web/src/hooks/useWebSocket.ts | 4 ++++ packages/web/src/stores/authStore.ts | 2 ++ packages/web/src/utils/identity.ts | 19 ++++++++++++++++++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index 9dcdee66..979e5c97 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -7,6 +7,7 @@ import { useSocialStore } from '../../stores/socialStore'; import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { isSelf } from '../../utils/identity'; import type { MessageWithUser } from '@backspace/shared'; const EMPTY_MESSAGES: MessageWithUser[] = []; @@ -247,7 +248,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) { if (isDm) { const dm = dmChannels.find(d => d.id === channelId); - const otherUser = dm?.members.find(m => m.id !== authUser?.id); + const otherUser = dm?.members.find(m => !isSelf(m, authUser)); const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; const username = otherUser?.username ?? 'unknown'; const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 905e97d0..f16eda13 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '../stores/settingsStore'; import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared'; import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; +import { registerSelfId } from '../utils/identity'; // ─── Connection state ───────────────────────────────────────────────────────── @@ -88,6 +89,9 @@ function handleEvent(origin: string, event: ServerEvent): void { switch (event.type) { case 'ready': + // Register this user's ID for cross-instance self-identification + registerSelfId(event.user.id); + if (isHome) { setUser(event.user); useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false); diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index 81b30561..f2471c78 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -8,6 +8,7 @@ import { useVoiceStore } from './voiceStore'; import { useInstanceStore } from './instanceStore'; import { syncProfileUpdateToRemotes } from '../utils/profileSync'; import { changePasswordOnRemotes, deleteAccountOnRemotes, type FederationOpResult } from '../utils/federationOps'; +import { clearSelfIds } from '../utils/identity'; interface AuthState { token: string | null; @@ -27,6 +28,7 @@ interface AuthState { /** Reset all user-scoped stores to prevent data leaking between sessions */ function resetUserStores() { + clearSelfIds(); useChatStore.getState().clearAllMessages(); useSpaceStore.getState().populateFromReady('', [], [], []); useSocialStore.getState().reset(); diff --git a/packages/web/src/utils/identity.ts b/packages/web/src/utils/identity.ts index fa556d7a..2a3f179a 100644 --- a/packages/web/src/utils/identity.ts +++ b/packages/web/src/utils/identity.ts @@ -11,6 +11,20 @@ export function parseFederatedUsername(username: string): { baseName: string; do return { baseName: username.slice(0, atIndex), domain: username.slice(atIndex + 1) }; } +// ─── Cross-instance self-ID registry ───────────────────────────────────────── +// Tracks all Snowflake IDs that belong to the current user across connected +// instances (home + remotes). Populated from WS `ready` events. + +const _knownSelfIds = new Set(); + +export function registerSelfId(id: string): void { + _knownSelfIds.add(id); +} + +export function clearSelfIds(): void { + _knownSelfIds.clear(); +} + /** * Stateless check: is `user` a replicated alias of `homeUser`? * Uses the immutable (username, homeInstance) composite key — @@ -23,12 +37,15 @@ export function isSelf( if (!homeUser) return false; // Same instance, same ID — trivial case if (user.id === homeUser.id) return true; + // Cross-instance: check all known user IDs from connected instances + if (_knownSelfIds.has(user.id)) return true; // Replicated user: homeInstance matches our origin if (!user.homeInstance) return false; if (user.homeInstance !== window.location.host) return false; // Username: "youruser" or "youruser@nova.ddns.net" → base must match const { baseName } = parseFederatedUsername(user.username); - return baseName === homeUser.username; + const { baseName: homeBase } = parseFederatedUsername(homeUser.username); + return baseName === homeBase; } /**