feat(federation): re-attach reconciles the account's 1-on-1 DM federatedIds inline (reattach-dm-reconcile spec §3.2)
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2948,6 +2948,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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) });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user