From a496dc01bd605ef1f36de3fb139900946fbaf04a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:49:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20federation=20DM=20identity=20corruption?= =?UTF-8?q?=20=E2=80=94=20sync=20federatedId,=20guard=20backfill,=20remove?= =?UTF-8?q?=20bad=20merge=20criterion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/server/src/db/migrate.ts | 16 +++------------- packages/server/src/routes/federation.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 61c98162..6724799c 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -789,19 +789,9 @@ export function runMigrations(db: Database.Database): void { let reason: string | null = null; - // Criterion 1: shared 1-on-1 DM membership - if (!reason) { - const shared = db.prepare(` - 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 1 removed: "shared 1-on-1 DM membership" was wrong — + // two users from the same domain sharing a DM channel doesn't mean + // they're duplicates, it means they're having a conversation. // Criterion 2: username cross-reference if (!reason) { diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 20fde9fd..86f6473d 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1208,8 +1208,17 @@ export async function federationRoutes(app: FastifyInstance): Promise { 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({ eventType: mutationType, + ...(syncChannel?.federatedId && syncChannel.ownerId ? { federatedId: syncChannel.federatedId } : {}), dmChannelId: mutation.context_id, messageId: message.id, encryptionVersion: 0, @@ -1550,6 +1559,13 @@ function backfillHomeUserId( db: ReturnType, ): typeof schema.users.$inferSelect { 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) .set({ homeUserId }) .where(eq(schema.users.id, user.id))