fix: federation relay for group DMs — origin normalization, missing federatedId, local-only broadcasts

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.
This commit is contained in:
Jannis Braun
2026-03-27 16:53:35 +01:00
parent 04c665e0f7
commit 44b6317c16
4 changed files with 40 additions and 12 deletions
+27 -9
View File
@@ -645,15 +645,25 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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];
+8 -1
View File
@@ -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,
@@ -288,8 +288,10 @@ export function getGroupDmTargetOrigins(dmChannelId: string): string[] | undefin
const origins = new Set<string>();
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);
}
}
@@ -171,6 +171,7 @@ async function processOutboxTick(): Promise<void> {
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;