From 44b6317c16364c1bca9d44d65ce3fc7ad5c96e92 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:53:35 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20federation=20relay=20for=20group=20DMs?= =?UTF-8?q?=20=E2=80=94=20origin=20normalization,=20missing=20federatedId,?= =?UTF-8?q?=20local-only=20broadcasts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stacked bugs prevented federation relay from working for group DMs: 1. Origin format mismatch: users.home_instance stores bare domains ("nova.ddns.net") but federation_peers.origin stores full URLs ("https://nova.ddns.net"). getGroupDmTargetOrigins() built target lists from bare domains, so queueOutboxEvent() never matched any peers — events were never queued into the outbox. 2. Missing federatedId in outbox reconstruction: the outbox worker rebuilt relay events from stored payloads but never copied the federatedId field. Receiving instances check this field and rejected all member_add/remove/ownership_transfer events with "missing_membership_payload". 3. Duplicate channels from cross-instance broadcasts: dm_channel_created was sent to ALL members including remote replicas. Users connected to multiple instances received the event twice (once per instance), creating duplicate group DMs in their sidebar. Fixed by only broadcasting to members whose home instance matches the current server — remote members receive the channel via federation bootstrap on their home instance. --- packages/server/src/routes/dm.ts | 36 ++++++++++++++----- packages/server/src/routes/federation.ts | 9 ++++- packages/server/src/utils/federationOutbox.ts | 6 ++-- packages/server/src/utils/federationWorker.ts | 1 + 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index c13077bd..1801ad00 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -645,15 +645,25 @@ export async function dmRoutes(app: FastifyInstance): Promise { lastMessage: null, }; - // Broadcast dm_channel_created to ALL members (including caller) + // Broadcast dm_channel_created only to LOCAL members. + // Remote members will receive the channel via federation relay → bootstrap + // on their home instance, preventing duplicate channels in their sidebar. + const domainOriginForBroadcast = isFederationRelayEnabled() ? getOurOrigin() : null; + const isLocalMember = (u: { homeInstance?: string | null }) => + !u.homeInstance || !domainOriginForBroadcast || + u.homeInstance === domainOriginForBroadcast || + `https://${u.homeInstance}` === domainOriginForBroadcast; + for (const member of allMembers) { + if (!isLocalMember(member)) continue; connectionManager.sendToUser(member.id, { type: 'dm_channel_created', dmChannel: result, }); } - // Insert & broadcast system messages for each added member + // Insert system messages (DB) for all members, but only broadcast to local members. + // Remote instances create their own system messages via federation event handlers. for (const targetUser of targetUsers) { if (!targetUser) continue; const baseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username; @@ -683,10 +693,14 @@ export async function dmRoutes(app: FastifyInstance): Promise { createdAt: sysMsg.createdAt, }).run(); - connectionManager.sendToDmMembers(dmChannelId, { - type: 'dm_message_created', - message: sysMsg as any, - }); + // Only broadcast to local members — remote instances handle their own + for (const member of allMembers) { + if (!isLocalMember(member)) continue; + connectionManager.sendToUser(member.id, { + type: 'dm_message_created', + message: sysMsg as any, + }); + } } // Federation: relay member_add for each remote member @@ -725,8 +739,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { const targetOrigins = getGroupDmTargetOrigins(dmChannelId); let finalTargets = targetOrigins; - if (finalTargets && targetUser.homeInstance !== domainOrigin && !finalTargets.includes(targetUser.homeInstance)) { - finalTargets = [...finalTargets, targetUser.homeInstance]; + // Normalize homeInstance to full URL to match peer origin format + const targetHomeOrigin = targetUser.homeInstance?.startsWith('http') ? targetUser.homeInstance : `https://${targetUser.homeInstance}`; + if (finalTargets && targetHomeOrigin !== domainOrigin && !finalTargets.includes(targetHomeOrigin)) { + finalTargets = [...finalTargets, targetHomeOrigin]; } appendMutationLog( @@ -1010,7 +1026,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { // Include the new member's instance in targets even if not previously in the group const targetOrigins = getGroupDmTargetOrigins(id); - const newMemberInstance = addedUser?.homeInstance || domainOrigin; + // Normalize homeInstance to full URL to match peer origin format + const rawNewMemberInstance = addedUser?.homeInstance || domainOrigin; + const newMemberInstance = rawNewMemberInstance.startsWith('http') ? rawNewMemberInstance : `https://${rawNewMemberInstance}`; let finalTargets = targetOrigins; if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) { finalTargets = [...finalTargets, newMemberInstance]; diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 78d14605..32117cd9 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1705,8 +1705,15 @@ function processMemberAddEvent( lastMessage: null, }; - // Send dm_channel_created to all local WebSocket-connected members + // Send dm_channel_created only to members whose home is THIS instance. + // Remote replicas will get the channel from their own home instance's + // federation bootstrap — prevents duplicate channels in their sidebar. + const bootstrapOrigin = getOurOrigin(); for (const mu of memberUsers) { + const muHome = mu.homeInstance + ? (mu.homeInstance.startsWith('http') ? mu.homeInstance : `https://${mu.homeInstance}`) + : bootstrapOrigin; // null homeInstance = native local user + if (muHome !== bootstrapOrigin) continue; connectionManager.sendToUser(mu.id, { type: 'dm_channel_created', dmChannel: bootstrapResult, diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 1f178a59..3d3f7fbc 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -288,8 +288,10 @@ export function getGroupDmTargetOrigins(dmChannelId: string): string[] | undefin const origins = new Set(); for (const p of participants) { - if (p.homeInstance !== ourOrigin) { - origins.add(p.homeInstance); + // Normalize homeInstance to full URL format to match federation_peers.origin + const normalized = p.homeInstance.startsWith('http') ? p.homeInstance : `https://${p.homeInstance}`; + if (normalized !== ourOrigin) { + origins.add(normalized); } } diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 67990aaf..1cb7ff26 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -171,6 +171,7 @@ async function processOutboxTick(): Promise { timestamp: entry.createdAt, }; if (isDm && entry.contextId) evt.dmChannelId = entry.contextId; + if (parsed.federatedId) evt.federatedId = parsed.federatedId; if (parsed.participants) evt.participants = parsed.participants; if (parsed.message) evt.message = parsed.message; if (parsed.reactions) evt.reactions = parsed.reactions;