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:
@@ -645,15 +645,25 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
lastMessage: null,
|
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) {
|
for (const member of allMembers) {
|
||||||
|
if (!isLocalMember(member)) continue;
|
||||||
connectionManager.sendToUser(member.id, {
|
connectionManager.sendToUser(member.id, {
|
||||||
type: 'dm_channel_created',
|
type: 'dm_channel_created',
|
||||||
dmChannel: result,
|
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) {
|
for (const targetUser of targetUsers) {
|
||||||
if (!targetUser) continue;
|
if (!targetUser) continue;
|
||||||
const baseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username;
|
const baseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username;
|
||||||
@@ -683,11 +693,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
createdAt: sysMsg.createdAt,
|
createdAt: sysMsg.createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
connectionManager.sendToDmMembers(dmChannelId, {
|
// 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',
|
type: 'dm_message_created',
|
||||||
message: sysMsg as any,
|
message: sysMsg as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Federation: relay member_add for each remote member
|
// Federation: relay member_add for each remote member
|
||||||
if (isFederationRelayEnabled() && federatedId) {
|
if (isFederationRelayEnabled() && federatedId) {
|
||||||
@@ -725,8 +739,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const targetOrigins = getGroupDmTargetOrigins(dmChannelId);
|
const targetOrigins = getGroupDmTargetOrigins(dmChannelId);
|
||||||
let finalTargets = targetOrigins;
|
let finalTargets = targetOrigins;
|
||||||
if (finalTargets && targetUser.homeInstance !== domainOrigin && !finalTargets.includes(targetUser.homeInstance)) {
|
// Normalize homeInstance to full URL to match peer origin format
|
||||||
finalTargets = [...finalTargets, targetUser.homeInstance];
|
const targetHomeOrigin = targetUser.homeInstance?.startsWith('http') ? targetUser.homeInstance : `https://${targetUser.homeInstance}`;
|
||||||
|
if (finalTargets && targetHomeOrigin !== domainOrigin && !finalTargets.includes(targetHomeOrigin)) {
|
||||||
|
finalTargets = [...finalTargets, targetHomeOrigin];
|
||||||
}
|
}
|
||||||
|
|
||||||
appendMutationLog(
|
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
|
// Include the new member's instance in targets even if not previously in the group
|
||||||
const targetOrigins = getGroupDmTargetOrigins(id);
|
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;
|
let finalTargets = targetOrigins;
|
||||||
if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) {
|
if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) {
|
||||||
finalTargets = [...finalTargets, newMemberInstance];
|
finalTargets = [...finalTargets, newMemberInstance];
|
||||||
|
|||||||
@@ -1705,8 +1705,15 @@ function processMemberAddEvent(
|
|||||||
lastMessage: null,
|
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) {
|
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, {
|
connectionManager.sendToUser(mu.id, {
|
||||||
type: 'dm_channel_created',
|
type: 'dm_channel_created',
|
||||||
dmChannel: bootstrapResult,
|
dmChannel: bootstrapResult,
|
||||||
|
|||||||
@@ -288,8 +288,10 @@ export function getGroupDmTargetOrigins(dmChannelId: string): string[] | undefin
|
|||||||
|
|
||||||
const origins = new Set<string>();
|
const origins = new Set<string>();
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
if (p.homeInstance !== ourOrigin) {
|
// Normalize homeInstance to full URL format to match federation_peers.origin
|
||||||
origins.add(p.homeInstance);
|
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,
|
timestamp: entry.createdAt,
|
||||||
};
|
};
|
||||||
if (isDm && entry.contextId) evt.dmChannelId = entry.contextId;
|
if (isDm && entry.contextId) evt.dmChannelId = entry.contextId;
|
||||||
|
if (parsed.federatedId) evt.federatedId = parsed.federatedId;
|
||||||
if (parsed.participants) evt.participants = parsed.participants;
|
if (parsed.participants) evt.participants = parsed.participants;
|
||||||
if (parsed.message) evt.message = parsed.message;
|
if (parsed.message) evt.message = parsed.message;
|
||||||
if (parsed.reactions) evt.reactions = parsed.reactions;
|
if (parsed.reactions) evt.reactions = parsed.reactions;
|
||||||
|
|||||||
Reference in New Issue
Block a user