diff --git a/packages/server/src/routes/federation.groupMetadataUpdate.test.ts b/packages/server/src/routes/federation.groupMetadataUpdate.test.ts new file mode 100644 index 00000000..9b576a92 --- /dev/null +++ b/packages/server/src/routes/federation.groupMetadataUpdate.test.ts @@ -0,0 +1,473 @@ +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, and } from 'drizzle-orm'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import type { FederationRelayEvent, FederationRelayParticipant } from '@backspace/shared'; + +setWorkerId(3); + +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, + // Set in beforeEach via reassignment trick — vi.mock factory runs once. + 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'; +import { deleteUploadFile } from '../utils/fileCleanup.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-channel-1'; +const CHANNEL_ID = 'ch-local-1'; +const OWNER_ID = 'owner-stub-1'; +const MEMBER_ID = 'member-local-1'; + +function seedChannelAndOwner(opts: { + channelName: string | null; + channelIcon: string | null; + metadataUpdatedAt: number; +}): void { + testDb.insert(schema.users).values({ + id: OWNER_ID, + username: `owner@${OWNER_INSTANCE}`, + displayName: 'owner', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: OWNER_INSTANCE, + homeUserId: 'home-owner-1', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: MEMBER_ID, + username: 'localmember', + displayName: 'localmember', + passwordHash: 'hash', + status: 'online', + isAdmin: 0, + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.dmChannels).values({ + id: CHANNEL_ID, + federatedId: FEDERATED_ID, + ownerId: OWNER_ID, + ownerHomeUserId: 'home-owner-1', + ownerHomeInstance: OWNER_INSTANCE, + name: opts.channelName, + icon: opts.channelIcon, + metadataUpdatedAt: opts.metadataUpdatedAt, + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.dmMembers).values({ + dmChannelId: CHANNEL_ID, + userId: OWNER_ID, + closed: 0, + }).run(); + testDb.insert(schema.dmMembers).values({ + dmChannelId: CHANNEL_ID, + userId: MEMBER_ID, + closed: 0, + }).run(); +} + +function ownerActor(): FederationRelayParticipant { + return { + homeUserId: 'home-owner-1', + homeInstance: OWNER_INSTANCE, + profile: { username: 'owner', displayName: 'owner' }, + }; +} + +function buildEvent(opts: { + messageId?: string; + name: string | null; + icon: string | null; + metadataUpdatedAt: number; + actor?: FederationRelayParticipant; +}): FederationRelayEvent { + return { + eventType: 'group_metadata_update', + contextType: 'dm', + messageId: opts.messageId ?? 'evt-1', + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + metadata: { + name: opts.name, + icon: opts.icon, + metadataUpdatedAt: opts.metadataUpdatedAt, + actor: opts.actor ?? ownerActor(), + }, + }; +} + +// Build a tiny PNG byte stream (1x1 transparent) so the disk write in +// downloadProfileAsset succeeds with a valid `image/*` content-type. +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 }); +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + tmpUploadDir = path.join(os.tmpdir(), `bs-fed-meta-${crypto.randomBytes(6).toString('hex')}`); + fs.mkdirSync(tmpUploadDir, { recursive: true }); + vi.mocked(connectionManager.sendToDmMembers).mockReset(); + vi.mocked(deleteUploadFile).mockReset(); +}); + +afterEach(() => { + try { fs.rmSync(tmpUploadDir, { recursive: true, force: true }); } catch { /* ignore */ } + vi.unstubAllGlobals(); +}); + +describe('processGroupMetadataUpdateEvent — authority', () => { + it('accepts and mutates the channel when sourceInstance matches ownerHomeInstance', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ name: 'new name', icon: null, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-1']); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.name).toBe('new name'); + expect(row!.metadataUpdatedAt).toBe(2000); + }); + + it('rejects with attribution_mismatch when sourceInstance differs from ownerHomeInstance', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ name: 'evil', icon: null, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, 'evil.example.com', testDb, accepted, rejected); + + expect(accepted).toEqual([]); + expect(rejected).toEqual([{ messageId: 'evt-1', reason: 'attribution_mismatch' }]); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.name).toBe('old'); + expect(row!.metadataUpdatedAt).toBe(1000); + }); +}); + +describe('processGroupMetadataUpdateEvent — version check', () => { + it('silently accepts a stale metadataUpdatedAt without mutating the channel', async () => { + seedChannelAndOwner({ channelName: 'current', channelIcon: null, metadataUpdatedAt: 5000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ name: 'older', icon: null, metadataUpdatedAt: 3000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-1']); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.name).toBe('current'); + expect(row!.metadataUpdatedAt).toBe(5000); + const sysRows = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)).all(); + expect(sysRows).toHaveLength(0); + }); +}); + +describe('processGroupMetadataUpdateEvent — receiver hardening', () => { + it('rejects an oversized name with invalid_payload', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ name: 'a'.repeat(10_000), icon: null, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(accepted).toEqual([]); + expect(rejected).toEqual([{ messageId: 'evt-1', reason: 'invalid_payload' }]); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.name).toBe('old'); + }); + + it('rejects a non-http(s) icon URL with invalid_payload', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ + name: null, + icon: '/api/uploads/foo.png', + metadataUpdatedAt: 2000, + }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(accepted).toEqual([]); + expect(rejected).toEqual([{ messageId: 'evt-1', reason: 'invalid_payload' }]); + }); + + it('rejects a missing metadata payload with missing_metadata_payload', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'group_metadata_update', + contextType: 'dm', + messageId: 'evt-1', + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + // metadata intentionally omitted + }; + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(accepted).toEqual([]); + expect(rejected).toEqual([{ messageId: 'evt-1', reason: 'missing_metadata_payload' }]); + }); +}); + +describe('processGroupMetadataUpdateEvent — icon download', () => { + it('caches the downloaded icon as a local filename on success', async () => { + seedChannelAndOwner({ channelName: 'g', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + vi.stubGlobal('fetch', vi.fn(async () => makeImageResponse())); + + const remoteIcon = `${OWNER_ORIGIN}/api/uploads/icon-remote.png`; + const event = buildEvent({ name: null, icon: remoteIcon, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.icon).not.toBeNull(); + expect(row!.icon!.startsWith('http')).toBe(false); // local filename, not URL + expect(row!.icon).toMatch(/\.png$/); + // File was actually written to the temp upload dir + expect(fs.existsSync(path.join(tmpUploadDir, row!.icon!))).toBe(true); + }); + + it('falls back to the absolute URL when the download fails', async () => { + seedChannelAndOwner({ channelName: 'g', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + vi.stubGlobal('fetch', vi.fn(async () => makeFailureResponse())); + + const remoteIcon = `${OWNER_ORIGIN}/api/uploads/icon-remote.png`; + const event = buildEvent({ name: null, icon: remoteIcon, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.icon).toBe(remoteIcon); + }); +}); + +describe('processGroupMetadataUpdateEvent — system messages', () => { + it('inserts two system messages with :name and :icon dedup suffixes when both change', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + vi.stubGlobal('fetch', vi.fn(async () => makeImageResponse())); + + const remoteIcon = `${OWNER_ORIGIN}/api/uploads/icon.png`; + const event = buildEvent({ + messageId: 'evt-multi', + name: 'fresh', + icon: remoteIcon, + metadataUpdatedAt: 2000, + }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-multi']); + + const sysRows = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)).all(); + expect(sysRows).toHaveLength(2); + const sourceMessageIds = sysRows.map(r => r.sourceMessageId).sort(); + expect(sourceMessageIds).toEqual(['evt-multi:icon', 'evt-multi:name']); + for (const r of sysRows) { + expect(r.sourceInstance).toBe(OWNER_ORIGIN); + expect(r.type).toBe('system'); + } + const nameRow = sysRows.find(r => r.sourceMessageId === 'evt-multi:name')!; + expect(JSON.parse(nameRow.content!)).toEqual({ event: 'name_changed', oldName: 'old', newName: 'fresh' }); + const iconRow = sysRows.find(r => r.sourceMessageId === 'evt-multi:icon')!; + expect(JSON.parse(iconRow.content!)).toEqual({ event: 'icon_changed' }); + }); + + it('is idempotent — a second delivery of the same event creates no additional rows', async () => { + seedChannelAndOwner({ channelName: 'old', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ messageId: 'evt-retry', name: 'fresh', icon: null, metadataUpdatedAt: 2000 }); + + const accepted1: string[] = []; + const rejected1: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted1, rejected1); + expect(rejected1).toEqual([]); + expect(accepted1).toEqual(['evt-retry']); + + const sendSpy = vi.mocked(connectionManager.sendToDmMembers); + const callsAfterFirst = sendSpy.mock.calls.length; + + // Reset metadataUpdatedAt back so the version-check doesn't short-circuit + // before the dedup check. (Forcing the redelivery to actually exercise the + // dedup logic, not the cheaper version short-circuit.) + testDb.update(schema.dmChannels) + .set({ metadataUpdatedAt: 1000, name: 'old' }) + .where(eq(schema.dmChannels.id, CHANNEL_ID)) + .run(); + + const accepted2: string[] = []; + const rejected2: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted2, rejected2); + expect(rejected2).toEqual([]); + expect(accepted2).toEqual(['evt-retry']); + + const sysRows = testDb.select().from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.dmChannelId, CHANNEL_ID), + eq(schema.dmMessages.sourceMessageId, 'evt-retry:name'), + )).all(); + expect(sysRows).toHaveLength(1); + // No additional broadcasts on retry. + expect(sendSpy.mock.calls.length).toBe(callsAfterFirst); + }); + + it('treats a no-op (received metadata identical to local) as a silent accept', async () => { + seedChannelAndOwner({ channelName: 'same', channelIcon: null, metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ + name: 'same', + icon: null, + // Bump timestamp so the version-check passes — no-op detection must + // happen on the diff, not just on the timestamp. + metadataUpdatedAt: 2000, + }); + + const sendSpy = vi.mocked(connectionManager.sendToDmMembers); + sendSpy.mockClear(); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-1']); + const sysRows = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)).all(); + expect(sysRows).toHaveLength(0); + expect(sendSpy).not.toHaveBeenCalled(); + }); +}); + +describe('processGroupMetadataUpdateEvent — icon clear', () => { + it('clears the icon and unlinks the previous local file', async () => { + seedChannelAndOwner({ channelName: null, channelIcon: 'old-local.png', metadataUpdatedAt: 1000 }); + const fed = await import('./federation.js'); + const event = buildEvent({ name: null, icon: null, metadataUpdatedAt: 2000 }); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processGroupMetadataUpdateEvent(event, OWNER_INSTANCE, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['evt-1']); + const row = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get(); + expect(row!.icon).toBeNull(); + + const sysRows = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)).all(); + expect(sysRows).toHaveLength(1); + expect(JSON.parse(sysRows[0]!.content!)).toEqual({ event: 'icon_changed' }); + + expect(deleteUploadFile).toHaveBeenCalledWith('old-local.png'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 60357276..dbb5afca 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -21,6 +21,7 @@ import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/f import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; import { getDmMessageWithUser } from './dm.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared'; +import { GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH } from '@backspace/shared/src/constants.js'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -3006,6 +3007,9 @@ export async function processRelayEvents( case 'profile_update': await processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected); break; + case 'group_metadata_update': + await processGroupMetadataUpdateEvent(event, sourceInstance, db, accepted, rejected); + break; case 'presence_update': processPresenceUpdateEvent(event, sourceInstance, db, accepted, rejected); break; @@ -5665,7 +5669,7 @@ function processDmTypingStopEvent( * Returns the local filename on success, or null on failure. * On failure, the caller stores the absolute URL as a display fallback. */ -async function downloadProfileAsset( +export async function downloadProfileAsset( url: string, sourceInstance: string, ): Promise { @@ -5852,6 +5856,268 @@ export async function processProfileUpdateEvent( accepted.push(event.messageId); } +/** + * Inbound group_metadata_update relay handler. + * + * Authority: only the group's owner instance may mutate name/icon. We compare + * `extractDomain(sourceInstance)` with `extractDomain(channel.ownerHomeInstance)`; + * any other peer relaying this event is treated as an attribution mismatch + * (mirrors the strict check in processProfileUpdateEvent). + * + * Receiver hardening: never trust the wire payload's bounds — re-validate name + * length and icon URL scheme. A malicious or buggy peer cannot push us past + * the same constraints we enforce in PATCH /api/dm/:id. + * + * Side-effects on success: + * 1. dm_channels.{name,icon,metadataUpdatedAt} updated in a single tx. + * 2. One or two `dm_messages` system rows inserted (name_changed / icon_changed), + * each tagged with `(sourceInstance, sourceMessageId)` using the dedup-suffix + * scheme `${event.messageId}:name` / `${event.messageId}:icon`. This mirrors + * processMemberAddEvent's idempotency contract — a retry of the same wire + * event must not insert a second row. + * 3. dm_channel_updated WS broadcast to local members. + * 4. dm_message_created WS broadcast for each new system message. + * 5. Old local icon file is unlinked from disk if it changed away from a local + * filename (same precedent as the local PATCH endpoint). + */ +export async function processGroupMetadataUpdateEvent( + event: FederationRelayEvent, + sourceInstance: string, + db: ReturnType, + accepted: string[], + rejected: Array<{ messageId: string; reason: string }>, +): Promise { + if (!event.federatedId) { + rejected.push({ messageId: event.messageId, reason: 'missing_federated_id' }); + return; + } + + // Lookup channel by federated_id. Missing → idempotent accept (this peer has + // no replica of the channel, nothing to update). + const channel = db + .select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.federatedId, event.federatedId)) + .get(); + + if (!channel) { + accepted.push(event.messageId); + return; + } + + // Authority: only the owner's home instance can mutate group metadata. + if (extractDomain(sourceInstance) !== extractDomain(channel.ownerHomeInstance ?? '')) { + rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' }); + return; + } + + // Receiver hardening — payload validation. Don't trust remote peers to + // respect our bounds; a peer bug or malicious actor must not be able to + // push values our local PATCH endpoint would have rejected. + const metadata = event.metadata; + if (!metadata) { + rejected.push({ messageId: event.messageId, reason: 'missing_metadata_payload' }); + return; + } + + if (metadata.name !== null) { + const trimmedLength = metadata.name.trim().length; + if (trimmedLength < GROUP_DM_NAME_MIN_LENGTH || trimmedLength > GROUP_DM_NAME_MAX_LENGTH) { + rejected.push({ messageId: event.messageId, reason: 'invalid_payload' }); + return; + } + } + + if (metadata.icon !== null && !(metadata.icon.startsWith('http://') || metadata.icon.startsWith('https://'))) { + rejected.push({ messageId: event.messageId, reason: 'invalid_payload' }); + return; + } + + // Version check: stale or duplicate timestamp → silent accept. + if (metadata.metadataUpdatedAt <= (channel.metadataUpdatedAt ?? 0)) { + accepted.push(event.messageId); + return; + } + + // Diff against stored row — if neither field actually changed, no-op. + const nameChanged = metadata.name !== channel.name; + const iconChanged = metadata.icon !== channel.icon; + if (!nameChanged && !iconChanged) { + accepted.push(event.messageId); + return; + } + + // Idempotency pre-check: if either system message already exists under + // `(sourceInstance, sourceMessageId)`, this event was already processed. + // Mirrors processMemberAddEvent's dedup contract — outbox retries and + // initial-sync replay must not double-insert. + const nameMessageId = `${event.messageId}:name`; + const iconMessageId = `${event.messageId}:icon`; + const existingNameRow = nameChanged + ? db + .select({ id: schema.dmMessages.id }) + .from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.sourceInstance, sourceInstance), + eq(schema.dmMessages.sourceMessageId, nameMessageId), + )) + .get() + : null; + const existingIconRow = iconChanged + ? db + .select({ id: schema.dmMessages.id }) + .from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.sourceInstance, sourceInstance), + eq(schema.dmMessages.sourceMessageId, iconMessageId), + )) + .get() + : null; + // If every changed field already has its corresponding system row, the + // entire event has been applied — accept silently. + if ( + (!nameChanged || existingNameRow) + && (!iconChanged || existingIconRow) + ) { + accepted.push(event.messageId); + return; + } + + // ── Resolve icon: download to local upload dir, fall back to absolute URL ── + let resolvedIcon: string | null = metadata.icon; + if (iconChanged && metadata.icon !== null) { + const localFile = await downloadProfileAsset(metadata.icon, sourceInstance); + resolvedIcon = localFile ?? metadata.icon; + } + + // Resolve actor → local user id for the system-message foreign key. + // Falls back to channel.ownerId if the actor stub can't be created (e.g. + // tombstoned identity); the system message still has to render somewhere. + const actorParticipant = metadata.actor; + let actorUserId: string | null = null; + if (actorParticipant) { + const actorUser = resolveOrCreateReplicatedUser( + actorParticipant.homeUserId, + actorParticipant.homeInstance, + db, + { username: actorParticipant.profile?.username, status: actorParticipant.profile?.status }, + ); + actorUserId = actorUser?.id ?? null; + } + if (!actorUserId) { + actorUserId = channel.ownerId; + } + if (!actorUserId) { + // No owner user row to attach a system message to — extremely unusual, + // bail out cleanly without persisting anything. + rejected.push({ messageId: event.messageId, reason: 'actor_not_found' }); + return; + } + + const oldName = channel.name; + const oldIcon = channel.icon; + + type SystemMessageRow = { id: string; sourceMessageId: string; content: string; createdAt: number }; + const sysMessageRows: SystemMessageRow[] = []; + + // Single transaction: channel update + system message insert(s). + db.transaction((tx) => { + tx.update(schema.dmChannels) + .set({ + name: metadata.name, + icon: resolvedIcon, + metadataUpdatedAt: metadata.metadataUpdatedAt, + }) + .where(eq(schema.dmChannels.id, channel.id)) + .run(); + + if (nameChanged && !existingNameRow) { + const sysId = generateSnowflake(); + const content = JSON.stringify({ event: 'name_changed', oldName, newName: metadata.name }); + tx.insert(schema.dmMessages).values({ + id: sysId, + dmChannelId: channel.id, + userId: actorUserId, + content, + type: 'system', + sourceInstance, + sourceMessageId: nameMessageId, + createdAt: metadata.metadataUpdatedAt, + }).run(); + sysMessageRows.push({ + id: sysId, + sourceMessageId: nameMessageId, + content, + createdAt: metadata.metadataUpdatedAt, + }); + } + + if (iconChanged && !existingIconRow) { + const sysId = generateSnowflake(); + const content = JSON.stringify({ event: 'icon_changed' }); + tx.insert(schema.dmMessages).values({ + id: sysId, + dmChannelId: channel.id, + userId: actorUserId, + content, + type: 'system', + sourceInstance, + sourceMessageId: iconMessageId, + createdAt: metadata.metadataUpdatedAt, + }).run(); + sysMessageRows.push({ + id: sysId, + sourceMessageId: iconMessageId, + content, + createdAt: metadata.metadataUpdatedAt, + }); + } + }); + + // ── Broadcast channel update to local members ── + connectionManager.sendToDmMembers(channel.id, { + type: 'dm_channel_updated', + dmChannelId: channel.id, + name: metadata.name, + icon: resolvedIcon, + }); + + // ── Broadcast each new system message ── + const actorRow = db.select().from(schema.users).where(eq(schema.users.id, actorUserId)).get(); + const sanitizedActor = actorRow ? sanitizeUser(actorRow) : undefined; + for (const sys of sysMessageRows) { + connectionManager.sendToDmMembers(channel.id, { + type: 'dm_message_created', + message: { + id: sys.id, + dmChannelId: channel.id, + userId: actorUserId, + content: sys.content, + type: 'system', + createdAt: sys.createdAt, + sourceInstance, + sourceMessageId: sys.sourceMessageId, + editedAt: null, + replyToId: null, + user: sanitizedActor, + attachments: [], + embeds: [], + reactions: [], + } as DmMessageWithUser, + }); + } + + // ── Cleanup old local icon file ── + // Mirrors the local PATCH precedent (dm.ts:1595): only unlink when the + // previous icon was a bare local filename (i.e. we own the file on disk). + // Absolute URLs point at remote files we never owned. + if (iconChanged && oldIcon && !oldIcon.startsWith('http://') && !oldIcon.startsWith('https://') && oldIcon !== resolvedIcon) { + deleteUploadFile(oldIcon); + } + + accepted.push(event.messageId); +} + /** * Inbound presence_update relay handler. *