diff --git a/packages/server/src/routes/dm.kick.test.ts b/packages/server/src/routes/dm.kick.test.ts new file mode 100644 index 00000000..dcbdae2c --- /dev/null +++ b/packages/server/src/routes/dm.kick.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let currentUserId = 'owner-A'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = currentUserId; + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + getRoom: () => undefined, + getUserRoom: () => undefined, + leaveCurrentRoom: vi.fn(() => false), + destroyRoom: vi.fn(), + clearVoiceUserStatus: vi.fn(), + }, +})); + +// Federation: keep the real outbox writers (queueOutboxEvent / appendMutationLog) +// so we can read federation_outbox rows directly to assert the wire payload. +// Same-module callers inside helpers bypass vi.mock — inspect the persisted +// row instead. Unrelated queue helpers are stubbed because they aren't under +// test here. +vi.mock('../utils/federationOutbox.js', async () => { + const actual = await vi.importActual('../utils/federationOutbox.js'); + return { + ...actual, + isFederationRelayEnabled: () => true, + queueDmCloseRelay: vi.fn(), + sendTypingRelay: vi.fn(), + queueDmRelay: vi.fn(), + queueGroupMetadataRelay: vi.fn(), + }; +}); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://local.test' }; +}); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedUsers(): void { + testDb.insert(schema.users).values({ + id: 'owner-A', + username: 'alice', + displayName: 'Alice', + passwordHash: 'x', + homeUserId: 'owner-A', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'member-B', + username: 'bob', + displayName: 'Bob', + passwordHash: 'x', + homeUserId: 'member-B', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'member-C', + username: 'carol', + displayName: 'Carol', + passwordHash: 'x', + homeUserId: 'member-C', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'remote-D', + username: 'dan@remote.test', + displayName: 'Dan', + passwordHash: 'x', + homeUserId: 'remote-dan', + homeInstance: 'https://remote.test', + createdAt: Date.now(), + }).run(); +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + federationRelayEnabled: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedFederationPeer(origin: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + status: 'active', + hmacSecret: 'x', + createdAt: Date.now(), + }).run(); +} + +interface GroupSeed { + id: string; + ownerId: string; + members: string[]; + federatedId?: string | null; +} + +function seedGroupDm(opts: GroupSeed): void { + testDb.insert(schema.dmChannels).values({ + id: opts.id, + ownerId: opts.ownerId, + federatedId: opts.federatedId ?? null, + createdAt: Date.now(), + }).run(); + for (const userId of opts.members) { + testDb.insert(schema.dmMembers).values({ + dmChannelId: opts.id, + userId, + }).run(); + } +} + +function seed1on1Dm(id: string, a: string, b: string): void { + testDb.insert(schema.dmChannels).values({ + id, + ownerId: null, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: a }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: b }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { dmRoutes } = await import('./dm.js'); + await app.register(dmRoutes); + await app.ready(); + return app; +} + +describe('DELETE /api/dm/:id/members/:targetUserId — owner kick', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUsers(); + seedFederationPeer('https://remote.test'); + currentUserId = 'owner-A'; + vi.clearAllMocks(); + app = await buildApp(); + }); + + it('owner kicks a member → 200, member row gone, system message + dm_member_removed broadcast + federation outbox queued with reason=kick', async () => { + seedGroupDm({ + id: 'dm-1', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'remote-D'], + federatedId: 'fed-kick-1', + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/dm-1/members/member-B', + }); + expect(res.statusCode).toBe(200); + + // dm_members row for kicked target is gone + const remainingMembers = testDb.select().from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, 'dm-1')) + .all(); + expect(remainingMembers.map((m) => m.userId).sort()).toEqual(['owner-A', 'remote-D']); + + // member_removed system message present with reason=kick + const sysRows = testDb.select().from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, 'dm-1')) + .all(); + const memberRemovedRow = sysRows.find((r) => { + try { + return JSON.parse(r.content!).event === 'member_removed'; + } catch { + return false; + } + }); + expect(memberRemovedRow).toBeDefined(); + expect(memberRemovedRow!.type).toBe('system'); + const parsed = JSON.parse(memberRemovedRow!.content!); + expect(parsed.event).toBe('member_removed'); + expect(parsed.reason).toBe('kick'); + expect(parsed.targetUserId).toBe('member-B'); + + // dm_member_removed broadcast fired for remaining members + const sendCalls = (connectionManager.sendToUser as ReturnType).mock.calls; + const removedBroadcasts = sendCalls.filter((c) => + c[1]?.type === 'dm_member_removed' && + c[1]?.dmChannelId === 'dm-1' && + c[1]?.userId === 'member-B' + ); + // Sent to each remaining member (owner-A + remote-D = 2) — ownership transfer doesn't fire on kick + expect(removedBroadcasts.length).toBeGreaterThanOrEqual(1); + const recipients = new Set(removedBroadcasts.map((c) => c[0])); + expect(recipients.has('owner-A')).toBe(true); + expect(recipients.has('remote-D')).toBe(true); + + // Federation outbox queued with reason=kick + correct identities + const outboxRows = testDb.select().from(schema.federationOutbox).all(); + const memberRemoveRows = outboxRows.filter((r) => r.eventType === 'member_remove'); + expect(memberRemoveRows.length).toBe(1); + const wire = JSON.parse(memberRemoveRows[0]!.payload); + expect(wire.eventType).toBe('member_remove'); + expect(wire.federatedId).toBe('fed-kick-1'); + expect(wire.membership.reason).toBe('kick'); + expect(wire.membership.user.homeUserId).toBe('member-B'); + expect(wire.membership.user.homeInstance).toBe('https://local.test'); + expect(wire.membership.removedBy.homeUserId).toBe('owner-A'); + expect(wire.membership.removedBy.homeInstance).toBe('https://local.test'); + + // Channel is NOT soft-deleted (kick never orphans the group) + const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-1')).get(); + expect(channel?.deletedAt).toBeNull(); + + // Owner unchanged (kick does not transfer ownership) + expect(channel?.ownerId).toBe('owner-A'); + + // No ownership_transfer outbox row queued + const transferRows = outboxRows.filter((r) => r.eventType === 'ownership_transfer'); + expect(transferRows.length).toBe(0); + }); + + it('non-owner kick attempt → 403', async () => { + seedGroupDm({ + id: 'dm-2', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'member-C'], + }); + currentUserId = 'member-B'; + + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/dm-2/members/member-C', + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/owner/i); + + // Target still a member + const stillMember = testDb.select().from(schema.dmMembers).where(eq(schema.dmMembers.userId, 'member-C')).get(); + expect(stillMember).toBeDefined(); + }); + + it('owner attempts to kick self → 400 with "use leave instead" message', async () => { + seedGroupDm({ + id: 'dm-3', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/dm-3/members/owner-A', + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/leave instead/i); + + // Owner still a member, channel unchanged + const stillOwner = testDb.select().from(schema.dmMembers).where(eq(schema.dmMembers.userId, 'owner-A')).get(); + expect(stillOwner).toBeDefined(); + }); + + it('kick from a 1-on-1 DM → 400', async () => { + seed1on1Dm('dm-1on1', 'owner-A', 'member-B'); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/dm-1on1/members/member-B', + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/1-on-1/i); + }); + + it('target is not a member of the group → 404', async () => { + seedGroupDm({ + id: 'dm-4', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/dm-4/members/member-C', + }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/not a member/i); + }); + + it('kick from a non-existent channel → 404', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/api/dm/does-not-exist/members/member-B', + }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/not found/i); + }); +}); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 8756e4a6..fce8d6d1 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -393,6 +393,278 @@ export function ensureOneOnOneDmChannel( return dmChannelId; } +/** + * Shared core for the leave-DM and kick-DM paths. + * + * Performs the destructive half of removal (system message, member-row delete, + * read_states cleanup, federation relay, dm_member_removed broadcast, + * ownership transfer for self-leave, soft-delete on last-member-leave). + * + * Pre-validation (channel exists, not 1-on-1, target is a member, etc.) is the + * caller's responsibility; this helper assumes a valid group DM and a valid + * target. The caller is also responsible for any voice-room eviction and for + * sending dm_channel_closed to the affected user. + * + * Branching rules: + * - Ownership transfer fires only when the actor is leaving themselves AND + * was the previous owner (kicks cannot orphan a group: the owner is still + * present, so there's nothing to transfer). + * - Last-member soft-delete fires only on self-leave (kicks are guaranteed + * to leave the owner behind, so the channel can never be empty after a + * kick). + */ +function removeDmMember( + channelId: string, + actorUserId: string, + targetUserId: string, + reason: 'leave' | 'kick', +): void { + const db = getDb(); + const isSelfLeave = actorUserId === targetUserId; + + const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, channelId)).get(); + if (!dmChannel) { + // Should never happen — caller validates. Defensive no-op. + return; + } + + // Compute federation targets BEFORE member deletion so the removed user's peer is still included + let fedTargetOrigins: string[] | undefined; + if (isFederationRelayEnabled() && dmChannel?.federatedId) { + fedTargetOrigins = getGroupDmTargetOrigins(channelId); + } + + const targetUserRow = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get(); + const actorUserRow = isSelfLeave + ? targetUserRow + : db.select().from(schema.users).where(eq(schema.users.id, actorUserId)).get(); + + const targetBaseName = targetUserRow?.username?.includes('@') + ? targetUserRow.username.split('@')[0] + : (targetUserRow?.username ?? 'Unknown'); + + // Insert + broadcast member_removed system message (still a member at this point) + const sysMsgId = generateSnowflake(); + const sysNow = Date.now(); + const sysContent = JSON.stringify({ + event: 'member_removed', + targetUserId, + targetDisplayName: targetUserRow?.displayName ?? targetBaseName, + reason, + }); + + db.insert(schema.dmMessages).values({ + id: sysMsgId, + dmChannelId: channelId, + userId: actorUserId, + content: sysContent, + type: 'system', + createdAt: sysNow, + }).run(); + + connectionManager.sendToDmMembers(channelId, { + type: 'dm_message_created', + message: { + id: sysMsgId, + dmChannelId: channelId, + userId: actorUserId, + content: sysContent, + type: 'system', + createdAt: sysNow, + user: actorUserRow ? sanitizeUser(actorUserRow) : undefined, + attachments: [], + embeds: [], + reactions: [], + } as any, + }); + + // Delete dm_members row for the target + db.delete(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, channelId), + eq(schema.dmMembers.userId, targetUserId), + )) + .run(); + + // Clean up read_states for the removed user + db.delete(schema.readStates).where(and( + eq(schema.readStates.userId, targetUserId), + eq(schema.readStates.channelId, channelId), + )).run(); + + // Federation: relay member_remove event with the reason + if (isFederationRelayEnabled() && dmChannel?.federatedId) { + const domainOrigin = getOurOrigin(); + + const memberRemovePayload: FederationRelayEvent = { + eventType: 'member_remove', + dmChannelId: channelId, + messageId: `member_remove:${targetUserId}:${Date.now()}`, + federatedId: dmChannel.federatedId, + encryptionVersion: 0, + timestamp: Date.now(), + membership: { + user: { + homeUserId: targetUserRow?.homeUserId || targetUserId, + homeInstance: targetUserRow?.homeInstance || domainOrigin, + }, + removedBy: { + homeUserId: actorUserRow?.homeUserId || actorUserId, + homeInstance: actorUserRow?.homeInstance || domainOrigin, + }, + reason, + }, + }; + + appendMutationLog( + memberRemovePayload.messageId, + channelId, + 'member_remove', + JSON.stringify(memberRemovePayload), + ); + queueOutboxEvent( + memberRemovePayload.messageId, + channelId, + 'member_remove', + JSON.stringify(memberRemovePayload), + fedTargetOrigins, + ); + } + + // Check remaining members + const remainingMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, channelId)) + .all(); + + if (remainingMembers.length > 0) { + // Ownership transfer only fires on self-leave when the leaver was the owner. + // Kicks cannot orphan a group: the owner is still in the channel. + if (isSelfLeave) { + const nextOwner = remainingMembers[0]; + if (dmChannel && dmChannel.ownerId === actorUserId && nextOwner) { + db.update(schema.dmChannels) + .set({ ownerId: nextOwner.userId }) + .where(eq(schema.dmChannels.id, channelId)) + .run(); + + for (const member of remainingMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_owner_updated', + dmChannelId: channelId, + newOwnerId: nextOwner.userId, + }); + } + + const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get(); + const newOwnerBaseName = newOwnerUser?.username?.includes('@') + ? newOwnerUser.username.split('@')[0] + : (newOwnerUser?.username ?? 'Unknown'); + const ownerSysMsgId = generateSnowflake(); + const ownerNow = Date.now(); + + db.insert(schema.dmMessages).values({ + id: ownerSysMsgId, + dmChannelId: channelId, + userId: actorUserId, + content: JSON.stringify({ + event: 'owner_changed', + newOwnerId: nextOwner.userId, + newOwnerDisplayName: newOwnerUser?.displayName ?? newOwnerBaseName, + }), + type: 'system', + createdAt: ownerNow, + }).run(); + + for (const member of remainingMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_message_created', + message: { + id: ownerSysMsgId, + dmChannelId: channelId, + userId: actorUserId, + content: JSON.stringify({ + event: 'owner_changed', + newOwnerId: nextOwner.userId, + newOwnerDisplayName: newOwnerUser?.displayName ?? newOwnerBaseName, + }), + type: 'system', + createdAt: ownerNow, + user: actorUserRow ? sanitizeUser(actorUserRow) : undefined, + attachments: [], + embeds: [], + reactions: [], + } as any, + }); + } + + // Federation: relay ownership transfer + if (isFederationRelayEnabled() && dmChannel?.federatedId) { + const domainOrigin = getOurOrigin(); + + db.update(schema.dmChannels) + .set({ + ownerHomeUserId: newOwnerUser?.homeUserId || nextOwner.userId, + ownerHomeInstance: newOwnerUser?.homeInstance || domainOrigin, + }) + .where(eq(schema.dmChannels.id, channelId)) + .run(); + + const transferPayload: FederationRelayEvent = { + eventType: 'ownership_transfer', + dmChannelId: channelId, + messageId: `ownership_transfer:${nextOwner.userId}:${Date.now()}`, + federatedId: dmChannel.federatedId, + encryptionVersion: 0, + timestamp: Date.now(), + ownership: { + newOwner: { + homeUserId: newOwnerUser?.homeUserId || nextOwner.userId, + homeInstance: newOwnerUser?.homeInstance || domainOrigin, + }, + previousOwner: { + homeUserId: actorUserRow?.homeUserId || actorUserId, + homeInstance: actorUserRow?.homeInstance || domainOrigin, + }, + }, + }; + + appendMutationLog( + transferPayload.messageId, + channelId, + 'ownership_transfer', + JSON.stringify(transferPayload), + ); + queueOutboxEvent( + transferPayload.messageId, + channelId, + 'ownership_transfer', + JSON.stringify(transferPayload), + fedTargetOrigins, + ); + } + } + } + + // Broadcast dm_member_removed to remaining members + for (const member of remainingMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_member_removed', + dmChannelId: channelId, + userId: targetUserId, + }); + } + } else if (isSelfLeave) { + // Last member left — soft-delete for deferred GC (24h grace period). + // Only reachable via self-leave; kicks always leave the owner behind. + db.update(schema.dmChannels) + .set({ deletedAt: Date.now() }) + .where(eq(schema.dmChannels.id, channelId)) + .run(); + console.log(`[dm] Group DM ${channelId} has no remaining members, soft-deleted for GC`); + } +} + export async function dmRoutes(app: FastifyInstance): Promise { // Centralized auth for all DM routes app.addHook('preHandler', authenticate); @@ -1591,240 +1863,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { connectionManager.clearVoiceUserStatus(request.userId); } - // Compute federation targets BEFORE member deletion so the leaving user's peer is included - let fedTargetOrigins: string[] | undefined; - let leavingUser: typeof schema.users.$inferSelect | undefined; - if (isFederationRelayEnabled() && dmChannel?.federatedId) { - fedTargetOrigins = getGroupDmTargetOrigins(id); - leavingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get() ?? undefined; - } - - // Insert system message for member leaving (before deletion so they're still a member) - const leavingUserRow = leavingUser ?? db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - const leaveBaseName = leavingUserRow?.username?.includes('@') ? leavingUserRow.username.split('@')[0] : (leavingUserRow?.username ?? 'Unknown'); - const leaveSysMsgId = generateSnowflake(); - const leaveNow = Date.now(); - - db.insert(schema.dmMessages).values({ - id: leaveSysMsgId, - dmChannelId: id, - userId: request.userId, - content: JSON.stringify({ - event: 'member_removed', - targetUserId: request.userId, - targetDisplayName: leavingUserRow?.displayName ?? leaveBaseName, - reason: 'leave', - }), - type: 'system', - createdAt: leaveNow, - }).run(); - - connectionManager.sendToDmMembers(id, { - type: 'dm_message_created', - message: { - id: leaveSysMsgId, - dmChannelId: id, - userId: request.userId, - content: JSON.stringify({ - event: 'member_removed', - targetUserId: request.userId, - targetDisplayName: leavingUserRow?.displayName ?? leaveBaseName, - reason: 'leave', - }), - type: 'system', - createdAt: leaveNow, - user: leavingUserRow ? sanitizeUser(leavingUserRow) : undefined, - attachments: [], - embeds: [], - reactions: [], - } as any, - }); - - // Delete dm_members row - db.delete(schema.dmMembers) - .where(and( - eq(schema.dmMembers.dmChannelId, id), - eq(schema.dmMembers.userId, request.userId), - )) - .run(); - - // Clean up read_states for the departing user - db.delete(schema.readStates).where(and( - eq(schema.readStates.userId, request.userId), - eq(schema.readStates.channelId, id), - )).run(); - - // Federation: relay member_remove (leave) to peers - if (isFederationRelayEnabled() && dmChannel?.federatedId) { - const domainOrigin = getOurOrigin(); - - const memberRemovePayload: FederationRelayEvent = { - eventType: 'member_remove', - dmChannelId: id, - messageId: `member_remove:${request.userId}:${Date.now()}`, - federatedId: dmChannel.federatedId, - encryptionVersion: 0, - timestamp: Date.now(), - membership: { - user: { - homeUserId: leavingUser?.homeUserId || request.userId, - homeInstance: leavingUser?.homeInstance || domainOrigin, - }, - removedBy: { - homeUserId: leavingUser?.homeUserId || request.userId, - homeInstance: leavingUser?.homeInstance || domainOrigin, - }, - reason: 'leave', - }, - }; - - appendMutationLog( - memberRemovePayload.messageId, - id, - 'member_remove', - JSON.stringify(memberRemovePayload), - ); - queueOutboxEvent( - memberRemovePayload.messageId, - id, - 'member_remove', - JSON.stringify(memberRemovePayload), - fedTargetOrigins, - ); - } - - // Check remaining members - const remainingMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, id)) - .all(); - - if (remainingMembers.length > 0) { - // Transfer ownership if the leaving user was the owner - const nextOwner = remainingMembers[0]; - if (dmChannel && dmChannel.ownerId === request.userId && nextOwner) { - db.update(schema.dmChannels) - .set({ ownerId: nextOwner.userId }) - .where(eq(schema.dmChannels.id, id)) - .run(); - - // Broadcast ownership change via dedicated event - for (const member of remainingMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_owner_updated', - dmChannelId: id, - newOwnerId: nextOwner.userId, - }); - } - - // Query new owner user outside federation block so it's available for system message - const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get(); - - // Insert system message for ownership transfer - const newOwnerBaseName = newOwnerUser?.username?.includes('@') ? newOwnerUser.username.split('@')[0] : (newOwnerUser?.username ?? 'Unknown'); - const ownerSysMsgId = generateSnowflake(); - const ownerNow = Date.now(); - - db.insert(schema.dmMessages).values({ - id: ownerSysMsgId, - dmChannelId: id, - userId: request.userId, - content: JSON.stringify({ - event: 'owner_changed', - newOwnerId: nextOwner.userId, - newOwnerDisplayName: newOwnerUser?.displayName ?? newOwnerBaseName, - }), - type: 'system', - createdAt: ownerNow, - }).run(); - - for (const member of remainingMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_message_created', - message: { - id: ownerSysMsgId, - dmChannelId: id, - userId: request.userId, - content: JSON.stringify({ - event: 'owner_changed', - newOwnerId: nextOwner.userId, - newOwnerDisplayName: newOwnerUser?.displayName ?? newOwnerBaseName, - }), - type: 'system', - createdAt: ownerNow, - user: leavingUserRow ? sanitizeUser(leavingUserRow) : undefined, - attachments: [], - embeds: [], - reactions: [], - } as any, - }); - } - - // Federation: relay ownership transfer - if (isFederationRelayEnabled() && dmChannel?.federatedId) { - const domainOrigin = getOurOrigin(); - const prevOwnerUser = leavingUser; // Already queried above before member deletion - - // Update federated owner columns - db.update(schema.dmChannels) - .set({ - ownerHomeUserId: newOwnerUser?.homeUserId || nextOwner.userId, - ownerHomeInstance: newOwnerUser?.homeInstance || domainOrigin, - }) - .where(eq(schema.dmChannels.id, id)) - .run(); - - const transferPayload: FederationRelayEvent = { - eventType: 'ownership_transfer', - dmChannelId: id, - messageId: `ownership_transfer:${nextOwner.userId}:${Date.now()}`, - federatedId: dmChannel.federatedId, - encryptionVersion: 0, - timestamp: Date.now(), - ownership: { - newOwner: { - homeUserId: newOwnerUser?.homeUserId || nextOwner.userId, - homeInstance: newOwnerUser?.homeInstance || domainOrigin, - }, - previousOwner: { - homeUserId: prevOwnerUser?.homeUserId || request.userId, - homeInstance: prevOwnerUser?.homeInstance || domainOrigin, - }, - }, - }; - - appendMutationLog( - transferPayload.messageId, - id, - 'ownership_transfer', - JSON.stringify(transferPayload), - ); - queueOutboxEvent( - transferPayload.messageId, - id, - 'ownership_transfer', - JSON.stringify(transferPayload), - fedTargetOrigins, - ); - } - } - - // Broadcast dm_member_removed to remaining members - for (const member of remainingMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_member_removed', - dmChannelId: id, - userId: request.userId, - }); - } - } else { - // Last member left — soft-delete for deferred GC (24h grace period) - db.update(schema.dmChannels) - .set({ deletedAt: Date.now() }) - .where(eq(schema.dmChannels.id, id)) - .run(); - console.log(`[dm] Group DM ${id} has no remaining members, soft-deleted for GC`); - } + removeDmMember(id, request.userId, request.userId, 'leave'); // Send dm_channel_closed to the leaving user connectionManager.sendToUser(request.userId, { @@ -1835,6 +1874,71 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ success: true }); }); + // DELETE /api/dm/:id/members/:targetUserId - Owner kicks a member from a group DM + app.delete<{ Params: { id: string; targetUserId: string } }>('/api/dm/:id/members/:targetUserId', async (request, reply) => { + const { id, targetUserId } = request.params; + const db = getDb(); + + // Channel must exist (and not be soft-deleted) + const dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get(); + if (!dmChannel) { + return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); + } + + // 1-on-1 DM rejection (ownerId=NULL signals 1-on-1) + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot kick from a 1-on-1 DM', statusCode: 400 }); + } + + // Caller must be the owner + if (dmChannel.ownerId !== request.userId) { + return reply.code(403).send({ error: 'Only the group owner can remove members', statusCode: 403 }); + } + + // Owner cannot kick themselves + if (targetUserId === request.userId) { + return reply.code(400).send({ error: 'Owners cannot kick themselves; use leave instead', statusCode: 400 }); + } + + // Target must be a current member + if (!isDmMember(id, targetUserId)) { + return reply.code(404).send({ error: 'Target user is not a member of this DM channel', statusCode: 404 }); + } + + // If kicked user is in this DM's voice room, evict them first so the call state stays consistent + const targetVoiceRoom = connectionManager.getUserRoom(targetUserId); + if (targetVoiceRoom && targetVoiceRoom.roomId === id) { + const left = connectionManager.leaveCurrentRoom(targetUserId); + if (left) { + connectionManager.sendToDmMembers(id, { + type: 'voice_state_update', + channelId: id, + userId: targetUserId, + action: 'leave', + }); + const updatedRoom = connectionManager.getRoom(id); + if (updatedRoom && updatedRoom.participants.size === 0) { + connectionManager.destroyRoom(id); + connectionManager.sendToDmMembers(id, { + type: 'dm_call_ended', + dmChannelId: id, + }); + } + } + connectionManager.clearVoiceUserStatus(targetUserId); + } + + removeDmMember(id, request.userId, targetUserId, 'kick'); + + // Notify the kicked user that they no longer have access to this channel + connectionManager.sendToUser(targetUserId, { + type: 'dm_channel_closed', + dmChannelId: id, + }); + + return reply.code(200).send({ success: true }); + }); + // GET /api/dm/:id/messages - Get DM messages with pagination app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', async (request, reply) => { const { id } = request.params;