feat(federation): direction-branched approve and deny for outbound queue

- /approve on outbound: generates HMAC, sends /peer/accept to remote.
  200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval,
  capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean
  up peer row, leave queue for admin retry.
- /deny on outbound: fans out kind='denied' notifications, cascade-deletes
  parent + subscribers, broadcasts admin event. No remote network call.
- /approve and /deny on inbound: existing behavior preserved verbatim.
- GET /approval-requests: response includes direction; outbound rows
  carry subscribers[] (joined with users.username, possibly empty).
- Removes Task 1's temporary /deny scaffolding guard now that the
  direction-branched dispatcher handles outbound rows correctly.
- Updates docs/systems/federation.md to describe direction-branched flow.
This commit is contained in:
Jannis Braun
2026-04-26 22:01:14 +02:00
parent 7cc360eb11
commit 42355ee889
4 changed files with 1412 additions and 221 deletions
+528 -213
View File
@@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js';
import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
/** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer {
@@ -317,6 +317,483 @@ function queueApprovalRequest(
});
}
/**
* Inbound approve — admin accepts a remote instance's peering request.
* Generates fresh HMAC, sends `/peer/accept` to the remote, and on success
* activates the peer locally. Preserves the historical behavior verbatim;
* extracted from the route handler so the dispatcher can branch on direction.
*/
async function handleInboundApprove(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
localOrigin: string,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const id = approvalReq.id;
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (existingPeer && existingPeer.status === 'active') {
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
}
if (existingPeer) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
try {
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// Forward the stored token (issued in our 202 response when the
// remote first sent /peer/accept). Lets the remote verify mutual
// admin approval. Spec §3.7.
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
}),
signal: AbortSignal.timeout(10_000),
});
if (response.status === 202) {
// Remote instance also has autoAcceptPeering off — they queued our request.
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
// Delete the approval request since we already acted on it
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({
success: true,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
}
// Parse the remote's instanceName from the response body so the
// federation panel renders a friendly label. Tolerate omission and
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
let remoteInstanceName: string | null = null;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — leave null.
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(502).send({
error: `Failed to reach remote instance: ${message}`,
statusCode: 502,
});
}
}
/**
* Outbound approve — admin authorizes the local instance to peer with a
* remote that one or more of its users have requested. Generates fresh HMAC,
* sends `/peer/accept` to the remote, and:
* - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out
* approved-notifications to outbound subscribers and cascade-deletes the
* queue row. The handler MUST NOT duplicate that cleanup.
* - 202 → remote also gates; transition to `awaiting_approval`, capture
* the returned token, leave the queue row + subscribers untouched (they
* wait for the remote admin to approve and the eventual full activation
* to fan out via `onPeerActivated`).
* - 4xx/5xx/network → clean up the peer row we created; leave the queue
* row alone so the admin can retry.
*/
async function handleOutboundApprove(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
localOrigin: string,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
// Insert the peer row in 'pending' so failure paths roll back cleanly.
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
let response: Response;
try {
response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// No approvalToken — outbound rows are admin-initiated locally; we
// hold no prior token from the remote and rely on the remote's own
// autoAcceptPeering setting to decide 200 vs 202.
}),
signal: AbortSignal.timeout(10_000),
});
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(503).send({
error: `Remote instance unreachable: ${message}`,
statusCode: 503,
});
}
if (response.status === 202) {
// Remote also gates new peers. Capture the approval token they returned
// so the next inbound /peer/accept (when the remote admin approves) can
// verify mutual admin approval. The outbound queue row and its
// subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers
// wait for the eventual activation (fanout happens then).
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer with no token to capture.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.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();
return reply.code(200).send({
success: true,
peerStatus: 'awaiting_approval' as const,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
peer: peer ? sanitizePeer(peer) : undefined,
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
// Clean up the peer row we created. Leave the outbound queue row alone
// so the admin can retry without re-collecting subscribers.
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({
error: errorMessage,
statusCode: 502,
remoteStatus: response.status,
});
}
// 200 — peer activated. Capture remote's instanceName for the friendly label.
let remoteInstanceName: string | null = approvalReq.instanceName;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — keep approvalReq.instanceName (may be null).
}
db.update(schema.federationPeers)
.set({
status: 'active',
lastSeenAt: now,
instanceName: remoteInstanceName,
approvalToken: null,
})
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
// onPeerActivated runs fanoutOutboundSubscribers (Task 6) which:
// - inserts kind='approved' notifications for each subscriber,
// - sends `peering_notification_received` WS to each subscriber,
// - cascade-deletes the parent + subscriber rows.
// Do NOT duplicate any of that here — it would double-notify and corrupt
// the queue.
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({
success: true,
peerStatus: 'active' as const,
peer: peer ? sanitizePeer(peer) : undefined,
});
}
/**
* Inbound deny — admin rejects a remote instance's peering request. Fires
* the existing /peer/denied notification to the remote, marks any local
* peer row as `rejected`, and clears the queue row. Preserves historical
* behavior verbatim.
*/
async function handleInboundDeny(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const id = approvalReq.id;
// Inbound rows always carry hmacSecret (CHECK constraint enforces this).
// If it's somehow null, we cannot sign /peer/denied — surface clearly.
if (!approvalReq.hmacSecret) {
return reply.code(500).send({
error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.',
statusCode: 500,
});
}
const ourOrigin = getOurOrigin();
const denialBody = JSON.stringify({
origin: ourOrigin,
reason: 'denied_by_admin' as const,
message: 'Request denied by admin',
});
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
let notificationSent = false;
try {
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
method: 'POST',
headers,
body: denialBody,
signal: AbortSignal.timeout(10_000),
});
notificationSent = response.ok;
} catch {
// Network error
}
if (!notificationSent) {
return reply.code(502).send({
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.',
statusCode: 502,
});
}
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (!existingPeer) {
db.insert(schema.federationPeers).values({
id: generateSnowflake(),
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret: approvalReq.hmacSecret,
status: 'rejected',
createdAt: Date.now(),
}).run();
} else if (existingPeer.status !== 'active') {
db.update(schema.federationPeers)
.set({ status: 'rejected' })
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true });
}
/**
* Outbound deny — admin refuses local users' peering request. Fans out
* `kind='denied'` notifications to each subscriber and cascade-deletes the
* parent (which clears subscribers via FK cascade). No remote network call
* — outbound rows have no /peer/denied counterpart on the wire (the remote
* never knew we were considering this).
*/
async function handleOutboundDeny(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const subscribers = db
.select()
.from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id))
.all();
const now = Date.now();
for (const sub of subscribers) {
db.insert(schema.peerApprovalNotifications)
.values({
id: generateSnowflake(),
userId: sub.userId,
kind: 'denied',
peerOrigin: approvalReq.origin,
triggerReason: sub.triggerReason,
triggerTarget: sub.triggerTarget,
createdAt: now,
readAt: null,
})
.run();
connectionManager.sendToUser(sub.userId, {
type: 'peering_notification_received' as const,
kind: 'denied',
});
}
// Cascade-delete clears subscribers via onDelete: 'cascade'.
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, approvalReq.id))
.run();
// Tell admins the queue changed.
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
if (subscribers.length > 0) {
console.log(
`[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`,
);
}
return reply.code(200).send({ success: true });
}
export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
// Admin-only: start a peering handshake with a remote instance.
@@ -1175,6 +1652,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
.select({
id: schema.peerApprovalRequests.id,
origin: schema.peerApprovalRequests.origin,
direction: schema.peerApprovalRequests.direction,
instanceName: schema.peerApprovalRequests.instanceName,
requestedAt: schema.peerApprovalRequests.requestedAt,
expiresAt: schema.peerApprovalRequests.expiresAt,
@@ -1183,11 +1661,50 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
.all();
return reply.code(200).send({ requests });
// For outbound rows, fetch subscriber summaries (joined with users for username).
// Inbound rows have no subscriber concept; field is omitted in their response.
const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id);
const subscribersByRequestId = new Map<string, ApprovalRequestSubscriberSummary[]>();
if (outboundIds.length > 0) {
const rows = db
.select({
requestId: schema.peerApprovalSubscribers.requestId,
userId: schema.peerApprovalSubscribers.userId,
username: schema.users.username,
triggerReason: schema.peerApprovalSubscribers.triggerReason,
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
})
.from(schema.peerApprovalSubscribers)
.innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId))
.where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds))
.all();
for (const row of rows) {
const arr = subscribersByRequestId.get(row.requestId) ?? [];
arr.push({
userId: row.userId,
username: row.username,
triggerReason: row.triggerReason as PeeringTriggerReason,
triggerTarget: row.triggerTarget,
});
subscribersByRequestId.set(row.requestId, arr);
}
}
return reply.code(200).send({
requests: requests.map(r =>
r.direction === 'outbound'
? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] }
: r,
),
});
},
);
// ─── POST /api/federation/approval-requests/:id/approve ───────────────────
// Direction-branched: inbound rows complete the existing accept-handshake
// path (preserved verbatim); outbound rows initiate /peer/accept against
// the remote, capturing 200/202 outcomes and leaving the queue intact on
// failure so the admin can retry.
app.post<{ Params: { id: string } }>(
'/api/federation/approval-requests/:id/approve',
{ preHandler: [authenticate, requireAdmin] },
@@ -1215,158 +1732,18 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (existingPeer && existingPeer.status === 'active') {
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
if (approvalReq.direction === 'outbound') {
return await handleOutboundApprove(approvalReq, localOrigin, reply);
}
if (existingPeer) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
try {
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// Forward the stored token (issued in our 202 response when the
// remote first sent /peer/accept). Lets the remote verify mutual
// admin approval. Spec §3.7.
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
}),
signal: AbortSignal.timeout(10_000),
});
if (response.status === 202) {
// Remote instance also has autoAcceptPeering off — they queued our request.
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
// Delete the approval request since we already acted on it
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({
success: true,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
}
// Parse the remote's instanceName from the response body so the
// federation panel renders a friendly label. Tolerate omission and
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
let remoteInstanceName: string | null = null;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — leave null.
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(502).send({
error: `Failed to reach remote instance: ${message}`,
statusCode: 502,
});
}
return await handleInboundApprove(approvalReq, localOrigin, reply);
},
);
// ─── POST /api/federation/approval-requests/:id/deny ───────────────────────
// Direction-branched: inbound rows hit the remote's /peer/denied endpoint
// (existing behavior preserved); outbound rows fan out denied notifications
// to subscribers and cascade-delete the queue row.
app.post<{ Params: { id: string } }>(
'/api/federation/approval-requests/:id/deny',
{ preHandler: [authenticate, requireAdmin] },
@@ -1384,73 +1761,11 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
}
// Outbound rows have no hmac_secret and no remote /peer/denied endpoint to call.
// Direction-branched handling lands in Task 7; until then, inbound is the only path here.
if (!approvalReq.hmacSecret) {
return reply.code(400).send({
error: 'Cannot deny an outbound peering request via this endpoint yet — outbound denial handling is not implemented.',
statusCode: 400,
});
if (approvalReq.direction === 'outbound') {
return await handleOutboundDeny(approvalReq, reply);
}
const ourOrigin = getOurOrigin();
const denialBody = JSON.stringify({
origin: ourOrigin,
reason: 'denied_by_admin' as const,
message: 'Request denied by admin',
});
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
let notificationSent = false;
try {
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
method: 'POST',
headers,
body: denialBody,
signal: AbortSignal.timeout(10_000),
});
notificationSent = response.ok;
} catch {
// Network error
}
if (!notificationSent) {
return reply.code(502).send({
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.',
statusCode: 502,
});
}
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (!existingPeer) {
db.insert(schema.federationPeers).values({
id: generateSnowflake(),
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret: approvalReq.hmacSecret,
status: 'rejected',
createdAt: Date.now(),
}).run();
} else if (existingPeer.status !== 'active') {
db.update(schema.federationPeers)
.set({ status: 'rejected' })
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true });
return await handleInboundDeny(approvalReq, reply);
},
);