From bec4c2446f08620f2b7fb6310ab4f903ba01c38a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:16:55 +0100 Subject: [PATCH] fix(dm): restore ownerId=NULL semantics for 1-on-1 DM channels - Add migrateFixOneOnOneOwnerIds migration to NULL-out ownerId on all existing 1-on-1 DMs (those with exactly 2 members) - Fix POST /api/dm to create 1-on-1 channels with ownerId=null instead of the creator's ID - Guard POST /api/dm/:id/members: reject with 400 if channel has no owner (i.e. is a 1-on-1), directing callers to POST /api/dm/group - Guard DELETE /api/dm/:id/members: replace member-count check with ownerId check; remove now-duplicate dmChannel query in that handler - Add CreateGroupDmRequest type to shared types --- packages/server/src/db/migrate.ts | 23 +++++++++++++++++++++++ packages/server/src/routes/dm.ts | 30 +++++++++++++++--------------- packages/shared/src/types.ts | 4 ++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index d861549c..e5a3feca 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -607,6 +607,8 @@ export function runMigrations(db: Database.Database): void { console.error('Federation mutation log backfill failed (non-fatal):', err); } + migrateFixOneOnOneOwnerIds(db); + console.log('Migrations complete.'); } @@ -1568,3 +1570,24 @@ function migrateDmChannelsFederatedId(db: Database.Database): void { console.error('migrateDmChannelsFederatedId: owner backfill failed (non-fatal):', err); } } + +/** Fix ownerId on 1-on-1 DMs: should be NULL, not the creator's ID */ +function migrateFixOneOnOneOwnerIds(db: Database.Database): void { + try { + const result = db.prepare(` + UPDATE dm_channels SET owner_id = NULL + WHERE id IN ( + SELECT dm_channel_id FROM dm_members + GROUP BY dm_channel_id + HAVING COUNT(*) = 2 + ) + AND owner_id IS NOT NULL + `).run(); + + if (result.changes > 0) { + console.log(`[migrate] Fixed ownerId on ${result.changes} 1-on-1 DM channel(s) (set to NULL)`); + } + } catch (err) { + console.error('migrateFixOneOnOneOwnerIds failed (non-fatal):', err); + } +} diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index cbc09667..b7cfef7e 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -444,7 +444,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { db.transaction((tx) => { tx.insert(schema.dmChannels).values({ id: dmChannelId, - ownerId: request.userId, + ownerId: null, createdAt: now, }).run(); @@ -466,7 +466,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannelId, - ownerId: request.userId, + ownerId: null, createdAt: now, members, lastMessage: null, @@ -537,12 +537,16 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } - // Enforce DM channel ownership: only the owner can add members (for new-style group DMs) + // Fetch channel and enforce type + ownership constraints let 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 }); } - if (dmChannel.ownerId && dmChannel.ownerId !== request.userId) { + // 1-on-1 DMs (ownerId=NULL) are immutable — cannot add members + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot add members to a 1-on-1 DM. Use POST /api/dm/group to create a group.', statusCode: 400 }); + } + if (dmChannel.ownerId !== request.userId) { return reply.code(403).send({ error: 'Only the group owner can add members', statusCode: 403 }); } @@ -759,14 +763,13 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } - // Count members — can't leave a 1-on-1 - const memberRows = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, id)) - .all(); - - if (memberRows.length <= 2) { - return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use close instead.', statusCode: 400 }); + // Fetch channel — 1-on-1 DMs (ownerId=NULL) cannot be left, only closed + 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 }); + } + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use DELETE /api/dm/:id to close it.', statusCode: 400 }); } // If user is in this DM's VoiceRoom, leave it first @@ -794,9 +797,6 @@ export async function dmRoutes(app: FastifyInstance): Promise { connectionManager.clearVoiceUserStatus(request.userId); } - // Check DM channel ownership before leaving - const dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get(); - // 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; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 057dcf93..c316c9af 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -516,6 +516,10 @@ export interface AddDmMemberRequest { userId: string; } +export interface CreateGroupDmRequest { + userIds: string[]; +} + export interface CreateDmMessageRequest { content?: string; attachments?: string[];