From 911c7e3479d19aab2e4ae2aad44927a783594461 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:27:07 +0200 Subject: [PATCH] fix(federation): ensurePeered must not auto-heal needs_attention peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered during live verification of #10b Scenario 3: the switch in ensurePeered had no case for needs_attention, so it fell through to performHandshake. Because the /peer/accept idempotent-200-no-update safeguard covers needs_attention on the inbound side, the remote returned 200 without writing the new secret, and performHandshake transitioned the local peer to 'active' on the 200 response — auto- healing a state that requires admin intervention. Affected paths: sendCallRelay non-blocking warm-up (used by typing relay); any future caller of ensurePeered on a needs_attention peer. Not affected: resolvePendingPeers (already filters on status='pending'). Fix: explicit case 'needs_attention' returning { status: 'rejected', error }. Caller observes the rejection and does not advance state. --- .../src/utils/federationPeering.test.ts | 58 ++++++++++++++++++- .../server/src/utils/federationPeering.ts | 3 + 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/federationPeering.test.ts b/packages/server/src/utils/federationPeering.test.ts index 3961dc0b..2b044795 100644 --- a/packages/server/src/utils/federationPeering.test.ts +++ b/packages/server/src/utils/federationPeering.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { EnsurePeeredResult } from './federationPeering.js'; import { racePeering } from './federationPeering.js'; @@ -112,3 +112,59 @@ describe('racePeering', () => { warnSpy.mockRestore(); }); }); + +describe('ensurePeered needs_attention handling', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns rejected without calling performHandshake when peer is in needs_attention', async () => { + const fakeDbGet = vi.fn().mockReturnValue({ + id: 'peer-na', + origin: 'https://remote.example', + status: 'needs_attention', + hmacSecret: 'secret', + createdAt: Date.now(), + lastSyncedAt: 0, + }); + + vi.doMock('../db/index.js', () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + get: fakeDbGet, + }), + }), + }), + }), + })); + + vi.doMock('../utils/federationAuth.js', () => ({ + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'new-secret', + })); + + vi.doMock('../routes/federation.js', () => ({ + validateOrigin: (o: string) => o, + })); + + vi.doMock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(), + })); + + const { ensurePeered } = await import('./federationPeering.js'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + expect(result.error).toContain('needs_attention'); + } + // performHandshake must not have fired — no POST to /peer/accept + expect(fetchSpy).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 31bb4936..fc6b9ae2 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -73,6 +73,9 @@ export async function ensurePeered(origin: string): Promise // Unreachable peers were previously active — treat as active for peering // (the health check will restore them; don't re-handshake) return { status: 'active', peerId: existing.id }; + case 'needs_attention': + // Admin intervention required — do not auto-heal via performHandshake + return { status: 'rejected', error: 'Peer in needs_attention — admin Reset required' }; case 'awaiting_approval': return { status: 'pending', error: 'Awaiting admin approval on remote instance' }; case 'pending':