From 531104fecc990cd8a9e47375092c60d3e9f46cf0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:41:44 +0200 Subject: [PATCH] fix(federation): handle 202 in admin /peer/initiate handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /peer/initiate checked `response.ok` to decide whether to activate the local peer. `response.ok` is true for the full 2xx range, so a remote that returned 202 (queued for admin approval — autoAcceptPeering off on their side) caused the local peer to flip to `active` while the remote had us `awaiting_approval`. The split only self-healed when the remote admin approved and pushed us an `awaiting_approval → active` override via the peer_approval_requests inbound path. The auto-peer flow in federationPeering.ts:performHandshake already had the correct 202 branch: set local status to awaiting_approval, broadcast federation_peers_changed, surface a pending outcome. Mirror it here: - Check response.status === 202 BEFORE the !response.ok branch so the fall-through can't reach the activation code. - Transition local peer to awaiting_approval (not active). - Broadcast federation_peers_changed so other admin tabs refresh. - Return 202 with the sanitized peer so the client observes the queued state distinctly from both success and failure. Also added the missing federation_peers_changed broadcast on the activation (200) path for parity with every other peer-state-change site in the codebase — it was a pre-existing drift that would leave sibling admin tabs stale after an initiate. Pattern-aligned with federationPeering.ts:160 and the rest of routes/federation.ts. Docs: expanded Phase 1 bullets in docs/systems/federation.md to cover the 200 / 202 / other non-2xx / network-error branches explicitly and reference the mirrored auto-peer branch. Verified: pnpm -r typecheck clean (shared + server), vitest 70/70 pass. Closes #21 from S2S DM unification backlog. --- docs/systems/federation.md | 6 +++-- packages/server/src/routes/federation.ts | 29 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 7cac2f84..7a3a6ea7 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -43,8 +43,10 @@ Backspace federation is peer-to-peer with no central authority. Each instance ma - Creates local peer record with `status='pending'` - POSTs to `{remoteOrigin}/api/federation/peer/accept` with `{ sourceOrigin, challenge, hmacSecret }` - Timeout: 10 seconds (`AbortSignal.timeout`) -- On remote acceptance: updates local peer to `status='active'`, sets `lastSeenAt` -- On failure: deletes pending peer, returns 502 (network error) or 504 (timeout) +- On remote 200 (accepted): updates local peer to `status='active'`, sets `lastSeenAt`, broadcasts `federation_peers_changed` to admin WS subscribers, returns 200 with `{ peer }` +- On remote 202 (queued for remote admin approval): transitions local peer to `status='awaiting_approval'` (does **not** activate), broadcasts `federation_peers_changed`, returns 202 with `{ peer }`. Without this branch `response.ok` would be true and the local peer would flip to `active` while the remote had us pending — a transient local-active / remote-pending split that only self-healed when the remote admin approved. Mirrors the auto-peer 202 branch in `federationPeering.ts:performHandshake`. +- On remote 403 / other non-2xx: deletes pending peer, returns 502 with the remote's error message +- On network error / timeout: deletes pending peer, returns 502 (network) or 504 (timeout) **Phase 2 -- Accept** (`POST /api/federation/peer/accept`) - Auth: **none** (first contact -- no JWT, no HMAC) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 12948082..4263f95d 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -304,6 +304,34 @@ export async function federationRoutes(app: FastifyInstance): Promise { signal: AbortSignal.timeout(10_000), }); + if (response.status === 202) { + // Remote instance queued our request for admin approval + // (autoAcceptPeering is off on their side). Do NOT activate the + // local peer — mirror the auto-peer flow in federationPeering.ts + // by transitioning the pending record to awaiting_approval. + // Without this branch the local peer would flip to `active` + // (because response.ok is true for 202) while the remote had us + // pending, producing a local-active / remote-pending split that + // only self-heals when the remote admin approves. + db.update(schema.federationPeers) + .set({ status: 'awaiting_approval' }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + if (!peer) { + return reply.code(500).send({ error: 'Failed to read peer after queuing', statusCode: 500 }); + } + + return reply.code(202).send({ peer: sanitizePeer(peer) }); + } + if (!response.ok) { let errorMessage = `Remote instance rejected peering (HTTP ${response.status})`; try { @@ -325,6 +353,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ status: 'active', lastSeenAt: Date.now() }) .where(eq(schema.federationPeers.id, peerId)) .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); const peer = db .select()