refactor(server): split federation routes into cohesive modules (#9)

routes/federation.ts had grown to 7.6k lines, spanning HTTP route
registration, federated identity resolution, ~30 inbound relay event
processors, DM reconciliation, and rate-limiting internals — too large
to review or hold in context, and awkward to change safely.

Split the implementation into 18 focused modules under routes/federation/
(helpers, events/, handlers/) and keep routes/federation.ts as a thin
barrel that re-exports the public API and composes the HTTP registrars
into federationRoutes(). No import paths change anywhere else.

Pure move, no behavior change:
- 61/61 named functions byte-identical; only deltas are 2 dynamic-import
  paths adjusted for the new directory depth
- public export surface unchanged (barrel re-exports all 22 symbols)
- all 30 endpoints preserved (identical verb+path set)
- typecheck, build, and full server suite (790 tests) green

Docs: update federation.md source-file map; add split design doc.
This commit is contained in:
TheZwiss
2026-07-10 02:08:03 +02:00
committed by GitHub
parent 180228f2d1
commit 94fe73522d
21 changed files with 8063 additions and 7620 deletions
@@ -0,0 +1,777 @@
import path from 'node:path';
import { getDb, schema } from '../../../db/index.js';
import { authenticate, requireAdmin } from '../../../utils/auth.js';
import { generateHmacSecret, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
import { fetchPeerEpoch, getInstanceId } from '../../../utils/federationEpoch.js';
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
import { markPeerReset } from '../../../utils/federationReset.js';
import { generateSnowflake } from '../../../utils/snowflake.js';
import { connectionManager } from '../../../ws/handler.js';
import { and, eq, inArray, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { queueApprovalRequest } from './approvals.js';
import { resolveLocalOrigin, sanitizePeer, validateOrigin } from '../origin.js';
import { isAcceptRateLimited, isEnsureRateLimited } from '../rateLimits.js';
export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
// Admin-only: start a peering handshake with a remote instance.
app.post<{ Body: { remoteOrigin: string } }>(
'/api/federation/peer/initiate',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { remoteOrigin: rawOrigin } = request.body ?? {};
if (!rawOrigin || typeof rawOrigin !== 'string') {
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
}
const remoteOrigin = validateOrigin(rawOrigin);
if (!remoteOrigin) {
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
}
const db = getDb();
// Check if a peer already exists for this origin
const existing = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, remoteOrigin))
.get();
if (existing) {
if (existing.status === 'active') {
return reply.code(200).send({ peer: sanitizePeer(existing) });
}
if (existing.status === 'pending') {
return reply.code(409).send({
error: 'A peering handshake with this instance is already in progress',
statusCode: 409,
});
}
if (existing.status === 'awaiting_approval') {
return reply.code(409).send({
error: "A peering handshake with this instance is awaiting the remote admin's approval",
statusCode: 409,
});
}
// Every remaining terminal/parked state is safe to clear and re-initiate
// from — falling through here (rather than to the db.insert below) is what
// keeps this route from violating UNIQUE(origin) and 500-ing.
// - revoked: local admin revoked; re-initiate cleanly.
// - rejected: a prior attempt was rejected; allow the local admin's
// authenticated retry (mirrors revoked).
// - needs_attention: this IS the one-click Re-peer step (resetPeer +
// initiate). Deleting the row here is equivalent to the documented
// reset: the reset-heal snapshot lives on users.federation_heal_pending
// (not the peer row) and the federation_reset_events journal is designed
// to survive peer-row deletion (design §4.2/§6.1), and onPeerActivated
// after the fresh handshake re-triggers the heal — so no recovery state
// is lost by removing the local needs_attention peer row here.
if (
existing.status === 'revoked' ||
existing.status === 'rejected' ||
existing.status === 'needs_attention'
) {
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run();
}
}
let localOrigin: string;
try {
localOrigin = resolveLocalOrigin();
} catch {
return reply.code(500).send({
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
statusCode: 500,
});
}
// Prevent self-peering
if (localOrigin === remoteOrigin) {
return reply.code(400).send({ error: 'Cannot peer with yourself', statusCode: 400 });
}
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
// Store peer as pending
db.insert(schema.federationPeers).values({
id: peerId,
origin: remoteOrigin,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
// Initiate the server-to-server handshake
try {
const response = await fetch(`${remoteOrigin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName: db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined,
instanceId: getInstanceId(),
}),
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.
// 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();
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) {
// Read the body exactly ONCE here — response.json()/text() consumes the
// stream, so both the honest-409 branch and the generic branch below
// share this single parse (no double-read of the same Response).
const rawBody = await response.text().catch(() => '');
let parsed: { error?: string; code?: string } = {};
try { parsed = JSON.parse(rawBody) as { error?: string; code?: string }; } catch { /* non-JSON body */ }
// Responder honestly refused: it already holds peering for us and will
// not rekey (anti-hijack). Do NOT create a conflicting row — delete the
// pending row so our slot stays clean and the remote's own later Re-peer
// can land on a fresh responder slot. Surface an actionable reason.
if (response.status === 409 && parsed.code === 'PEER_EXISTS_RESET_REQUIRED') {
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
return reply.code(409).send({
error: 'The remote instance still holds stale peering for you. Ask its admin to reset (or Re-peer) their side, then try again.',
code: 'PEER_EXISTS_RESET_REQUIRED',
statusCode: 409,
});
}
const errorMessage = parsed.error || `Remote instance rejected peering (HTTP ${response.status})`;
// Clean up the pending peer
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
}
// Remote accepted — activate the peer. Parse the remote's instanceName
// and instanceId (epoch) from the response body so the federation panel
// renders a friendly label and we record the peer's authenticated
// epoch baseline. Tolerate omission and non-JSON bodies.
let remoteInstanceName: string | null = null;
let remoteInstanceId: string | null = null;
try {
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
remoteInstanceId = body.instanceId;
}
} catch {
// Non-JSON body — leave null.
}
// The responder returned 200 → it claims it adopted our secret. PROVE it
// with a signed round-trip before trusting the peering (catches BUG-1: a
// responder that reported success without adopting, and any residual
// desync). fetchPeerEpoch signs with the just-negotiated secret; a desync
// → 401/403 → null. Park the peer in needs_attention instead of falsely
// activating so the admin sees "re-peer incomplete", not a dead-active row.
const verifiedEpoch = await fetchPeerEpoch({ origin: remoteOrigin, hmacSecret });
if (!verifiedEpoch) {
db.update(schema.federationPeers)
.set({ status: 'needs_attention', needsAttentionReason: 'repeer_incomplete', lastSeenAt: Date.now() })
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
const parked = db.select().from(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).get();
return reply.code(200).send({ peer: parked ? sanitizePeer(parked) : null, verified: false });
}
db.update(schema.federationPeers)
// The baseline is trust-consequential (design §9 — a poisoned baseline can drive
// a spurious heal), so store the epoch we cryptographically verified via the signed
// /epoch round-trip, not the unverified handshake-response body. They are normally
// identical; the verified one is authoritative if they ever differ.
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, peerInstanceId: verifiedEpoch, needsAttentionReason: null, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'initiate_accepted').catch(err =>
console.error('[federation] onPeerActivated from /peer/initiate failed:', err)
);
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 activation', statusCode: 500 });
}
return reply.code(200).send({ peer: sanitizePeer(peer), verified: true });
} catch (err: unknown) {
// Clean up the pending peer on network/timeout errors
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,
});
}
},
);
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
// Server-to-server: accept a peering request from a remote instance.
// No JWT auth — this is first contact. Rate-limited by IP.
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; instanceId?: string; approvalToken?: string } }>(
'/api/federation/peer/accept',
async (request, reply) => {
const clientIp = request.ip;
if (isAcceptRateLimited(clientIp)) {
return reply.code(429).send({
error: 'Too many peering requests — try again later',
statusCode: 429,
});
}
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, instanceId: reqInstanceId, approvalToken: inboundToken } = request.body ?? {};
if (!rawOrigin || typeof rawOrigin !== 'string') {
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
}
if (!hmacSecret || typeof hmacSecret !== 'string') {
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
}
const sourceOrigin = validateOrigin(rawOrigin);
if (!sourceOrigin) {
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
}
const db = getDb();
const settings = db
.select({
instanceName: schema.instanceSettings.instanceName,
autoAcceptPeering: schema.instanceSettings.autoAcceptPeering,
})
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get();
const ourInstanceName = settings?.instanceName ?? null;
const ourInstanceId = getInstanceId();
const autoAccept = settings?.autoAcceptPeering ?? 1;
// ── autoAcceptPeering gate ──────────────────────────────────────────
// When auto-accept is disabled, only allow incoming accept requests
// that correspond to a local pending peer (i.e., a local admin
// initiated the handshake). Unsolicited requests are rejected.
if (autoAccept === 0) {
// Check if the local admin already initiated or approved peering with this origin.
// 'pending' = admin used peer/initiate (handshake in progress)
// 'awaiting_approval' = admin approved an earlier request, handshake was sent,
// remote queued it (202). Now the remote admin approved too and is handshaking
// back to us. We should accept — both admins have approved.
const localPending = db
.select({ id: schema.federationPeers.id })
.from(schema.federationPeers)
.where(
and(
eq(schema.federationPeers.origin, sourceOrigin),
inArray(schema.federationPeers.status, ['pending', 'awaiting_approval']),
),
)
.get();
if (!localPending) {
// Check if this origin is blocked (previously denied)
const blockedPeer = db
.select({ id: schema.federationPeers.id })
.from(schema.federationPeers)
.where(
and(
eq(schema.federationPeers.origin, sourceOrigin),
eq(schema.federationPeers.status, 'rejected'),
),
)
.get();
if (blockedPeer) {
return reply.code(403).send({
error: 'This instance requires manual peering approval',
code: 'PEERING_REQUIRES_APPROVAL',
statusCode: 403,
});
}
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
}
// Check if peer already exists
const existing = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, sourceOrigin))
.get();
if (existing) {
if (existing.status === 'active' || existing.status === 'needs_attention') {
// Idempotent — already peered (or peering is in needs_attention state).
// In both cases we refuse to overwrite hmac_secret via this
// unauthenticated endpoint. An unauthenticated caller cannot
// prove prior trust, and needs_attention means "we don't know
// why this broke" — letting an unauthenticated request flip it
// to active with a new secret defeats the purpose.
//
// Legitimate recovery path: local admin clicks "Reset peering" →
// row is deleted → remote's /peer/accept then lands on a
// non-existent row and the normal handshake path runs.
//
// Detection-only: if the inbound epoch differs from our trusted
// baseline, the peer is a NEW incarnation on the same domain (a
// wipe-and-reinstall). Route it to needs_attention + snapshot +
// journal — but STILL return 409 (PEER_EXISTS_RESET_REQUIRED) and
// STILL do not rekey. The anti-hijack guard above is preserved
// verbatim; detection never grants capability.
if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) {
markPeerReset(existing.id, sourceOrigin, existing.peerInstanceId, reqInstanceId);
}
// Anti-hijack: we did NOT adopt the caller's secret. Report that
// honestly (409) instead of a false success (was 200 {accepted:true}),
// so the initiator does not false-activate into a permanent HMAC
// desync. Legacy initiators read only response.ok → they fail loudly
// (never a silent desync); new initiators special-case this code.
return reply.code(409).send({
accepted: false,
code: 'PEER_EXISTS_RESET_REQUIRED',
error: 'This instance already holds peering for you; its admin must reset that peering before a new handshake can be accepted.',
instanceName: ourInstanceName,
instanceId: ourInstanceId,
statusCode: 409,
});
}
if (existing.status === 'revoked') {
return reply.code(403).send({
error: 'Peering with this instance has been revoked',
statusCode: 403,
});
}
if (existing.status === 'rejected') {
// A remote admin manually initiated peering with us after we
// previously auto-rejected them. Override rejected → active.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
peerInstanceId: reqInstanceId ?? null,
status: 'active',
lastSeenAt: Date.now(),
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Broadcast activation to all connected local users
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_rejected_override').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (rejected override) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
}
if (existing.status === 'awaiting_approval') {
// Spec §3.5: token verification gates the awaiting_approval → active
// promotion. Without proof the inbound came from the remote's
// /approve endpoint, an adversarial timing-knowledge attack or a
// bug-prone background code path could falsely flip this row to
// active. The token is single-use entropy issued in the 202 we
// returned when the remote's outbound /peer/accept first hit our
// queue — only their /approve endpoint forwards it.
const tokenValid =
typeof existing.approvalToken === 'string' &&
existing.approvalToken.length > 0 &&
existing.approvalToken === inboundToken;
if (tokenValid) {
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
peerInstanceId: reqInstanceId ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Clean up any stale approval-request row for this origin (e.g.,
// queued debris from a prior bypass attempt that did not promote).
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.run();
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
}
// Token absent or mismatched. Cannot prove mutual approval.
if (autoAccept === 1) {
// We accept any inbound anyway — promoting here is no weaker than
// accepting a fresh handshake from a new peer. Clear the stored
// token (moot now) and proceed.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
peerInstanceId: reqInstanceId ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
}
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
// Existing awaiting_approval row stays untouched; the new approval-
// request lets the local admin decide whether to honor this inbound.
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
// Pending — update with new secret and activate
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
peerInstanceId: reqInstanceId ?? null,
status: 'active',
lastSeenAt: Date.now(),
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_pending').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (pending) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
}
// New peer — create and activate
const peerId = generateSnowflake();
db.insert(schema.federationPeers).values({
id: peerId,
origin: sourceOrigin,
hmacSecret,
instanceName: reqInstanceName ?? null,
peerInstanceId: reqInstanceId ?? null,
status: 'active',
lastSeenAt: Date.now(),
createdAt: Date.now(),
}).run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'accept_new').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (new) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
},
);
// ─── POST /api/federation/peer/ensure ──────────────────────────────────────
// JWT-authenticated (any user): trigger auto-peering with a remote instance.
// Rate-limited per user (3 requests per 15 minutes).
app.post<{ Body: { remoteOrigin: string } }>(
'/api/federation/peer/ensure',
{ preHandler: [authenticate] },
async (request, reply) => {
const { remoteOrigin: rawOrigin } = request.body ?? {};
if (!rawOrigin || typeof rawOrigin !== 'string') {
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
}
const remoteOrigin = validateOrigin(rawOrigin);
if (!remoteOrigin) {
return reply.code(400).send({
error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)',
statusCode: 400,
});
}
if (isEnsureRateLimited(request.userId)) {
return reply.code(429).send({
error: 'Too many peering requests — try again later',
statusCode: 429,
});
}
const { ensurePeered } = await import('../../../utils/federationPeering.js');
// NOTE: /peer/ensure is currently only invoked from friend-add client paths
// (see packages/web/src/stores/instanceStore.ts ensurePeered references).
// The hardcoded reason here is correct TODAY but will become wrong when
// DM-to-stranger or space-join grow into the gate. When that happens,
// surface the reason and target through the request body instead. Do NOT
// silently leave the hardcoding in place when adding a new caller.
const result = await ensurePeered(remoteOrigin, {
kind: 'user_action',
userId: request.userId,
reason: 'friend_add',
target: remoteOrigin,
});
// NOTE: The internal EnsurePeeredResult status names differ from the client-facing
// peeringStatus values. The mapping:
// 'active' → 'active' (peer is live)
// 'rejected' → 'rejected' (permanently blocked)
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
// 'failed' → 'pending' (transient error, will retry automatically)
// 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve)
// The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet",
// while 'failed' means "network/timeout — the outbox worker will retry next tick".
// The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve).
switch (result.status) {
case 'active':
return reply.code(200).send({ peeringStatus: 'active', peerId: result.peerId });
case 'rejected':
return reply.code(200).send({ peeringStatus: 'rejected', error: result.error });
case 'pending':
return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error });
case 'failed':
return reply.code(200).send({ peeringStatus: 'pending', error: result.error });
case 'admin_required':
return reply.code(200).send({ peeringStatus: 'admin_required' });
default:
return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' });
}
},
);
// ─── POST /api/federation/peer/rotate ───────────────────────────────────────
// Server-to-server: accept a secret rotation request from a peer instance.
// Authenticated via HMAC-SHA256 signature (current secret), NOT JWT.
app.post<{ Body: { newSecret: string } }>(
'/api/federation/peer/rotate',
async (request, reply) => {
const db = getDb();
// 1. 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 peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
.get();
if (!peer || peer.status !== 'active') {
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
}
const bodyString = JSON.stringify(request.body);
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
}
// 2. Validate request body
const { newSecret } = request.body ?? {};
if (!newSecret || typeof newSecret !== 'string' || newSecret.length !== 64 || !/^[0-9a-f]+$/.test(newSecret)) {
return reply.code(400).send({ error: 'newSecret must be a 64-character hex string', statusCode: 400 });
}
// 3. Reject if rotation already in progress
if (peer.pendingHmacSecret) {
return reply.code(409).send({
error: 'A secret rotation is already in progress — wait for it to complete',
statusCode: 409,
});
}
// 4. Store pending secret and activate grace period
db.update(schema.federationPeers)
.set({
pendingHmacSecret: newSecret,
secretRotationAt: Date.now(),
})
.where(eq(schema.federationPeers.id, peer.id))
.run();
console.log(`[federation] Secret rotation accepted from peer ${peer.origin}`);
return reply.code(200).send({ accepted: true, gracePeriodMs: 900_000 });
},
);
// ─── 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();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
// 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 });
},
);
}