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
This commit is contained in:
@@ -607,6 +607,8 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
console.error('Federation mutation log backfill failed (non-fatal):', err);
|
console.error('Federation mutation log backfill failed (non-fatal):', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateFixOneOnOneOwnerIds(db);
|
||||||
|
|
||||||
console.log('Migrations complete.');
|
console.log('Migrations complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,3 +1570,24 @@ function migrateDmChannelsFederatedId(db: Database.Database): void {
|
|||||||
console.error('migrateDmChannelsFederatedId: owner backfill failed (non-fatal):', err);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
tx.insert(schema.dmChannels).values({
|
tx.insert(schema.dmChannels).values({
|
||||||
id: dmChannelId,
|
id: dmChannelId,
|
||||||
ownerId: request.userId,
|
ownerId: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const result: DmChannel = {
|
const result: DmChannel = {
|
||||||
id: dmChannelId,
|
id: dmChannelId,
|
||||||
ownerId: request.userId,
|
ownerId: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
members,
|
members,
|
||||||
lastMessage: null,
|
lastMessage: null,
|
||||||
@@ -537,12 +537,16 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
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();
|
let dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get();
|
||||||
if (!dmChannel) {
|
if (!dmChannel) {
|
||||||
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
|
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 });
|
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<void> {
|
|||||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
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
|
// Fetch channel — 1-on-1 DMs (ownerId=NULL) cannot be left, only closed
|
||||||
const memberRows = db.select()
|
const dmChannel = db.select().from(schema.dmChannels).where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))).get();
|
||||||
.from(schema.dmMembers)
|
if (!dmChannel) {
|
||||||
.where(eq(schema.dmMembers.dmChannelId, id))
|
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
|
||||||
.all();
|
}
|
||||||
|
if (!dmChannel.ownerId) {
|
||||||
if (memberRows.length <= 2) {
|
return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use DELETE /api/dm/:id to close it.', statusCode: 400 });
|
||||||
return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use close instead.', statusCode: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is in this DM's VoiceRoom, leave it first
|
// If user is in this DM's VoiceRoom, leave it first
|
||||||
@@ -794,9 +797,6 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
connectionManager.clearVoiceUserStatus(request.userId);
|
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
|
// Compute federation targets BEFORE member deletion so the leaving user's peer is included
|
||||||
let fedTargetOrigins: string[] | undefined;
|
let fedTargetOrigins: string[] | undefined;
|
||||||
let leavingUser: typeof schema.users.$inferSelect | undefined;
|
let leavingUser: typeof schema.users.$inferSelect | undefined;
|
||||||
|
|||||||
@@ -516,6 +516,10 @@ export interface AddDmMemberRequest {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupDmRequest {
|
||||||
|
userIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateDmMessageRequest {
|
export interface CreateDmMessageRequest {
|
||||||
content?: string;
|
content?: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user