feat(federation): add friend event processors for relay receiving side

This commit is contained in:
Jannis Braun
2026-03-27 00:51:35 +01:00
parent 2a741a0dc7
commit a485bf3098
+392 -2
View File
@@ -9,10 +9,10 @@ import { config } from '../config.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js'; import { computeFederatedId, getDmParticipants, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
import { getDmMessageWithUser } from './dm.js'; import { getDmMessageWithUser } from './dm.js';
import { AVATAR_COLORS } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser } from '@backspace/shared'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationFriendshipPayload } from '@backspace/shared';
/** Fields safe to expose to admin callers (everything except hmacSecret). */ /** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer { interface SanitizedPeer {
@@ -453,6 +453,21 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
case 'ownership_transfer': case 'ownership_transfer':
processOwnershipTransferEvent(event, sourceInstance, db, accepted, rejected); processOwnershipTransferEvent(event, sourceInstance, db, accepted, rejected);
break; break;
case 'friend_request_create':
processFriendRequestCreateEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'friend_request_update':
processFriendRequestUpdateEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'friend_request_cancel':
processFriendRequestCancelEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'friend_add':
processFriendAddEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'friend_remove':
processFriendRemoveEvent(event, sourceInstance, db, accepted, rejected);
break;
default: default:
rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' }); rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' });
break; break;
@@ -1814,3 +1829,378 @@ function processOwnershipTransferEvent(
accepted.push(event.messageId); accepted.push(event.messageId);
} }
// ─── Friend Event Processors ─────────────────────────────────────────────────
function processFriendRequestCreateEvent(
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;
// Authority check: the sender's home instance must be the source
if (from.homeInstance !== sourceInstance) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
return;
}
// Resolve the sender (create stub if needed — they're on a remote instance)
const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, 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: if a pending request already exists from this sender to this recipient, accept as no-op
const existingRequest = 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 (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);
}
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;
// Authority check: the recipient's instance accepts/declines
if (to.homeInstance !== sourceInstance) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
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);
// 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);
}
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;
// Authority check: the sender cancels their own request
if (from.homeInstance !== sourceInstance) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
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);
}
function processFriendAddEvent(
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;
// Authority check: the recipient's instance creates the friendship
if (to.homeInstance !== sourceInstance) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
return;
}
// Resolve both users (create stubs if needed)
const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db);
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, 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);
}
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;
// Authority check: either side can unfriend
if (from.homeInstance !== sourceInstance && to.homeInstance !== sourceInstance) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
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);
}