From d03f8e4f775bda93dd61df60e9ae2dd3ea6b13e0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:50:08 +0200 Subject: [PATCH] fix(federation): heal path broadcasts user_updated so tombstoned-stub DMs update live (S4) --- .../server/src/utils/federationReset.test.ts | 27 +++++++++++++++++++ packages/server/src/utils/federationReset.ts | 15 ++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/federationReset.test.ts b/packages/server/src/utils/federationReset.test.ts index a8139c52..32e156bf 100644 --- a/packages/server/src/utils/federationReset.test.ts +++ b/packages/server/src/utils/federationReset.test.ts @@ -279,6 +279,33 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { expect(stub.isDeleted, `reason=${reason}`).toBe(0); } }); + + it('broadcasts user_updated to the survivor of a tombstoned stub 1-on-1 DM', async () => { + seedPeer(); + seedJournal('E0'); // deadEpoch E0 (differs from the E1 we heal with) + seedUser('stub-1', { passwordHash: STUB }); // pure S2S stub on ORIGIN + flag('stub-1'); // federation_heal_pending = 1 + + // Survivor (local native user) + 1-on-1 DM with the flagged stub. + testDb.insert(schema.users).values({ + id: 'survivor', username: 'survivor', passwordHash: '$2b$10$localhash', + homeInstance: null, homeUserId: null, isDeleted: 0, createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmChannels).values({ id: 'dm_heal', ownerId: null, createdAt: Date.now() }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: 'dm_heal', userId: 'stub-1', closed: 0 }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: 'dm_heal', userId: 'survivor', closed: 0 }).run(); + + const { connectionManager } = await import('../ws/handler.js'); + const sendToUser = connectionManager.sendToUser as ReturnType; + sendToUser.mockClear(); + + const { healResetIncarnation } = await import('./federationReset.js'); + healResetIncarnation(ORIGIN, 'E1', 'initiate_accepted'); // genuine reset (E0 != E1) → tombstones stub-1 + + const call = sendToUser.mock.calls.find(([uid, ev]) => uid === 'survivor' && ev?.type === 'user_updated'); + expect(call).toBeTruthy(); + expect(call![1].user).toMatchObject({ id: 'stub-1', isDeleted: true, username: 'Deleted User' }); + }); }); describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { diff --git a/packages/server/src/utils/federationReset.ts b/packages/server/src/utils/federationReset.ts index 899c144d..eca9b914 100644 --- a/packages/server/src/utils/federationReset.ts +++ b/packages/server/src/utils/federationReset.ts @@ -2,7 +2,8 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { extractDomain } from '../routes/federation.js'; import { connectionManager } from '../ws/handler.js'; -import { tombstoneUser } from './userDeletion.js'; +import { tombstoneUser, collectDeletionBroadcastTargets } from './userDeletion.js'; +import { sanitizeUser } from './sanitize.js'; import type { PeerActivationReason } from './federationPeerActivation.js'; /** Pure-stub sentinel: a user replicated purely over S2S (no local credentials). */ @@ -264,7 +265,19 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P // §1 invariant that a remote's reset never destroys our non-re-syncable // content. Each call opens its own transaction, so this loop stays UNWRAPPED. for (const stub of stubs) { + // Collect co-members BEFORE tombstoning — it deletes the DM/friend/space + // rows this set is derived from, so reading them after would return empty + // and the survivors' clients would never learn the stub became a + // "Deleted User". Mirrors admin.ts / users.ts / federation.ts identity-delete. + const targets = collectDeletionBroadcastTargets(stub.id).targetUserIds; tombstoneUser(stub.id, { purgeContent: false }); + // Re-read the now-tombstoned row and broadcast the sanitized (anonymized) + // profile so every surviving co-member updates live — no reload required. + const deletedRow = db.select().from(schema.users).where(eq(schema.users.id, stub.id)).get(); + if (deletedRow) { + const event = { type: 'user_updated' as const, user: sanitizeUser(deletedRow) }; + for (const targetId of targets) connectionManager.sendToUser(targetId, event); + } } // Clear the heal flag on exactly the stubs we healed, keyed by id.