feat: add processProfileUpdateEvent S2S relay processor

This commit is contained in:
Jannis Braun
2026-04-07 13:54:43 +02:00
parent 34902fe320
commit c229b32771
+89 -2
View File
@@ -10,10 +10,10 @@ import { connectionManager } from '../ws/handler.js';
import type { FederatedCallEntry, DmRoomMeta } from '../ws/handler.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { tombstoneUser, collectDeletionBroadcastTargets } from '../utils/userDeletion.js';
import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest } from '@backspace/shared';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload } from '@backspace/shared';
/** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer {
@@ -1329,6 +1329,9 @@ export function processRelayEvents(
case 'dm_typing_stop':
processDmTypingStopEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'profile_update':
processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected);
break;
default:
rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' });
break;
@@ -3680,6 +3683,90 @@ function processDmTypingStopEvent(
accepted.push(event.messageId);
}
function processProfileUpdateEvent(
event: FederationRelayEvent,
sourceInstance: string,
db: ReturnType<typeof getDb>,
accepted: string[],
rejected: Array<{ messageId: string; reason: string }>,
): void {
const payload = event.profileUpdate;
if (!payload) {
rejected.push({ messageId: event.messageId, reason: 'missing_profile_update_payload' });
return;
}
// Strict attribution: profile updates MUST originate from the home instance.
// No homeward relay exception — unlike DMs, profile updates always come from home.
const payloadDomain = extractDomain(payload.homeInstance);
const sourceDomain = extractDomain(sourceInstance);
if (payloadDomain !== sourceDomain) {
console.warn(`[federation] Attribution mismatch in profile_update: homeInstance=${payloadDomain} source=${sourceDomain}`);
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
return;
}
// Look up the local replicated user by canonical identity
const localUser = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.homeUserId, payload.homeUserId),
eq(schema.users.isDeleted, 0),
),
)
.get();
if (!localUser) {
// This peer has no replica of this user — silently accept
accepted.push(event.messageId);
return;
}
// Verify the homeInstance domain matches (guard against homeUserId collisions)
if (localUser.homeInstance && extractDomain(localUser.homeInstance) !== payloadDomain) {
accepted.push(event.messageId);
return;
}
// Version check: reject stale/duplicate events
const storedTs = localUser.profileUpdatedAt ?? 0;
const incomingTs = payload.profileUpdatedAt ?? 0;
if (incomingTs <= storedTs) {
accepted.push(event.messageId);
return;
}
// Authoritative overwrite — home instance is always right
db.update(schema.users)
.set({
displayName: payload.displayName,
avatar: payload.avatar,
banner: payload.banner,
accentColor: payload.accentColor,
avatarColor: payload.avatarColor,
bio: payload.bio,
profileUpdatedAt: payload.profileUpdatedAt,
})
.where(eq(schema.users.id, localUser.id))
.run();
// Broadcast user_updated to local clients
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, localUser.id)).get();
if (updatedUser) {
const sanitized = sanitizeUser(updatedUser, false);
const targetUserIds = collectProfileBroadcastTargetIds(localUser.id);
targetUserIds.add(localUser.id); // Include self (other tabs/connections)
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
for (const uid of targetUserIds) {
connectionManager.sendToUser(uid, userUpdatedEvent);
}
}
accepted.push(event.messageId);
}
/**
* Fan out a call event to all remote instances with DM members,
* optionally excluding the instance that triggered the event.