fix(federation): handle 202 in admin /peer/initiate handshake

/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.
This commit is contained in:
Jannis Braun
2026-04-21 22:41:44 +02:00
parent 43f0c40685
commit 531104fecc
2 changed files with 33 additions and 2 deletions
+4 -2
View File
@@ -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)
+29
View File
@@ -304,6 +304,34 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
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<void> {
.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()