From 097eb9a2ef3790732abd1b1496d928edf8989350 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 15:48:56 +0200 Subject: [PATCH] feat(federation): preserve effective displayName across profile_update relay FederationProfileUpdatePayload gains `username`: the home user's canonical handle. Receiver applies displayName ?? username so stubs whose home user has no displayName show the real handle instead of getting clobbered to null. Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself is immutable on the home instance, so the receiver does not rewrite the stub's username column on profile_update. --- .../routes/federation.profileUpdate.test.ts | 124 ++++++++++++++++++ packages/server/src/routes/federation.ts | 13 +- packages/server/src/routes/users.ts | 2 + packages/shared/src/types.ts | 6 + 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/routes/federation.profileUpdate.test.ts diff --git a/packages/server/src/routes/federation.profileUpdate.test.ts b/packages/server/src/routes/federation.profileUpdate.test.ts new file mode 100644 index 00000000..d2b408a1 --- /dev/null +++ b/packages/server/src/routes/federation.profileUpdate.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { eq } from 'drizzle-orm'; +import type { FederationRelayEvent } from '@backspace/shared'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sql.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + // Stub seeded with displayName='pbtest3' (set previously by hydrateReplicatedUserProfile) + testDb.insert(schema.users).values({ + id: 'stub-1', + username: 'pbtest3@orbit.ddns.net', + displayName: 'pbtest3', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: 'orbit.ddns.net', + homeUserId: 'home-1', + profileUpdatedAt: 1000, + createdAt: Date.now(), + }).run(); +}); + +describe('processProfileUpdateEvent — displayName fallback', () => { + it('falls back to username when home displayName is null', async () => { + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'profile_update', + contextType: 'profile', + messageId: 'm1', + encryptionVersion: 0, + timestamp: Date.now(), + profileUpdate: { + homeUserId: 'home-1', + homeInstance: 'orbit.ddns.net', + profileUpdatedAt: 2000, + username: 'pbtest3', + displayName: null, + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + }, + }; + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['m1']); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get(); + expect(row!.displayName).toBe('pbtest3'); // fell back to username, not clobbered to null + }); + + it('uses home displayName when provided (not the fallback)', async () => { + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'profile_update', + contextType: 'profile', + messageId: 'm2', + encryptionVersion: 0, + timestamp: Date.now(), + profileUpdate: { + homeUserId: 'home-1', + homeInstance: 'orbit.ddns.net', + profileUpdatedAt: 3000, + username: 'pbtest3', + displayName: 'Peter B.', + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + }, + }; + await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, [], []); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get(); + expect(row!.displayName).toBe('Peter B.'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index f4d188e8..5b5d2ebe 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -5625,7 +5625,7 @@ async function downloadProfileAsset( } } -async function processProfileUpdateEvent( +export async function processProfileUpdateEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, @@ -5717,10 +5717,17 @@ async function processProfileUpdateEvent( deleteUploadFile(oldBanner); } - // Authoritative overwrite — home instance is always right + // Authoritative overwrite — home instance is always right. + // displayName falls back to the home user's canonical username when null, + // mirroring hydrateReplicatedUserProfile so stubs whose home user has no + // displayName show the real handle instead of getting clobbered to null. + // (The username field on the wire is the home's canonical handle, not the + // stub's local-part; usernames are immutable on the home instance, so we + // never rewrite the stub's username column here.) + const effectiveDisplayName = payload.displayName ?? payload.username ?? null; db.update(schema.users) .set({ - displayName: payload.displayName, + displayName: effectiveDisplayName, avatar: resolvedAvatar, banner: resolvedBanner, accentColor: payload.accentColor, diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 9d82e6a1..5a1f4b18 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: preUpdateUser.id, homeInstance: origin, profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(), + username: preUpdateUser.username, displayName: preUpdateUser.displayName, avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http') ? `${origin}/api/uploads/${preUpdateUser.avatar}` @@ -524,6 +525,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: updatedUser!.id, homeInstance: origin, profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(), + username: updatedUser!.username, displayName: updatedUser!.displayName, avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http') ? `${origin}/api/uploads/${updatedUser!.avatar}` diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 43096358..64b87c3a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -972,6 +972,12 @@ export interface FederationProfileUpdatePayload { homeUserId: string; homeInstance: string; profileUpdatedAt: number; + // Home user's canonical username (without @domain suffix). Receivers apply + // `displayName ?? username` so stubs whose home user has no displayName show + // the real handle instead of getting clobbered to null. Username itself is + // immutable on the home instance — receivers do NOT rewrite the stub's + // username column on profile_update. + username: string; displayName: string | null; avatar: string | null; banner: string | null;