fix: federation DM identity corruption — sync federatedId, guard backfill, remove bad merge criterion

Three bugs that combined to corrupt DM identities during initial sync:

1. Sync endpoint omitted federatedId for group DMs, causing the receiver
   to treat them as 1-on-1 DMs and compute wrong pair hashes — creating
   phantom channels that collide with real ones.

2. backfillHomeUserId unconditionally overwrote existing homeUserIds,
   so a single wrong match would permanently corrupt a user's identity
   and cascade to all subsequent lookups.

3. Migration duplicate-stub Criterion 1 ("shared 1-on-1 DM membership")
   incorrectly merged different users from the same domain who were
   simply having a conversation, destroying one user's identity.
This commit is contained in:
Jannis Braun
2026-04-08 00:49:14 +02:00
parent 2d32d9ae41
commit a496dc01bd
2 changed files with 19 additions and 13 deletions
+3 -13
View File
@@ -789,19 +789,9 @@ export function runMigrations(db: Database.Database): void {
let reason: string | null = null; let reason: string | null = null;
// Criterion 1: shared 1-on-1 DM membership // Criterion 1 removed: "shared 1-on-1 DM membership" was wrong —
if (!reason) { // two users from the same domain sharing a DM channel doesn't mean
const shared = db.prepare(` // they're duplicates, it means they're having a conversation.
SELECT m1.dm_channel_id
FROM dm_members m1
JOIN dm_members m2 ON m1.dm_channel_id = m2.dm_channel_id
JOIN dm_channels c ON c.id = m1.dm_channel_id
WHERE m1.user_id = ? AND m2.user_id = ?
AND c.owner_id IS NULL
LIMIT 1
`).get(a.id, b.id) as { dm_channel_id: string } | undefined;
if (shared) reason = `shared 1-on-1 DM channel ${shared.dm_channel_id}`;
}
// Criterion 2: username cross-reference // Criterion 2: username cross-reference
if (!reason) { if (!reason) {
+16
View File
@@ -1208,8 +1208,17 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
sourceUrl: `${localOrigin}/api/uploads/${a.filename}`, sourceUrl: `${localOrigin}/api/uploads/${a.filename}`,
})); }));
// Include federatedId for group DMs so the peer uses the correct
// channel lookup path instead of computing a 1-on-1 pair hash.
const syncChannel = db
.select({ federatedId: schema.dmChannels.federatedId, ownerId: schema.dmChannels.ownerId })
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, mutation.context_id))
.get();
events.push({ events.push({
eventType: mutationType, eventType: mutationType,
...(syncChannel?.federatedId && syncChannel.ownerId ? { federatedId: syncChannel.federatedId } : {}),
dmChannelId: mutation.context_id, dmChannelId: mutation.context_id,
messageId: message.id, messageId: message.id,
encryptionVersion: 0, encryptionVersion: 0,
@@ -1550,6 +1559,13 @@ function backfillHomeUserId(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
): typeof schema.users.$inferSelect { ): typeof schema.users.$inferSelect {
if (user.homeUserId === homeUserId) return user; if (user.homeUserId === homeUserId) return user;
// Only backfill if the user has no homeUserId yet. If they already have a
// DIFFERENT non-null homeUserId, this means the wrong user was matched —
// overwriting would corrupt their identity.
if (user.homeUserId) {
console.warn(`[federation] Refusing to overwrite homeUserId on user ${user.id} (${user.username}): existing=${user.homeUserId}, incoming=${homeUserId}`);
return user;
}
db.update(schema.users) db.update(schema.users)
.set({ homeUserId }) .set({ homeUserId })
.where(eq(schema.users.id, user.id)) .where(eq(schema.users.id, user.id))