feat(federation): relay membership mutations from dm.ts to federation outbox
Hook POST /api/dm/:id/members and DELETE /api/dm/:id/members to the federation outbox so membership changes propagate to peer instances: - Assign federatedId when a group DM first gains remote members - Relay member_add events with group metadata and participant-aware routing - Relay member_remove (leave) events to peer instances - Relay ownership_transfer when the departing user was the group owner, updating the federated owner columns accordingly
This commit is contained in:
@@ -21,7 +21,17 @@ import {
|
|||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
||||||
import { appendMutationLog, queueOutboxEvent, queueDmRelay } from '../utils/federationOutbox.js';
|
import {
|
||||||
|
appendMutationLog,
|
||||||
|
queueOutboxEvent,
|
||||||
|
queueDmRelay,
|
||||||
|
getDmParticipants,
|
||||||
|
getGroupDmTargetOrigins,
|
||||||
|
isFederationRelayEnabled,
|
||||||
|
computeFederatedId,
|
||||||
|
} from '../utils/federationOutbox.js';
|
||||||
|
import { getOurOrigin } from '../utils/federationAuth.js';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-fetch reactions for a set of DM message IDs.
|
* Batch-fetch reactions for a set of DM message IDs.
|
||||||
@@ -528,7 +538,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enforce DM channel ownership: only the owner can add members (for new-style group DMs)
|
// Enforce DM channel ownership: only the owner can add members (for new-style group DMs)
|
||||||
const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get();
|
let dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get();
|
||||||
if (!dmChannel) {
|
if (!dmChannel) {
|
||||||
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
|
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
|
||||||
}
|
}
|
||||||
@@ -583,6 +593,33 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
// If the channel doesn't have a federatedId and now has a remote member, assign one
|
||||||
|
if (!dmChannel.federatedId && isFederationRelayEnabled()) {
|
||||||
|
const domainOrigin = getOurOrigin();
|
||||||
|
const participants = getDmParticipants(id);
|
||||||
|
const hasRemote = participants.some(p => p.homeInstance !== domainOrigin);
|
||||||
|
|
||||||
|
if (hasRemote) {
|
||||||
|
const newFederatedId = computeFederatedId();
|
||||||
|
const ownerUser = db.select().from(schema.users).where(eq(schema.users.id, dmChannel.ownerId!)).get();
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({
|
||||||
|
federatedId: newFederatedId,
|
||||||
|
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
|
||||||
|
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
|
||||||
|
})
|
||||||
|
.where(eq(schema.dmChannels.id, id))
|
||||||
|
.run();
|
||||||
|
// Refresh for subsequent federation code
|
||||||
|
dmChannel = {
|
||||||
|
...dmChannel,
|
||||||
|
federatedId: newFederatedId,
|
||||||
|
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
|
||||||
|
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build full DmChannel response with all members
|
// Build full DmChannel response with all members
|
||||||
const allMemberRows = db.select()
|
const allMemberRows = db.select()
|
||||||
.from(schema.dmMembers)
|
.from(schema.dmMembers)
|
||||||
@@ -633,6 +670,63 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
dmChannel: result,
|
dmChannel: result,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Federation: relay member_add to peers
|
||||||
|
if (isFederationRelayEnabled() && dmChannel.federatedId) {
|
||||||
|
const domainOrigin = getOurOrigin();
|
||||||
|
const allParticipants = getDmParticipants(id);
|
||||||
|
|
||||||
|
const addedUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
|
||||||
|
const adderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
|
||||||
|
const memberAddPayload: FederationRelayEvent = {
|
||||||
|
eventType: 'member_add',
|
||||||
|
dmChannelId: id,
|
||||||
|
messageId: `member_add:${targetUserId}:${Date.now()}`,
|
||||||
|
federatedId: dmChannel.federatedId,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
membership: {
|
||||||
|
user: {
|
||||||
|
homeUserId: addedUser?.homeUserId || targetUserId,
|
||||||
|
homeInstance: addedUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
addedBy: {
|
||||||
|
homeUserId: adderUser?.homeUserId || request.userId,
|
||||||
|
homeInstance: adderUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
owner: {
|
||||||
|
homeUserId: dmChannel.ownerHomeUserId || request.userId,
|
||||||
|
homeInstance: dmChannel.ownerHomeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
members: allParticipants,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include the new member's instance in targets even if not previously in the group
|
||||||
|
const targetOrigins = getGroupDmTargetOrigins(id);
|
||||||
|
const newMemberInstance = addedUser?.homeInstance || domainOrigin;
|
||||||
|
let finalTargets = targetOrigins;
|
||||||
|
if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) {
|
||||||
|
finalTargets = [...finalTargets, newMemberInstance];
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMutationLog(
|
||||||
|
memberAddPayload.messageId,
|
||||||
|
id,
|
||||||
|
'member_add',
|
||||||
|
JSON.stringify(memberAddPayload),
|
||||||
|
);
|
||||||
|
queueOutboxEvent(
|
||||||
|
memberAddPayload.messageId,
|
||||||
|
id,
|
||||||
|
'member_add',
|
||||||
|
JSON.stringify(memberAddPayload),
|
||||||
|
finalTargets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Active call sync: if there's an active call in this DM, notify the new member
|
// Active call sync: if there's an active call in this DM, notify the new member
|
||||||
const room = connectionManager.getRoom(id);
|
const room = connectionManager.getRoom(id);
|
||||||
if (room && room.roomType === 'dm') {
|
if (room && room.roomType === 'dm') {
|
||||||
@@ -717,6 +811,50 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
eq(schema.readStates.channelId, id),
|
eq(schema.readStates.channelId, id),
|
||||||
)).run();
|
)).run();
|
||||||
|
|
||||||
|
// Federation: relay member_remove (leave) to peers
|
||||||
|
const dmChannelForFed = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get();
|
||||||
|
let leavingUser: typeof schema.users.$inferSelect | undefined;
|
||||||
|
let targetOrigins: string[] | undefined;
|
||||||
|
if (isFederationRelayEnabled() && dmChannelForFed?.federatedId) {
|
||||||
|
const domainOrigin = getOurOrigin();
|
||||||
|
leavingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
|
||||||
|
const memberRemovePayload: FederationRelayEvent = {
|
||||||
|
eventType: 'member_remove',
|
||||||
|
dmChannelId: id,
|
||||||
|
messageId: `member_remove:${request.userId}:${Date.now()}`,
|
||||||
|
federatedId: dmChannelForFed.federatedId,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
membership: {
|
||||||
|
user: {
|
||||||
|
homeUserId: leavingUser?.homeUserId || request.userId,
|
||||||
|
homeInstance: leavingUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
removedBy: {
|
||||||
|
homeUserId: leavingUser?.homeUserId || request.userId,
|
||||||
|
homeInstance: leavingUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
reason: 'leave',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
targetOrigins = getGroupDmTargetOrigins(id);
|
||||||
|
appendMutationLog(
|
||||||
|
memberRemovePayload.messageId,
|
||||||
|
id,
|
||||||
|
'member_remove',
|
||||||
|
JSON.stringify(memberRemovePayload),
|
||||||
|
);
|
||||||
|
queueOutboxEvent(
|
||||||
|
memberRemovePayload.messageId,
|
||||||
|
id,
|
||||||
|
'member_remove',
|
||||||
|
JSON.stringify(memberRemovePayload),
|
||||||
|
targetOrigins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check remaining members
|
// Check remaining members
|
||||||
const remainingMembers = db.select()
|
const remainingMembers = db.select()
|
||||||
.from(schema.dmMembers)
|
.from(schema.dmMembers)
|
||||||
@@ -731,6 +869,55 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.set({ ownerId: nextOwner.userId })
|
.set({ ownerId: nextOwner.userId })
|
||||||
.where(eq(schema.dmChannels.id, id))
|
.where(eq(schema.dmChannels.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
// Federation: relay ownership transfer
|
||||||
|
if (isFederationRelayEnabled() && dmChannelForFed?.federatedId) {
|
||||||
|
const domainOrigin = getOurOrigin();
|
||||||
|
const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get();
|
||||||
|
const prevOwnerUser = leavingUser; // Already queried above in step D
|
||||||
|
|
||||||
|
// Update federated owner columns
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({
|
||||||
|
ownerHomeUserId: newOwnerUser?.homeUserId || nextOwner.userId,
|
||||||
|
ownerHomeInstance: newOwnerUser?.homeInstance || domainOrigin,
|
||||||
|
})
|
||||||
|
.where(eq(schema.dmChannels.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const transferPayload: FederationRelayEvent = {
|
||||||
|
eventType: 'ownership_transfer',
|
||||||
|
dmChannelId: id,
|
||||||
|
messageId: `ownership_transfer:${nextOwner.userId}:${Date.now()}`,
|
||||||
|
federatedId: dmChannelForFed.federatedId,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ownership: {
|
||||||
|
newOwner: {
|
||||||
|
homeUserId: newOwnerUser?.homeUserId || nextOwner.userId,
|
||||||
|
homeInstance: newOwnerUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
previousOwner: {
|
||||||
|
homeUserId: prevOwnerUser?.homeUserId || request.userId,
|
||||||
|
homeInstance: prevOwnerUser?.homeInstance || domainOrigin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
appendMutationLog(
|
||||||
|
transferPayload.messageId,
|
||||||
|
id,
|
||||||
|
'ownership_transfer',
|
||||||
|
JSON.stringify(transferPayload),
|
||||||
|
);
|
||||||
|
queueOutboxEvent(
|
||||||
|
transferPayload.messageId,
|
||||||
|
id,
|
||||||
|
'ownership_transfer',
|
||||||
|
JSON.stringify(transferPayload),
|
||||||
|
targetOrigins,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast dm_member_removed to remaining members
|
// Broadcast dm_member_removed to remaining members
|
||||||
|
|||||||
Reference in New Issue
Block a user