diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 0fd73f63..a5a7031f 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -12,7 +12,7 @@ import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js'; import { getDmMessageWithUser } from './dm.js'; 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, FederationRelayProfileSnapshot } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -1859,6 +1859,36 @@ function processOwnershipTransferEvent( // ─── Friend Event Processors ───────────────────────────────────────────────── +/** + * Hydrate a replicated user stub with profile data from a relay event. + * Only updates fields that are currently null/empty on the local row, + * so manually-set local values are preserved. + */ +function hydrateReplicatedUserProfile( + user: typeof schema.users.$inferSelect, + profile: FederationRelayProfileSnapshot | undefined, + db: ReturnType, +): typeof schema.users.$inferSelect { + if (!profile) return user; + if (!user.homeInstance) return user; // Don't update native users + + const updates: Record = {}; + if (profile.displayName && !user.displayName) updates.displayName = profile.displayName; + if (profile.avatar && !user.avatar) updates.avatar = profile.avatar; + if (profile.avatarColor && !user.avatarColor) updates.avatarColor = profile.avatarColor; + if (profile.banner && !user.banner) updates.banner = profile.banner; + if (profile.bio && !user.bio) updates.bio = profile.bio; + + if (Object.keys(updates).length === 0) return user; + + db.update(schema.users) + .set(updates) + .where(eq(schema.users.id, user.id)) + .run(); + + return { ...user, ...updates }; +} + function processFriendRequestCreateEvent( event: FederationRelayEvent, sourceInstance: string, @@ -1880,7 +1910,8 @@ function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); // Resolve the recipient — must be a local user on this instance const toUser = resolveLocalUser(to.homeUserId, db); @@ -2114,9 +2145,11 @@ function processFriendAddEvent( return; } - // Resolve both users (create stubs if needed) - const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); - const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db); + // Resolve both users (create stubs if needed) and hydrate with profile data + let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); + let toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db); + toUser = hydrateReplicatedUserProfile(toUser, event.friendship.toProfile, db); // Idempotency: if friendship already exists, accept as no-op const existingFriend = db diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index e2a2e358..27eb593a 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -6,7 +6,7 @@ import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from '../ws/handler.js'; import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js'; import { getOurOrigin } from '../utils/federationAuth.js'; -import type { FederationRelayEvent } from '@backspace/shared'; +import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared'; import type { Friend, FriendRequest, @@ -16,6 +16,16 @@ import type { } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { + return { + displayName: user.displayName ?? null, + avatar: user.avatar ?? null, + avatarColor: user.avatarColor ?? null, + banner: user.banner ?? null, + bio: user.bio ?? null, + }; +} + export async function socialRoutes(app: FastifyInstance): Promise { // GET /api/social/friends - List all friends app.get('/api/social/friends', { @@ -200,6 +210,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined, + toProfile: buildProfileSnapshot(targetUser), status: 'pending', createdAt: now, }, @@ -310,6 +322,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: buildProfileSnapshot(fromUser), + toProfile: buildProfileSnapshot(toUser), status: status as 'accepted' | 'declined', createdAt: friendRequest.createdAt, }, @@ -330,6 +344,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: buildProfileSnapshot(fromUser), + toProfile: buildProfileSnapshot(toUser), createdAt: now2, }, }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5fa61018..67ca9279 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -782,9 +782,19 @@ export interface FederationGroupPayload { members: FederationRelayParticipant[]; } +export interface FederationRelayProfileSnapshot { + displayName?: string | null; + avatar?: string | null; + avatarColor?: string | null; + banner?: string | null; + bio?: string | null; +} + export interface FederationFriendshipPayload { from: FederationRelayParticipant; to: FederationRelayParticipant; + fromProfile?: FederationRelayProfileSnapshot; + toProfile?: FederationRelayProfileSnapshot; status?: 'pending' | 'accepted' | 'declined'; createdAt: number; }