diff --git a/packages/server/src/routes/federation.reattach.test.ts b/packages/server/src/routes/federation.reattach.test.ts index 2f96b166..7140e1f3 100644 --- a/packages/server/src/routes/federation.reattach.test.ts +++ b/packages/server/src/routes/federation.reattach.test.ts @@ -9,6 +9,7 @@ import { eq } from 'drizzle-orm'; import * as schema from '../db/schema.js'; import { setWorkerId } from '../utils/snowflake.js'; import { signJwt } from '../utils/auth.js'; +import { computeFederatedId } from '../utils/federationOutbox.js'; setWorkerId(13); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -268,3 +269,56 @@ describe('POST /api/users/@me/reattach — stub merge', () => { expect(row.homeUserId).toBe('dead-home-1'); }); }); + +describe('POST /api/users/@me/reattach — 1-on-1 DM channel reconciliation', () => { + beforeEach(() => { + verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' }); + profileMock.mockResolvedValue(null); + // 'alice' is R-native; she has a DM with the detached account under the OLD + // pairing, and a fresh DM under the NEW pairing (created by post-reset relay). + }); + + it('merges the pre-reattach history channel into the new-identity channel', async () => { + const oldFed = computeFederatedId('alice', 'dead-home-1'); // alice home = her id (native) + const newFed = computeFederatedId('alice', 'new-home-1'); + // history channel (old id) + testDb.insert(schema.dmChannels).values({ id: 'ch-old', federatedId: oldFed, createdAt: 1 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'ch-old', userId: 'alice', closed: 0 }, + { dmChannelId: 'ch-old', userId: 'detached-1', closed: 0 }, + ]).run(); + testDb.insert(schema.dmMessages).values([ + { id: 'mo1', dmChannelId: 'ch-old', userId: 'alice', content: 'old1', createdAt: 100 }, + { id: 'mo2', dmChannelId: 'ch-old', userId: 'detached-1', content: 'old2', createdAt: 110 }, + ]).run(); + // fresh channel (new id) + testDb.insert(schema.dmChannels).values({ id: 'ch-new', federatedId: newFed, createdAt: 2 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'ch-new', userId: 'alice', closed: 0 }, + { dmChannelId: 'ch-new', userId: 'detached-1', closed: 0 }, + ]).run(); + testDb.insert(schema.dmMessages).values({ id: 'mn1', dmChannelId: 'ch-new', userId: 'detached-1', content: 'new1', createdAt: 200 }).run(); + + const res = await reattach('detached-1', 'youruser@orbit.test'); + expect(res.statusCode).toBe(200); + // old channel gone; all history now under ch-new, in order. + expect(testDb.select().from(schema.dmChannels).all().some(c => c.id === 'ch-old')).toBe(false); + const msgs = testDb.select().from(schema.dmMessages).all().filter(m => m.dmChannelId === 'ch-new').sort((a, b) => a.createdAt - b.createdAt); + expect(msgs.map(m => m.id)).toEqual(['mo1', 'mo2', 'mn1']); + }); + + it('re-keys the history channel in place when no new-identity channel exists yet', async () => { + const oldFed = computeFederatedId('alice', 'dead-home-1'); + testDb.insert(schema.dmChannels).values({ id: 'ch-old', federatedId: oldFed, createdAt: 1 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'ch-old', userId: 'alice', closed: 0 }, + { dmChannelId: 'ch-old', userId: 'detached-1', closed: 0 }, + ]).run(); + testDb.insert(schema.dmMessages).values({ id: 'mo1', dmChannelId: 'ch-old', userId: 'alice', content: 'x', createdAt: 100 }).run(); + + const res = await reattach('detached-1', 'youruser@orbit.test'); + expect(res.statusCode).toBe(200); + const ch = testDb.select().from(schema.dmChannels).all().find(c => c.id === 'ch-old')!; + expect(ch.federatedId).toBe(computeFederatedId('alice', 'new-home-1')); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 382ae82f..c85f43c4 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2948,6 +2948,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // rows that would collide on a composite PK / unique index BEFORE repointing // (spec §3.3). The stub row is the only source — a real account holding the // identity was already rejected by guard 4. + const dmReconcileResults: DmReconcileResult[] = []; rawDb.transaction(() => { if (existingRow) { const stubId = existingRow.id; @@ -3001,6 +3002,25 @@ export async function federationRoutes(app: FastifyInstance): Promise { // only fire on federation_home_orphaned = 1). rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`) .run(verified.homeUserId, newUsername, detached.id); + + // Reconcile the account's 1-on-1 DM channels: the home_user_id just + // changed, so every 1-on-1 federatedId derived from it is now stale. + // Re-key or merge each into its new-identity channel so history stays a + // single conversation (reattach-dm-reconcile spec §3.2). Group DMs (UUID + // federatedId / != 2 members) are skipped by the helper. + const oneOnOne = rawDb.prepare(` + SELECT c.id FROM dm_channels c + WHERE c.deleted_at IS NULL + AND c.federated_id IS NOT NULL + AND EXISTS (SELECT 1 FROM dm_members m WHERE m.dm_channel_id = c.id AND m.user_id = ?) + AND (SELECT count(*) FROM dm_members m2 WHERE m2.dm_channel_id = c.id) = 2 + `).all(detached.id) as Array<{ id: string }>; + for (const c of oneOnOne) { + // A merge earlier in this loop may have deleted this id — reconcile + // returns noop for a missing/mutated channel, so the loop is convergent. + const result = reconcileDmChannelFederatedId(rawDb, c.id); + if (result.action !== 'noop') dmReconcileResults.push(result); + } })(); // Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the @@ -3036,6 +3056,27 @@ export async function federationRoutes(app: FastifyInstance): Promise { connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) }); } + // Push DM-list refresh for reconciled channels to affected local members so + // the merged/re-keyed conversation replaces the split without a reload + // (reattach-dm-reconcile spec §3.4). Reuses existing events, no new type: + // - merged: dm_channel_closed removes the stale source entry; dm_channel_created + // (full DmChannel payload — the client handler reads dmChannel.members) resurfaces + // the surviving target with its merged history. + // - rekeyed: dm_channel_created upserts the channel by id (spaceStore.addDmChannel + // replaces by id), refreshing the now-stale federatedId in place. dm_channel_updated + // would only patch name/icon, not federatedId, so it cannot heal the client here. + for (const r of dmReconcileResults) { + const targetPayload = buildDmChannelPayload(r.targetChannelId, db); + for (const uid of r.affectedUserIds) { + if (r.action === 'merged') { + connectionManager.sendToUser(uid, { type: 'dm_channel_closed' as const, dmChannelId: r.channelId }); + } + if (targetPayload) { + connectionManager.sendToUser(uid, { type: 'dm_channel_created' as const, dmChannel: targetPayload }); + } + } + } + return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) }); });