diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 55b7bcab..8b663a55 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -672,6 +672,98 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/peer/denied ───────────────────────────────────── + // Server-to-server: receive a denial notification from a remote instance. + // Authenticated via HMAC-SHA256 signature (the secret we sent in our original + // peer/accept request, which the remote stored in their approval queue). + app.post<{ Body: { origin: string; reason: 'denied_by_admin' | 'expired'; message?: string } }>( + '/api/federation/peer/denied', + async (request, reply) => { + const db = getDb(); + + // Verify HMAC signature + const fedHeaders = parseFederationHeaders(request.headers as Record); + if (!fedHeaders) { + return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); + } + + const { origin: senderOrigin, signature, timestamp, nonce } = fedHeaders; + + // Find the local peer for this origin + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, senderOrigin)) + .get(); + + if (!peer) { + return reply.code(404).send({ error: 'No peer record for this origin', statusCode: 404 }); + } + + // Only accept denial for awaiting_approval peers + if (peer.status !== 'awaiting_approval') { + return reply.code(409).send({ + error: `Peer is in '${peer.status}' state, not awaiting_approval`, + statusCode: 409, + }); + } + + // Verify signature using our stored hmacSecret (the one we sent in the original request) + const rawBody = JSON.stringify(request.body); + const isValid = verifyPeerSignature(rawBody, signature, timestamp, nonce, { + hmacSecret: peer.hmacSecret, + pendingHmacSecret: null, + secretRotationAt: null, + }); + + if (!isValid) { + return reply.code(401).send({ error: 'Invalid HMAC signature', statusCode: 401 }); + } + + const { reason, message } = request.body; + + // Transition to rejected + db.update(schema.federationPeers) + .set({ status: 'rejected' }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + + // Push federation_peer_rejected WS event to affected users + const entries = db + .select({ + contextId: schema.federationOutbox.contextId, + contextType: schema.federationOutbox.contextType, + }) + .from(schema.federationOutbox) + .where(eq(schema.federationOutbox.peerId, peer.id)) + .all(); + + const contextMap = new Map(); + for (const entry of entries) { + contextMap.set(entry.contextId, entry.contextType); + } + + // Purge outbox entries + db.delete(schema.federationOutbox) + .where(eq(schema.federationOutbox.peerId, peer.id)) + .run(); + + // Build and send WS event + if (contextMap.size > 0) { + const { pushPeerRejectedEvent } = await import('../utils/federationWorker.js'); + pushPeerRejectedEvent( + senderOrigin, + contextMap, + message || (reason === 'expired' + ? 'Request expired — no response from admin within 30 days' + : 'Request denied by admin'), + ); + } + + return reply.code(200).send({ acknowledged: true }); + }, + ); + // ─── GET /api/federation/peers ───────────────────────────────────────────── // Admin-only: list all federation peers (hmacSecret excluded). app.get( diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 31e9dedb..8c62223b 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -410,9 +410,10 @@ async function resolvePendingPeers(): Promise { * Push a federation_peer_rejected WS event to all local users affected by * the rejection. Resolves contextLabel from the database for each context. */ -function pushPeerRejectedEvent( +export function pushPeerRejectedEvent( peerOrigin: string, contextMap: Map, + reasonOverride?: string, ): void { const db = getDb(); @@ -481,7 +482,7 @@ function pushPeerRejectedEvent( type: 'federation_peer_rejected' as const, peerOrigin, peerLabel, - reason: 'Remote instance requires manual peering approval', + reason: reasonOverride || 'Remote instance requires manual peering approval', affectedContexts, };