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:
@@ -0,0 +1,890 @@
|
||||
import path from 'node:path';
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||
import { buildFederationHeaders, generateHmacSecret, getOurOrigin } from '../../../utils/federationAuth.js';
|
||||
import { getInstanceId } from '../../../utils/federationEpoch.js';
|
||||
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
|
||||
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { resolveLocalOrigin, sanitizePeer } from '../origin.js';
|
||||
|
||||
/**
|
||||
* Queue an inbound peer/accept request for local-admin approval.
|
||||
*
|
||||
* Called from `/peer/accept` when:
|
||||
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
|
||||
* exists for the source origin (first-contact request from remote), OR
|
||||
* (b) the receiver is in `awaiting_approval` for this origin but the
|
||||
* inbound `/peer/accept` cannot be cryptographically verified
|
||||
* (token absent or mismatched) — see spec §3.5.
|
||||
*
|
||||
* Generates a fresh single-use approval token, upserts the
|
||||
* `peer_approval_requests` row, notifies admins, and returns 202 with the
|
||||
* token in the body. The initiator stores the token alongside its
|
||||
* `awaiting_approval` row so a future `/peer/accept` from this side's
|
||||
* `/approve` endpoint can verify mutual admin approval.
|
||||
*/
|
||||
export function queueApprovalRequest(
|
||||
db: ReturnType<typeof getDb>,
|
||||
reply: FastifyReply,
|
||||
sourceOrigin: string,
|
||||
hmacSecret: string,
|
||||
reqInstanceName: string | null,
|
||||
): FastifyReply {
|
||||
const now = Date.now();
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const approvalToken = randomBytes(32).toString('hex');
|
||||
|
||||
const existingRequest = db
|
||||
.select({ id: schema.peerApprovalRequests.id })
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.get();
|
||||
|
||||
if (existingRequest) {
|
||||
db.update(schema.peerApprovalRequests)
|
||||
.set({
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.peerApprovalRequests)
|
||||
.values({
|
||||
id: generateSnowflake(),
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({
|
||||
type: 'federation_approval_request_received' as const,
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName ?? undefined,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
queued: true,
|
||||
message: 'Request queued for admin approval',
|
||||
approvalToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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,
|
||||
instanceId: getInstanceId(),
|
||||
// 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 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 — same pattern as performHandshake and /peer/initiate.
|
||||
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.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, 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.
|
||||
*/
|
||||
export 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,
|
||||
instanceId: getInstanceId(),
|
||||
// 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
|
||||
// and instanceId (epoch) for the authenticated baseline.
|
||||
let remoteInstanceName: string | null = approvalReq.instanceName;
|
||||
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 — keep approvalReq.instanceName (may be null).
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
status: 'active',
|
||||
lastSeenAt: now,
|
||||
instanceName: remoteInstanceName,
|
||||
peerInstanceId: remoteInstanceId,
|
||||
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.
|
||||
*/
|
||||
export 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).
|
||||
*/
|
||||
export 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',
|
||||
});
|
||||
// Subscriber row is about to cascade-delete; refresh the user's pending list.
|
||||
connectionManager.sendToUser(sub.userId, {
|
||||
type: 'peering_subscription_changed' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 function registerApprovalRoutes(app: FastifyInstance): void {
|
||||
// ─── GET /api/federation/approval-requests ─────────────────────────────────
|
||||
app.get(
|
||||
'/api/federation/approval-requests',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (_request, reply) => {
|
||||
const db = getDb();
|
||||
const requests = db
|
||||
.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,
|
||||
})
|
||||
.from(schema.peerApprovalRequests)
|
||||
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
|
||||
.all();
|
||||
|
||||
// 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] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
|
||||
const approvalReq = db
|
||||
.select()
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.get();
|
||||
|
||||
if (!approvalReq) {
|
||||
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundApprove(approvalReq, localOrigin, reply);
|
||||
}
|
||||
|
||||
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] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
|
||||
const approvalReq = db
|
||||
.select()
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.get();
|
||||
|
||||
if (!approvalReq) {
|
||||
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundDeny(approvalReq, reply);
|
||||
}
|
||||
|
||||
return await handleInboundDeny(approvalReq, reply);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── GET /api/federation/peering-subscriptions ─────────────────────────────
|
||||
// User-facing: list the requesting user's pending outbound peering
|
||||
// subscriber rows joined to their parent peer_approval_requests. Used by the
|
||||
// pending-peering UI surface to show "you have a peering with X waiting on
|
||||
// your admin's approval" rows.
|
||||
app.get(
|
||||
'/api/federation/peering-subscriptions',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const rows = db
|
||||
.select({
|
||||
id: schema.peerApprovalSubscribers.id,
|
||||
requestId: schema.peerApprovalSubscribers.requestId,
|
||||
peerOrigin: schema.peerApprovalRequests.origin,
|
||||
peerInstanceName: schema.peerApprovalRequests.instanceName,
|
||||
triggerReason: schema.peerApprovalSubscribers.triggerReason,
|
||||
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
|
||||
createdAt: schema.peerApprovalSubscribers.createdAt,
|
||||
})
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.innerJoin(
|
||||
schema.peerApprovalRequests,
|
||||
eq(schema.peerApprovalRequests.id, schema.peerApprovalSubscribers.requestId),
|
||||
)
|
||||
.where(eq(schema.peerApprovalSubscribers.userId, userId))
|
||||
.orderBy(desc(schema.peerApprovalSubscribers.createdAt))
|
||||
.all();
|
||||
return reply.send({ subscriptions: rows });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── DELETE /api/federation/peering-subscriptions/:id ──────────────────────
|
||||
// User-facing: cancel one of the requesting user's pending peering
|
||||
// subscriptions. Authorization: subscriber.userId must match request.userId.
|
||||
// If this was the last subscriber for its parent request, the parent
|
||||
// cascade-deletes too (avoids zombie outbound rows in the admin queue).
|
||||
// No notification is created for the canceller (per spec §4.3 (iii)).
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/federation/peering-subscriptions/:id',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
const userId = request.userId;
|
||||
|
||||
const sub = db
|
||||
.select()
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.get();
|
||||
if (!sub) {
|
||||
return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 });
|
||||
}
|
||||
if (sub.userId !== userId) {
|
||||
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.delete(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.run();
|
||||
|
||||
// If the row we just removed was the last subscriber on its parent
|
||||
// peer_approval_request, cascade-delete the parent. The admin queue
|
||||
// refreshes via federation_peers_changed.
|
||||
const remaining = db
|
||||
.select({ id: schema.peerApprovalSubscribers.id })
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.requestId, sub.requestId))
|
||||
.all();
|
||||
if (remaining.length === 0) {
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, sub.requestId))
|
||||
.run();
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
}
|
||||
|
||||
connectionManager.sendToUser(userId, { type: 'peering_subscription_changed' as const });
|
||||
return reply.send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── GET /api/federation/peering-notifications ─────────────────────────────
|
||||
// User-facing: list the requesting user's terminal-state peering
|
||||
// notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1
|
||||
// filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt
|
||||
// (newest first).
|
||||
app.get<{ Querystring: { unread?: string } }>(
|
||||
'/api/federation/peering-notifications',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const unread = request.query?.unread === '1';
|
||||
|
||||
const whereClause = unread
|
||||
? and(
|
||||
eq(schema.peerApprovalNotifications.userId, userId),
|
||||
isNull(schema.peerApprovalNotifications.readAt),
|
||||
)
|
||||
: eq(schema.peerApprovalNotifications.userId, userId);
|
||||
|
||||
const notifications = db
|
||||
.select({
|
||||
id: schema.peerApprovalNotifications.id,
|
||||
kind: schema.peerApprovalNotifications.kind,
|
||||
peerOrigin: schema.peerApprovalNotifications.peerOrigin,
|
||||
triggerReason: schema.peerApprovalNotifications.triggerReason,
|
||||
triggerTarget: schema.peerApprovalNotifications.triggerTarget,
|
||||
createdAt: schema.peerApprovalNotifications.createdAt,
|
||||
readAt: schema.peerApprovalNotifications.readAt,
|
||||
})
|
||||
.from(schema.peerApprovalNotifications)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.peerApprovalNotifications.createdAt))
|
||||
.all();
|
||||
|
||||
return reply.send({ notifications });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peering-notifications/:id/read ───────────────────
|
||||
// User-facing: mark a single peering notification as read. Authorization:
|
||||
// notification.userId must match request.userId.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/peering-notifications/:id/read',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
const userId = request.userId;
|
||||
|
||||
const notif = db
|
||||
.select()
|
||||
.from(schema.peerApprovalNotifications)
|
||||
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||
.get();
|
||||
if (!notif) {
|
||||
return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 });
|
||||
}
|
||||
if (notif.userId !== userId) {
|
||||
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.update(schema.peerApprovalNotifications)
|
||||
.set({ readAt: Date.now() })
|
||||
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||
.run();
|
||||
return reply.send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peering-notifications/read-all ───────────────────
|
||||
// User-facing: mark all the requesting user's unread peering notifications
|
||||
// as read. Already-read rows are NOT touched (their readAt is preserved).
|
||||
// Returns the count of rows affected for UI feedback.
|
||||
app.post(
|
||||
'/api/federation/peering-notifications/read-all',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const result = db
|
||||
.update(schema.peerApprovalNotifications)
|
||||
.set({ readAt: Date.now() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.peerApprovalNotifications.userId, userId),
|
||||
isNull(schema.peerApprovalNotifications.readAt),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
return reply.send({ success: true, count: result.changes });
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user