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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user