feat: add peer/denied S2S endpoint and export pushPeerRejectedEvent

This commit is contained in:
Jannis Braun
2026-04-20 14:56:31 +02:00
parent 5e48d67cb0
commit b407e38730
2 changed files with 95 additions and 2 deletions
+92
View File
@@ -672,6 +672,98 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// ─── 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<string, string | string[] | undefined>);
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<string, string>();
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 ───────────────────────────────────────────── // ─── GET /api/federation/peers ─────────────────────────────────────────────
// Admin-only: list all federation peers (hmacSecret excluded). // Admin-only: list all federation peers (hmacSecret excluded).
app.get( app.get(
@@ -410,9 +410,10 @@ async function resolvePendingPeers(): Promise<void> {
* Push a federation_peer_rejected WS event to all local users affected by * Push a federation_peer_rejected WS event to all local users affected by
* the rejection. Resolves contextLabel from the database for each context. * the rejection. Resolves contextLabel from the database for each context.
*/ */
function pushPeerRejectedEvent( export function pushPeerRejectedEvent(
peerOrigin: string, peerOrigin: string,
contextMap: Map<string, string>, contextMap: Map<string, string>,
reasonOverride?: string,
): void { ): void {
const db = getDb(); const db = getDb();
@@ -481,7 +482,7 @@ function pushPeerRejectedEvent(
type: 'federation_peer_rejected' as const, type: 'federation_peer_rejected' as const,
peerOrigin, peerOrigin,
peerLabel, peerLabel,
reason: 'Remote instance requires manual peering approval', reason: reasonOverride || 'Remote instance requires manual peering approval',
affectedContexts, affectedContexts,
}; };