From aeebf79feb2922c5c58f6f1f52ad19ecd7eaf1a1 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:36:15 +0200 Subject: [PATCH] fix: federated friend request routed to wrong user with same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two instances each have a native user with the same username, the Add Friend search card for the federated one sent its request to the local namesake instead of the intended remote user. Root cause: `isNative = !homeUserId` in socialStore's searchUsers and loadFriends dedup. The server backfills native users' homeUserId to their own id so federation tier-1 lookups succeed, so `homeUserId` is set on natives too. Only `homeInstance` distinguishes native (null) from replicated stubs. With the wrong check, no entry was ever "native" and the home-origin stub of the remote user was kept over the true native record — leaving `_instanceOrigin=''`, which caused the Send button handler to drop the domain suffix and POST to the home API, where "nova" resolved to a completely different local user. Also fixes loadRequests dedup to prefer the target-native record so the search card correctly flips to "Request Pending" after sending. --- docs/systems/social.md | 9 +++--- packages/web/src/stores/socialStore.ts | 39 ++++++++++++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/systems/social.md b/docs/systems/social.md index bdcde69e..7f44a131 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -401,13 +401,13 @@ type TaggedUser = User & { _instanceOrigin: string }; 2. Fires `Promise.allSettled()` with: - Home instance: `api.social.friends()` - Each connected remote instance: `inst.api.social.friends()` -3. **Deduplication:** Uses a `Set` keyed by `${friend.id}:${origin}` -- prevents duplicates within the same instance +3. **Deduplication by canonical identity:** Uses `Map` keyed by `friend.homeUserId ?? friend.id`. First occurrence wins, but **native profiles replace replicated stubs**: a native profile (`homeInstance` is null) found for a canonical ID that was previously seen as a stub replaces the entry. Critically, the "native" check is `!homeInstance`, **not** `!homeUserId` -- the server backfills native users' `homeUserId` to their own id so federation tier-1 lookups succeed (see `federation.ts:backfillHomeUserId`), so `homeUserId` is set on natives too. 4. **Asset normalization:** For remote-origin friends, calls `normalizeUserAssets(friend, origin)` to resolve relative avatar/banner URLs to absolute remote URLs 5. Stores the merged, tagged array as `friends` ### Cross-Instance Request Loading (`loadRequests`) -Same `Promise.allSettled()` fan-out pattern as `loadFriends`, with same dedup key: `${request.id}:${origin}`. Normalizes assets for remote request user profiles. +Same `Promise.allSettled()` fan-out pattern as `loadFriends`. **Dedup by the other party's canonical identity** (`request.user.homeUserId ?? request.user.id`), preferring the record from the instance where the other party is native (`!request.user.homeInstance`). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct `_instanceOrigin` tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles. ### Sending Friend Requests (Federation Routing) @@ -426,8 +426,9 @@ Same `Promise.allSettled()` fan-out pattern as `loadFriends`, with same dedup ke 1. Fires parallel searches to home + all connected instances 2. **Deduplication by canonical identity:** Uses `Map` keyed by `user.homeUserId ?? user.id` - - First occurrence wins, but **native profiles replace replicated stubs**: if a native profile (`homeUserId` is null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry - - This ensures the user sees the "real" profile rather than a replicated copy + - First occurrence wins, but **native profiles replace replicated stubs**: if a native profile (`homeInstance` is null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry + - The "native" check is `!homeInstance`, **not** `!homeUserId`. Native users have `homeUserId` backfilled to their own id by the server so federation tier-1 lookups succeed (`federation.ts:backfillHomeUserId`). `homeInstance` is the only field that reliably distinguishes native users (null) from replicated stubs (set to domain). + - This ensures the user sees the "real" profile (including the correct `_instanceOrigin` tag) rather than a replicated stub whose origin would be the caller's home instance ### Instance API Resolution (`getApiForOrigin`) diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 62a9c775..419fc02a 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -111,7 +111,10 @@ export const useSocialStore = create((set, get) => ({ const allFriends: TaggedFriend[] = []; // Deduplicate by canonical identity — a user who exists on multiple // instances (native + replicated stub) should appear once. - // Native profiles (homeUserId is null) replace stubs when found. + // Native profiles (homeInstance is null) replace stubs when found. + // Note: homeUserId alone is NOT a native indicator — the server backfills + // native users' homeUserId to their own id so federation tier-1 lookups + // can find them. Only homeInstance distinguishes native from replicated. const seen = new Map(); // canonicalId → index in allFriends for (const result of results) { @@ -119,12 +122,13 @@ export const useSocialStore = create((set, get) => ({ const { friends, origin } = result.value; for (const friend of friends) { const canonicalId = friend.homeUserId ?? friend.id; - const isNative = !friend.homeUserId; + const isNative = !friend.homeInstance; const existingIdx = seen.get(canonicalId); if (existingIdx !== undefined) { // Replace replicated stub with native profile when found if (isNative) { + if (origin) normalizeUserAssets(friend, origin); allFriends[existingIdx] = { ...friend, _instanceOrigin: origin }; } continue; @@ -165,18 +169,31 @@ export const useSocialStore = create((set, get) => ({ const allRequests: TaggedFriendRequest[] = []; // Deduplicate by the canonical identity of the other party — // there can only be one pending request between any two users. - const seen = new Set(); + // Prefer the record from the instance where the other party is native + // (homeInstance is null), because that record's ids and _instanceOrigin + // line up with the discover/search cards and the UserProfileModal — + // this is what lets buttons like "Request Pending" match correctly. + // Note: homeUserId alone is NOT a native indicator — see loadFriends. + const seen = new Map(); for (const result of results) { if (result.status !== 'fulfilled') continue; const { requests, origin } = result.value; for (const request of requests) { - // Use the other party's canonical identity for dedup const otherCanonicalId = request.user?.homeUserId ?? request.user?.id; - if (otherCanonicalId) { - if (seen.has(otherCanonicalId)) continue; - seen.add(otherCanonicalId); + const otherIsNativeHere = !request.user?.homeInstance; + const existingIdx = otherCanonicalId ? seen.get(otherCanonicalId) : undefined; + + if (existingIdx !== undefined) { + // Replace prior stub-origin record with native one + if (otherIsNativeHere) { + if (origin && request.user) normalizeUserAssets(request.user, origin); + allRequests[existingIdx] = { ...request, _instanceOrigin: origin }; + } + continue; } + + if (otherCanonicalId) seen.set(otherCanonicalId, allRequests.length); if (origin && request.user) normalizeUserAssets(request.user, origin); allRequests.push({ ...request, _instanceOrigin: origin }); } @@ -335,9 +352,13 @@ export const useSocialStore = create((set, get) => ({ for (const user of result.value) { // Deduplicate by canonical identity: replicated profiles share // the same homeUserId as the native profile's id, so collapse them. - // Prefer native profiles (homeUserId is null) over replicated ones. + // Prefer native profiles (homeInstance is null) over replicated ones. + // Note: homeUserId alone is NOT a native indicator — the server + // backfills native users' homeUserId to their own id so federation + // tier-1 lookups can find them. Only homeInstance distinguishes + // native from replicated. const canonicalId = user.homeUserId ?? user.id; - const isNative = !user.homeUserId; + const isNative = !user.homeInstance; const existingIdx = seen.get(canonicalId); if (existingIdx !== undefined) {