feat(federation): heal on re-peer with false-positive guard

Add healResetIncarnation (federationReset.ts): fires from onPeerActivated after
an authenticated re-peer to soft-tombstone the flagged pure S2S stubs of a reset
peer's dead incarnation, clearing stale friendships/DMs so the reported bug is
fixed. Two mandatory guards: a reason gate (allow-list of 8 genuine handshake
activation reasons; excludes health_check_recovery + startup_bootstrap so their
stale baseline can never silently resolve a journal without healing) and an
epoch comparison (dead_epoch === newEpoch => false alarm, no tombstone). Uses
tombstoneUser(uid, { purgeContent: false }); real federated accounts are left
flagged + intact for Phase 2. Runs outside any transaction. Wire into
onPeerActivated before the mutation-log re-sync.
This commit is contained in:
Jannis Braun
2026-07-01 22:17:14 +02:00
parent 45e1c88bdc
commit 8ae8dcfd86
4 changed files with 290 additions and 1 deletions
@@ -161,3 +161,119 @@ describe('markPeerReset — detection-only reset routing', () => {
.where(eq(schema.users.id, 'stub-url')).get()!.federationHealPending).toBe(1);
});
});
describe('healResetIncarnation — heal after authenticated re-peer', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.clearAllMocks();
});
afterEach(() => {
sqlite.close();
});
function seedJournal(deadEpoch: string): void {
testDb.insert(schema.federationResetEvents).values({
origin: ORIGIN, deadEpoch, newEpoch: null,
detectedAt: Date.now(), resolvedAt: null,
stubCount: 1, orphanedAccountCount: 1,
}).run();
}
function flag(id: string): void {
testDb.update(schema.users).set({ federationHealPending: 1 })
.where(eq(schema.users.id, id)).run();
}
it('genuine reset: soft-tombstones flagged stubs only, leaves real accounts flagged + intact, resolves journal', async () => {
seedPeer();
seedJournal('E0');
// A local native user to be the friendship counterpart.
testDb.insert(schema.users).values({
id: 'local-1', username: 'alice', passwordHash: '$2b$10$localhash',
homeInstance: null, homeUserId: null, isDeleted: 0, createdAt: Date.now(),
}).run();
// Flagged pure S2S stub with a friendship to the local user.
seedUser('stub-1', { passwordHash: STUB });
flag('stub-1');
testDb.insert(schema.friends).values({
userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(),
}).run();
// Flagged REAL federated account (real bcrypt) — must survive untouched + still flagged.
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
flag('real-1');
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(ORIGIN, 'E1', 'initiate_accepted');
// Stub soft-tombstoned.
const stub = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get()!;
expect(stub.isDeleted).toBe(1);
expect(stub.username).toBe('!deleted:stub-1');
// Its friendship row is gone → re-adds work again.
expect(testDb.select().from(schema.friends)
.where(eq(schema.friends.userId, 'stub-1')).all()).toHaveLength(0);
// Heal flag cleared on the healed stub.
expect(stub.federationHealPending).toBe(0);
// Real account UNTOUCHED and STILL flagged (left for Phase 2 quarantine).
const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!;
expect(real.isDeleted).toBe(0);
expect(real.username).toBe('real-1@peer.example');
expect(real.federationHealPending).toBe(1);
// Journal resolved with the freshly-handshaked epoch.
const journal = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!;
expect(journal.resolvedAt).not.toBeNull();
expect(journal.newEpoch).toBe('E1');
});
it('false positive (re-peer confirmed same incarnation): NO tombstone, flags cleared, journal resolved', async () => {
seedPeer();
seedJournal('E0');
seedUser('stub-1', { passwordHash: STUB });
flag('stub-1');
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(ORIGIN, 'E0', 'accept_new'); // dead_epoch === newEpoch → false alarm
const stub = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get()!;
expect(stub.isDeleted).toBe(0); // NOT tombstoned
expect(stub.username).toBe('stub-1@peer.example');
expect(stub.federationHealPending).toBe(0); // flag cleared
const journal = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!;
expect(journal.resolvedAt).not.toBeNull();
expect(journal.newEpoch).toBe('E0');
});
it('recovery/startup flip must NOT resolve the journal or clear flags (the critical guard)', async () => {
seedPeer();
seedJournal('E0');
seedUser('stub-1', { passwordHash: STUB });
flag('stub-1');
const { healResetIncarnation } = await import('./federationReset.js');
// Both non-handshake reasons flip a peer to active with a STALE baseline
// (still E0). Without the reason gate they'd hit dead_epoch === newEpoch and
// silently resolve the journal WITHOUT healing → the bug permanently buried.
for (const reason of ['health_check_recovery', 'startup_bootstrap'] as const) {
healResetIncarnation(ORIGIN, 'E0', reason);
const journal = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!;
expect(journal.resolvedAt, `reason=${reason}`).toBeNull();
expect(journal.newEpoch, `reason=${reason}`).toBeNull();
const stub = testDb.select().from(schema.users)
.where(eq(schema.users.id, 'stub-1')).get()!;
expect(stub.federationHealPending, `reason=${reason}`).toBe(1);
expect(stub.isDeleted, `reason=${reason}`).toBe(0);
}
});
});