From 04d791007701e0f607a207fccf4a15daff66e4ce Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 19:23:47 +0200 Subject: [PATCH] feat(federation): bootstrap carries group name + icon + metadataUpdatedAt --- packages/server/src/routes/dm.ts | 37 +- .../routes/federation.groupBootstrap.test.ts | 398 ++++++++++++++++++ packages/server/src/routes/federation.ts | 22 +- 3 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/routes/federation.groupBootstrap.test.ts diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index a85320cb..150e6581 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -1294,6 +1294,19 @@ export async function dmRoutes(app: FastifyInstance): Promise { const domainOrigin = getOurOrigin(); const allParticipants = getDmParticipants(dmChannelId); + // Re-fetch the channel row so the bootstrap payload carries the + // current name/icon/metadataUpdatedAt. On a freshly-created group + // these are null/null/0, but we read them rather than hard-code + // so future producer paths (or test mutations between create and + // relay) stay correct without touching this site. + const channelRow = db.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)).get(); + const wireIcon = channelRow?.icon + ? (channelRow.icon.startsWith('http://') || channelRow.icon.startsWith('https://') + ? channelRow.icon + : `${domainOrigin}/api/uploads/${channelRow.icon}`) + : null; + for (const targetUser of targetUsers) { if (!targetUser.homeInstance || targetUser.homeInstance === domainOrigin) continue; @@ -1320,9 +1333,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { homeInstance: callerUser?.homeInstance || domainOrigin, }, members: allParticipants, - name: null, // safe default — Phase 4 task 4.3 replaces with channel.name - icon: null, // safe default — Phase 4 task 4.3 replaces with normalized URL - metadataUpdatedAt: 0, // safe default — Phase 4 task 4.3 replaces with channel.metadataUpdatedAt + name: channelRow?.name ?? null, + icon: wireIcon, + metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0, }, }; @@ -1845,6 +1858,18 @@ export async function dmRoutes(app: FastifyInstance): Promise { const addedUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get(); const adderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + // Carry the current group metadata snapshot so a fresh peer can + // bootstrap the channel with the correct name + icon. Re-fetch to + // pick up any concurrent metadata mutation; the row may have been + // patched between the start of this handler and now. + const channelRow = db.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.id, id)).get(); + const wireIcon = channelRow?.icon + ? (channelRow.icon.startsWith('http://') || channelRow.icon.startsWith('https://') + ? channelRow.icon + : `${domainOrigin}/api/uploads/${channelRow.icon}`) + : null; + const memberAddPayload: FederationRelayEvent = { eventType: 'member_add', dmChannelId: id, @@ -1868,9 +1893,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { homeInstance: dmChannel.ownerHomeInstance || domainOrigin, }, members: allParticipants, - name: null, // safe default — Phase 4 task 4.3 replaces with channel.name - icon: null, // safe default — Phase 4 task 4.3 replaces with normalized URL - metadataUpdatedAt: 0, // safe default — Phase 4 task 4.3 replaces with channel.metadataUpdatedAt + name: channelRow?.name ?? null, + icon: wireIcon, + metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0, }, }; diff --git a/packages/server/src/routes/federation.groupBootstrap.test.ts b/packages/server/src/routes/federation.groupBootstrap.test.ts new file mode 100644 index 00000000..8d9522b5 --- /dev/null +++ b/packages/server/src/routes/federation.groupBootstrap.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect, beforeEach, afterEach, 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 os from 'node:os'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { eq } from 'drizzle-orm'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import type { FederationRelayEvent } from '@backspace/shared'; + +setWorkerId(5); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let tmpUploadDir: string; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.test', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + get uploadDir(): string { return tmpUploadDir; }, + }, +})); + +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(), + }, +})); + +vi.mock('../utils/fileCleanup.js', async () => { + const actual = await vi.importActual('../utils/fileCleanup.js'); + return { + ...actual, + deleteUploadFile: vi.fn(), + deleteAttachmentByFilename: vi.fn(), + deleteAttachmentFiles: vi.fn(), + }; +}); + +import { connectionManager } from '../ws/handler.js'; + +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); + } + } +} + +const OWNER_INSTANCE = 'orbit.ddns.net'; +const OWNER_ORIGIN = `https://${OWNER_INSTANCE}`; +const FEDERATED_ID = 'fed-bootstrap-1'; + +// Tiny PNG body for fetch stubs — matches the shape downloadProfileAsset +// expects (image/* content-type, non-empty body). +const TINY_PNG = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63600000000200015d3a87' + + '6f0000000049454e44ae426082', + 'hex', +); + +function makeImageResponse(): Response { + return new Response(TINY_PNG, { + status: 200, + headers: { 'content-type': 'image/png' }, + }); +} + +function makeFailureResponse(): Response { + return new Response('boom', { status: 500 }); +} + +/** + * Build a member_add event whose group payload bootstraps a fresh peer. + * Optional fields default to safe values so tests can omit them when + * exercising the "older peer that doesn't yet emit the new fields" branch. + */ +function buildBootstrapEvent(opts: { + messageId?: string; + name?: string | null; + icon?: string | null; + metadataUpdatedAt?: number; + // When true, omit the metadata fields entirely (older-peer payload). + omitMetadataFields?: boolean; +}): FederationRelayEvent { + const group: FederationRelayEvent['group'] = { + owner: { + homeUserId: 'home-owner-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'owner', displayName: 'owner' }, + }, + members: [ + { + homeUserId: 'home-owner-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'owner', displayName: 'owner' }, + }, + { + homeUserId: 'home-added-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'added', displayName: 'added' }, + }, + ], + // Defaults match what an older peer emits; explicit overrides below. + name: null, + icon: null, + metadataUpdatedAt: 0, + }; + + if (!opts.omitMetadataFields) { + group.name = opts.name ?? null; + group.icon = opts.icon ?? null; + group.metadataUpdatedAt = opts.metadataUpdatedAt ?? 0; + } else { + // Simulate an older peer's payload by deleting the metadata-snapshot + // fields entirely. Validates the consumer's `?? null`/`?? 0` fallbacks. + delete (group as Partial).name; + delete (group as Partial).icon; + delete (group as Partial).metadataUpdatedAt; + } + + return { + eventType: 'member_add', + contextType: 'dm', + messageId: opts.messageId ?? 'evt-bootstrap-1', + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + membership: { + user: { + homeUserId: 'home-added-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'added', displayName: 'added' }, + }, + addedBy: { + homeUserId: 'home-owner-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'owner', displayName: 'owner' }, + }, + }, + group, + }; +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + tmpUploadDir = path.join(os.tmpdir(), `bs-fed-bootstrap-${crypto.randomBytes(6).toString('hex')}`); + fs.mkdirSync(tmpUploadDir, { recursive: true }); + vi.mocked(connectionManager.sendToUser).mockReset(); + vi.mocked(connectionManager.sendToDmMembers).mockReset(); +}); + +afterEach(() => { + try { fs.rmSync(tmpUploadDir, { recursive: true, force: true }); } catch { /* ignore */ } + vi.unstubAllGlobals(); +}); + +describe('processMemberAddEvent — bootstrap with group metadata snapshot', () => { + it('creates the channel with name and downloaded icon when the icon fetch succeeds', async () => { + const fed = await import('./federation.js'); + vi.stubGlobal('fetch', vi.fn(async () => makeImageResponse())); + + const event = buildBootstrapEvent({ + name: 'Cool Group', + icon: `${OWNER_ORIGIN}/api/uploads/x.png`, + metadataUpdatedAt: 12345, + }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processMemberAddEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-bootstrap-1']); + + const row = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get(); + expect(row).toBeDefined(); + expect(row!.name).toBe('Cool Group'); + expect(row!.metadataUpdatedAt).toBe(12345); + // Icon was downloaded → stored as local filename, not absolute URL + expect(row!.icon).not.toBeNull(); + expect(row!.icon!.startsWith('http')).toBe(false); + expect(row!.icon).toMatch(/\.png$/); + expect(fs.existsSync(path.join(tmpUploadDir, row!.icon!))).toBe(true); + }); + + it('falls back to the absolute URL when the icon download fails', async () => { + const fed = await import('./federation.js'); + vi.stubGlobal('fetch', vi.fn(async () => makeFailureResponse())); + + const remoteIcon = `${OWNER_ORIGIN}/api/uploads/x.png`; + const event = buildBootstrapEvent({ + name: 'Group With Failed Icon', + icon: remoteIcon, + metadataUpdatedAt: 999, + }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processMemberAddEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + const row = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get(); + expect(row!.name).toBe('Group With Failed Icon'); + expect(row!.icon).toBe(remoteIcon); + expect(row!.metadataUpdatedAt).toBe(999); + }); + + it('records metadataUpdatedAt so subsequent stale group_metadata_update events are rejected', async () => { + const fed = await import('./federation.js'); + const event = buildBootstrapEvent({ + name: 'Versioned', + icon: null, + metadataUpdatedAt: 12345, + }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + const row = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get(); + expect(row!.metadataUpdatedAt).toBe(12345); + + // Replay a stale metadata-update with timestamp <= bootstrap value. + // The receiver must silently accept and not mutate the channel. + const staleMeta: FederationRelayEvent = { + eventType: 'group_metadata_update', + contextType: 'dm', + messageId: 'stale-meta-1', + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + metadata: { + name: 'older name', + icon: null, + metadataUpdatedAt: 12345, // equal to bootstrap → stale by <= + actor: { + homeUserId: 'home-owner-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'owner', displayName: 'owner' }, + }, + }, + }; + + const accepted2: string[] = []; + const rejected2: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(staleMeta, OWNER_INSTANCE, testDb, accepted2, rejected2); + expect(rejected2).toEqual([]); + expect(accepted2).toEqual(['stale-meta-1']); + + const rowAfter = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get(); + expect(rowAfter!.name).toBe('Versioned'); + expect(rowAfter!.metadataUpdatedAt).toBe(12345); + }); + + it('falls back to safe defaults when the older-peer payload omits the metadata fields', async () => { + const fed = await import('./federation.js'); + + const event = buildBootstrapEvent({ omitMetadataFields: true }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + const row = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get(); + expect(row).toBeDefined(); + expect(row!.name).toBeNull(); + expect(row!.icon).toBeNull(); + expect(row!.metadataUpdatedAt).toBe(0); + }); + + it('does not overwrite local metadata when the channel already exists (incremental path)', async () => { + const fed = await import('./federation.js'); + + // Seed: channel already bootstrapped with current metadata. The + // incremental member_add must not touch any of these fields. + const channelId = 'ch-existing-1'; + const ownerLocalId = 'owner-stub-1'; + testDb.insert(schema.users).values({ + id: ownerLocalId, + username: `owner@${OWNER_INSTANCE}`, + displayName: 'owner', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: OWNER_INSTANCE, + homeUserId: 'home-owner-1', + createdAt: Date.now(), + }).run(); + + const localMemberId = 'local-member-1'; + testDb.insert(schema.users).values({ + id: localMemberId, + username: 'localmember', + displayName: 'localmember', + passwordHash: 'hash', + status: 'online', + isAdmin: 0, + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.dmChannels).values({ + id: channelId, + federatedId: FEDERATED_ID, + ownerId: ownerLocalId, + ownerHomeUserId: 'home-owner-1', + ownerHomeInstance: OWNER_INSTANCE, + name: 'Local Current Name', + icon: 'local-current-icon.png', + metadataUpdatedAt: 9999, + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.dmMembers).values({ + dmChannelId: channelId, + userId: ownerLocalId, + closed: 0, + }).run(); + testDb.insert(schema.dmMembers).values({ + dmChannelId: channelId, + userId: localMemberId, + closed: 0, + }).run(); + + // Incremental member_add carrying STALE bootstrap metadata. Must not + // overwrite the local row even though the receiver reads these fields + // for the bootstrap branch. + const event = buildBootstrapEvent({ + name: 'STALE bootstrap name', + icon: `${OWNER_ORIGIN}/api/uploads/stale.png`, + metadataUpdatedAt: 1, // way older than local 9999 + messageId: 'evt-incremental-1', + }); + // Replace the added user with a fresh remote member so the incremental + // path actually inserts a new dm_members row (instead of dedup-ing). + event.membership!.user = { + homeUserId: 'home-added-2', + homeInstance: OWNER_INSTANCE, + profile: { username: 'added2', displayName: 'added2' }, + }; + + // No fetch stub — if the receiver mistakenly tried to download the + // icon for an existing channel, the un-stubbed call would surface as + // a thrown ECONNREFUSED. The current implementation downloads only + // in the bootstrap branch, so this also locks in that scoping. + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-incremental-1']); + + const row = testDb.select().from(schema.dmChannels) + .where(eq(schema.dmChannels.id, channelId)).get(); + expect(row!.name).toBe('Local Current Name'); + expect(row!.icon).toBe('local-current-icon.png'); + expect(row!.metadataUpdatedAt).toBe(9999); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index de00a300..ec8c4a9d 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2960,7 +2960,7 @@ export async function processRelayEvents( processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected); break; case 'member_add': - processMemberAddEvent(event, sourceInstance, db, accepted, rejected); + await processMemberAddEvent(event, sourceInstance, db, accepted, rejected); break; case 'member_remove': processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected); @@ -4043,13 +4043,13 @@ function processReactionRemoveEvent( // ─── Membership mutation processors ────────────────────────────────────────── -function processMemberAddEvent( +export async function processMemberAddEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, -): void { +): Promise { if (!event.federatedId || !event.membership?.user) { rejected.push({ messageId: event.messageId, reason: 'missing_membership_payload' }); return; @@ -4101,6 +4101,19 @@ function processMemberAddEvent( ownerId = ownerLocal?.id ?? null; } + // Group metadata snapshot. Older peers omit these fields — fall back + // to safe defaults (null name/icon, metadataUpdatedAt=0). When an icon + // URL is present, mirror processGroupMetadataUpdateEvent and try to + // download a local copy; on failure, persist the absolute URL. + const bootstrapName = event.group.name ?? null; + const bootstrapIconUrl = event.group.icon ?? null; + const bootstrapMetadataUpdatedAt = event.group.metadataUpdatedAt ?? 0; + let bootstrapResolvedIcon: string | null = bootstrapIconUrl; + if (bootstrapIconUrl !== null) { + const localFile = await downloadProfileAsset(bootstrapIconUrl, sourceInstance); + bootstrapResolvedIcon = localFile ?? bootstrapIconUrl; + } + db.insert(schema.dmChannels) .values({ id: channelId, @@ -4109,6 +4122,9 @@ function processMemberAddEvent( ownerHomeUserId: event.group.owner?.homeUserId ?? null, ownerHomeInstance: event.group.owner?.homeInstance ?? null, createdAt: now, + name: bootstrapName, + icon: bootstrapResolvedIcon, + metadataUpdatedAt: bootstrapMetadataUpdatedAt, }) .run();