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.
This commit is contained in:
Jannis Braun
2026-05-05 15:48:56 +02:00
parent 1d353c994b
commit 097eb9a2ef
4 changed files with 142 additions and 3 deletions
@@ -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<typeof drizzle<typeof schema>>;
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.');
});
});
+10 -3
View File
@@ -5625,7 +5625,7 @@ async function downloadProfileAsset(
} }
} }
async function processProfileUpdateEvent( export async function processProfileUpdateEvent(
event: FederationRelayEvent, event: FederationRelayEvent,
sourceInstance: string, sourceInstance: string,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
@@ -5717,10 +5717,17 @@ async function processProfileUpdateEvent(
deleteUploadFile(oldBanner); 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) db.update(schema.users)
.set({ .set({
displayName: payload.displayName, displayName: effectiveDisplayName,
avatar: resolvedAvatar, avatar: resolvedAvatar,
banner: resolvedBanner, banner: resolvedBanner,
accentColor: payload.accentColor, accentColor: payload.accentColor,
+2
View File
@@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
homeUserId: preUpdateUser.id, homeUserId: preUpdateUser.id,
homeInstance: origin, homeInstance: origin,
profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(), profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(),
username: preUpdateUser.username,
displayName: preUpdateUser.displayName, displayName: preUpdateUser.displayName,
avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http') avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http')
? `${origin}/api/uploads/${preUpdateUser.avatar}` ? `${origin}/api/uploads/${preUpdateUser.avatar}`
@@ -524,6 +525,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
homeUserId: updatedUser!.id, homeUserId: updatedUser!.id,
homeInstance: origin, homeInstance: origin,
profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(), profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(),
username: updatedUser!.username,
displayName: updatedUser!.displayName, displayName: updatedUser!.displayName,
avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http') avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http')
? `${origin}/api/uploads/${updatedUser!.avatar}` ? `${origin}/api/uploads/${updatedUser!.avatar}`
+6
View File
@@ -972,6 +972,12 @@ export interface FederationProfileUpdatePayload {
homeUserId: string; homeUserId: string;
homeInstance: string; homeInstance: string;
profileUpdatedAt: number; 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; displayName: string | null;
avatar: string | null; avatar: string | null;
banner: string | null; banner: string | null;