feat: add peer/denied S2S endpoint and export pushPeerRejectedEvent
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user