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,433 @@
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { getOurOrigin, normalizeOriginForCompare } from '../../../utils/federationAuth.js';
|
||||
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import type { FederationRelayEvent } from '@backspace/shared';
|
||||
import { extractDomain, resolveLocalUser, resolveOrCreateReplicatedUser, verifyAttribution } from '../identity.js';
|
||||
import { hydrateReplicatedUserProfile } from '../profile.js';
|
||||
|
||||
export async function processFriendRequestCreateEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
accepted: string[],
|
||||
rejected: Array<{ messageId: string; reason: string }>,
|
||||
): Promise<void> {
|
||||
if (!event.friendship) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = event.friendship;
|
||||
|
||||
// Attribution: sender must belong to source instance (FED-010)
|
||||
if (!verifyAttribution(from.homeInstance, sourceInstance)) {
|
||||
console.warn(`[federation] Attribution mismatch in friend_request_create: from homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-target guard (defense-in-depth): from-identity must not equal to-identity.
|
||||
// Sender's local cannot_friend_self check should catch this, but the receiver must not trust it.
|
||||
if (
|
||||
from.homeUserId === to.homeUserId &&
|
||||
normalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance)
|
||||
) {
|
||||
console.warn(`[federation] Self-target friend_request_create rejected: homeUserId=${from.homeUserId} homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'self_target_invalid' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the sender (create stub if needed — they're on a remote instance)
|
||||
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
|
||||
if (!fromUserResolved) {
|
||||
// Sender's identity has been deleted — silently accept to drop the event
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
|
||||
|
||||
// Resolve the recipient — must be a local user on this instance
|
||||
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||
if (!toUser) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'recipient_not_found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: if already friends, accept as no-op
|
||||
const existingFriend = db
|
||||
.select()
|
||||
.from(schema.friends)
|
||||
.where(
|
||||
or(
|
||||
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingFriend) {
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: a pending request in EITHER direction makes this event a no-op.
|
||||
// Forward (from→to): re-delivery of an event we've already processed.
|
||||
// Reverse (to→from): the local user has already sent a request TO this remote sender.
|
||||
// Race window: both sides click "add friend" near-simultaneously. Each sender's both-direction
|
||||
// check passes locally (no rows yet anywhere). When the events cross, each receiver must
|
||||
// treat the reverse-direction collision as idempotent — otherwise both instances end up
|
||||
// with two opposite-direction pending rows for the same logical pair. Mirror the
|
||||
// sender-side both-direction check (`incoming_request_exists` in social.ts).
|
||||
const existingRequest = db
|
||||
.select()
|
||||
.from(schema.friendRequests)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
and(eq(schema.friendRequests.fromId, fromUser.id), eq(schema.friendRequests.toId, toUser.id)),
|
||||
and(eq(schema.friendRequests.fromId, toUser.id), eq(schema.friendRequests.toId, fromUser.id)),
|
||||
),
|
||||
eq(schema.friendRequests.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingRequest) {
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the friend request
|
||||
const id = generateSnowflake();
|
||||
const now = event.friendship.createdAt || Date.now();
|
||||
|
||||
db.insert(schema.friendRequests)
|
||||
.values({
|
||||
id,
|
||||
fromId: fromUser.id,
|
||||
toId: toUser.id,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
// Broadcast to the recipient
|
||||
connectionManager.sendToUser(toUser.id, {
|
||||
type: 'friend_request_received',
|
||||
request: {
|
||||
id,
|
||||
fromId: fromUser.id,
|
||||
toId: toUser.id,
|
||||
status: 'pending' as const,
|
||||
createdAt: now,
|
||||
user: sanitizeUser(fromUser),
|
||||
},
|
||||
});
|
||||
|
||||
accepted.push(event.messageId);
|
||||
}
|
||||
|
||||
|
||||
export function processFriendRequestUpdateEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
accepted: string[],
|
||||
rejected: Array<{ messageId: string; reason: string }>,
|
||||
): void {
|
||||
if (!event.friendship || !event.friendship.status) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, status } = event.friendship;
|
||||
|
||||
// Attribution: recipient (acceptor/decliner) must belong to source instance (FED-010)
|
||||
if (!verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||
console.warn(`[federation] Attribution mismatch in friend_request_update: to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the sender — must be a local user (the one who sent the original request)
|
||||
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||
if (!fromUser) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'sender_not_found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the recipient (create stub if needed — they're on the remote instance)
|
||||
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
|
||||
if (!toUser) {
|
||||
// Recipient's identity has been deleted — accept idempotently to drop the event
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the pending request
|
||||
const pendingRequest = db
|
||||
.select()
|
||||
.from(schema.friendRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.friendRequests.fromId, fromUser.id),
|
||||
eq(schema.friendRequests.toId, toUser.id),
|
||||
eq(schema.friendRequests.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!pendingRequest) {
|
||||
// Accept idempotently — friend_add may have arrived first
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update request status
|
||||
db.update(schema.friendRequests)
|
||||
.set({ status: status as string })
|
||||
.where(eq(schema.friendRequests.id, pendingRequest.id))
|
||||
.run();
|
||||
|
||||
if (status === 'accepted') {
|
||||
const now = event.friendship.createdAt || Date.now();
|
||||
connectionManager.sendToUser(fromUser.id, {
|
||||
type: 'friend_request_accepted',
|
||||
friend: {
|
||||
...sanitizeUser(toUser),
|
||||
addedAt: now,
|
||||
},
|
||||
requestId: pendingRequest.id,
|
||||
});
|
||||
} else if (status === 'declined') {
|
||||
connectionManager.sendToUser(fromUser.id, {
|
||||
type: 'friend_request_declined',
|
||||
requestId: pendingRequest.id,
|
||||
userId: toUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
accepted.push(event.messageId);
|
||||
}
|
||||
|
||||
|
||||
export function processFriendRequestCancelEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
accepted: string[],
|
||||
rejected: Array<{ messageId: string; reason: string }>,
|
||||
): void {
|
||||
if (!event.friendship) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = event.friendship;
|
||||
|
||||
// Attribution: sender must belong to source instance (FED-010)
|
||||
if (!verifyAttribution(from.homeInstance, sourceInstance)) {
|
||||
console.warn(`[federation] Attribution mismatch in friend_request_cancel: from homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve both users — must both exist locally for there to be a pending request
|
||||
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||
|
||||
if (!fromUser || !toUser) {
|
||||
// Accept idempotently — if either user doesn't exist, there's nothing to cancel
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the pending request
|
||||
const pendingRequest = db
|
||||
.select()
|
||||
.from(schema.friendRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.friendRequests.fromId, fromUser.id),
|
||||
eq(schema.friendRequests.toId, toUser.id),
|
||||
eq(schema.friendRequests.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!pendingRequest) {
|
||||
// Accept idempotently — already cancelled or never existed
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the request
|
||||
db.delete(schema.friendRequests)
|
||||
.where(eq(schema.friendRequests.id, pendingRequest.id))
|
||||
.run();
|
||||
|
||||
// Broadcast to the recipient
|
||||
connectionManager.sendToUser(toUser.id, {
|
||||
type: 'friend_request_cancelled',
|
||||
requestId: pendingRequest.id,
|
||||
userId: fromUser.id,
|
||||
});
|
||||
|
||||
accepted.push(event.messageId);
|
||||
}
|
||||
|
||||
|
||||
export async function processFriendAddEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
accepted: string[],
|
||||
rejected: Array<{ messageId: string; reason: string }>,
|
||||
): Promise<void> {
|
||||
if (!event.friendship) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = event.friendship;
|
||||
|
||||
// Attribution: acceptor must belong to source instance (FED-010)
|
||||
if (!verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||
console.warn(`[federation] Attribution mismatch in friend_add: to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve both users (create stubs if needed) and hydrate with profile data
|
||||
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
|
||||
if (!fromUserResolved) {
|
||||
// One party's identity is deleted — accept idempotently to drop the event
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
|
||||
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
|
||||
if (!toUserResolved) {
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
let toUser = await hydrateReplicatedUserProfile(toUserResolved, event.friendship.toProfile, db);
|
||||
|
||||
// Idempotency: if friendship already exists, accept as no-op
|
||||
const existingFriend = db
|
||||
.select()
|
||||
.from(schema.friends)
|
||||
.where(
|
||||
or(
|
||||
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingFriend) {
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert friendship row
|
||||
const now = event.friendship.createdAt || Date.now();
|
||||
db.insert(schema.friends)
|
||||
.values({
|
||||
userId: fromUser.id,
|
||||
friendId: toUser.id,
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
// Auto-resolve any pending friend request between these users to 'accepted'
|
||||
// (handles friend_add arriving before friend_request_update)
|
||||
db.update(schema.friendRequests)
|
||||
.set({ status: 'accepted' })
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
and(eq(schema.friendRequests.fromId, fromUser.id), eq(schema.friendRequests.toId, toUser.id)),
|
||||
and(eq(schema.friendRequests.fromId, toUser.id), eq(schema.friendRequests.toId, fromUser.id)),
|
||||
),
|
||||
eq(schema.friendRequests.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
// Determine which user is local and broadcast to them
|
||||
const ourOrigin = getOurOrigin();
|
||||
const localUser = from.homeInstance === ourOrigin ? fromUser : toUser;
|
||||
const remoteUser = from.homeInstance === ourOrigin ? toUser : fromUser;
|
||||
|
||||
connectionManager.sendToUser(localUser.id, {
|
||||
type: 'friend_request_accepted',
|
||||
friend: {
|
||||
...sanitizeUser(remoteUser),
|
||||
addedAt: now,
|
||||
},
|
||||
// Use empty string for requestId since the request may not exist locally yet
|
||||
requestId: '',
|
||||
});
|
||||
|
||||
accepted.push(event.messageId);
|
||||
}
|
||||
|
||||
|
||||
export function processFriendRemoveEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
accepted: string[],
|
||||
rejected: Array<{ messageId: string; reason: string }>,
|
||||
): void {
|
||||
if (!event.friendship) {
|
||||
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = event.friendship;
|
||||
|
||||
// Attribution: at least one side must belong to source instance (FED-010)
|
||||
if (!verifyAttribution(from.homeInstance, sourceInstance) && !verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||
console.warn(`[federation] Attribution mismatch in friend_remove: from homeInstance=${extractDomain(from.homeInstance)} to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve both users — must both exist locally for there to be a friendship
|
||||
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||
|
||||
if (!fromUser || !toUser) {
|
||||
// Accept idempotently — if either user doesn't exist locally, nothing to remove
|
||||
accepted.push(event.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete friendship in both directions
|
||||
db.delete(schema.friends)
|
||||
.where(
|
||||
or(
|
||||
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
// Determine which user is local (the one whose home instance is NOT the source)
|
||||
// The removing user is on the source instance; broadcast to the other user
|
||||
const ourOrigin = getOurOrigin();
|
||||
const localUser = from.homeInstance === ourOrigin ? fromUser : toUser;
|
||||
const removingUser = from.homeInstance === ourOrigin ? toUser : fromUser;
|
||||
|
||||
connectionManager.sendToUser(localUser.id, {
|
||||
type: 'friend_removed',
|
||||
userId: removingUser.id,
|
||||
});
|
||||
|
||||
accepted.push(event.messageId);
|
||||
}
|
||||
Reference in New Issue
Block a user