Files
backspace/packages/server/src/routes/dm.ts
T
Jannis Braun 85e1975fa5 fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.

Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
  admin still short-circuit first, so they are unaffected).

Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
  invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
  space id against the local table so a spoofed spaceInstanceOrigin can't slip
  past it.
- InviteModal hides the invite affordances for request spaces.

Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.

Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.

Reported-by: BadAtCaptchas (#2)
2026-07-07 19:45:51 +02:00

2673 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from 'fastify';
import { eq, and, or, desc, lt, inArray, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isDmMember, isDeadOneOnOne } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import {
MAX_MESSAGE_LENGTH,
type DmChannel,
type DmMessage,
type DmMessageWithUser,
type CreateDmRequest,
type CreateGroupDmRequest,
type CreateDmMessageRequest,
type AddDmMemberRequest,
type PaginatedQuery,
type Attachment,
type Reaction,
type Embed,
type SpaceInviteRequest,
type SpaceInviteResponse,
type SpaceInviteSystemPayload,
} from '@backspace/shared';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
import { isValidAssetUrl } from './users.js';
import {
GROUP_DM_NAME_MIN_LENGTH,
GROUP_DM_NAME_MAX_LENGTH,
GROUP_DM_ICON_MAX_BYTES,
GROUP_DM_ICON_MIME_PREFIX,
} from '@backspace/shared/src/constants.js';
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
import {
appendMutationLog,
queueOutboxEvent,
queueDmRelay,
queueDmCloseRelay,
queueGroupMetadataRelay,
getDmParticipants,
getGroupDmTargetOrigins,
isFederationRelayEnabled,
computeFederatedId,
sendTypingRelay,
normalizeIconForWire,
} from '../utils/federationOutbox.js';
import { getOurOrigin, canonicalizeHomeInstance } from '../utils/federationAuth.js';
import { resolveOriginFromHostname } from '../utils/federationOriginResolve.js';
import type { FederationRelayEvent } from '@backspace/shared';
import { resolveLocalUser, resolveOrCreateReplicatedUser } from './federation.js';
/**
* Batch-fetch reactions for a set of DM message IDs.
* Returns a map from dmMessageId to Reaction[].
*/
export function fetchDmReactionsForMessages(dmMessageIds: string[]): Map<string, Reaction[]> {
if (dmMessageIds.length === 0) return new Map();
const db = getDb();
const reactionRows = db.select()
.from(schema.dmReactions)
.where(inArray(schema.dmReactions.dmMessageId, dmMessageIds))
.all();
// Batch fetch users for reactions
const reactionUserIds = [...new Set(reactionRows.map(r => r.userId))];
const reactionUsers = reactionUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, reactionUserIds)).all()
: [];
const reactionUserMap = new Map(reactionUsers.map(u => [u.id, u]));
const map = new Map<string, Reaction[]>();
for (const r of reactionRows) {
const user = reactionUserMap.get(r.userId);
const reaction: Reaction = {
id: r.id,
messageId: r.dmMessageId,
userId: r.userId,
emoji: r.emoji,
createdAt: r.createdAt,
user: user ? sanitizeUser(user) : undefined,
};
if (!map.has(r.dmMessageId)) {
map.set(r.dmMessageId, []);
}
map.get(r.dmMessageId)!.push(reaction);
}
return map;
}
/**
* Pure transformer: builds a DmMessageWithUser from pre-fetched data.
*/
export function buildDmMessageWithUser(
message: typeof schema.dmMessages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
reactions: Reaction[] = [],
replyTo: DmMessageWithUser | null = null,
embedRows: (typeof schema.embeds.$inferSelect)[] = [],
): DmMessageWithUser {
return {
id: message.id,
dmChannelId: message.dmChannelId,
userId: message.userId,
replyToId: message.replyToId,
content: message.content,
type: (message.type ?? 'user') as 'user' | 'system',
editedAt: message.editedAt,
createdAt: message.createdAt,
sourceMessageId: message.sourceMessageId ?? null,
sourceInstance: message.sourceInstance ?? null,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
messageId: a.dmMessageId ?? message.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
thumbnailFilename: a.thumbnailFilename ?? null,
width: a.width ?? null,
height: a.height ?? null,
duration: a.duration ?? null,
playable: a.playable ?? null,
federationStatus: a.federationStatus ?? null,
federationMeta: a.federationMeta ?? null,
createdAt: a.createdAt,
})),
embeds: embedRows.map(e => embedRowToEmbed(e)),
reactions,
replyTo,
};
}
/**
* Fetches a DM message by ID and hydrates it with user, attachments, reactions, and replyTo.
*/
export function getDmMessageWithUser(dmMessageId: string): DmMessageWithUser | null {
const db = getDb();
const message = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, dmMessageId)).get();
if (!message) return null;
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) return null;
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.dmMessageId, dmMessageId))
.all();
const embedRows = db.select()
.from(schema.embeds)
.where(eq(schema.embeds.dmMessageId, dmMessageId))
.all();
const reactionsMap = fetchDmReactionsForMessages([dmMessageId]);
const reactions = reactionsMap.get(dmMessageId) ?? [];
let replyTo: DmMessageWithUser | null = null;
if (message.replyToId) {
const replyMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, message.replyToId)).get();
if (replyMsg) {
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
if (replyUser) {
replyTo = {
id: replyMsg.id,
dmChannelId: replyMsg.dmChannelId,
userId: replyMsg.userId,
replyToId: replyMsg.replyToId,
content: replyMsg.content,
editedAt: replyMsg.editedAt,
createdAt: replyMsg.createdAt,
user: sanitizeUser(replyUser),
attachments: [],
embeds: [],
reactions: [],
};
}
}
}
return buildDmMessageWithUser(message, user, attachmentRows, reactions, replyTo, embedRows);
}
/**
* Broadcasts a DM message to all members of a DM channel.
* For members who have closed the channel (closed=1), also sends a
* dm_channel_created event to resurface the channel in their sidebar,
* and flips their closed flag back to 0.
*/
export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUser): void {
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
// Clear typing indicator for the message author — the message itself proves they stopped
for (const member of dmMembers) {
if (member.userId !== message.userId) {
connectionManager.sendToUser(member.userId, {
type: 'dm_typing_stop',
dmChannelId,
userId: message.userId,
});
}
}
// Relay typing stop to remote peers (fire-and-forget)
sendTypingRelay(dmChannelId, 'dm_typing_stop', message.userId);
for (const member of dmMembers) {
// If this member had closed the DM, resurface it first
if (member.closed === 1) {
db.update(schema.dmMembers)
.set({ closed: 0 })
.where(and(
eq(schema.dmMembers.dmChannelId, dmChannelId),
eq(schema.dmMembers.userId, member.userId),
))
.run();
// Build and send dm_channel_created so their sidebar picks it up
const allMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
const memberUserIds = allMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const dmChannel = db.select()
.from(schema.dmChannels)
.where(and(eq(schema.dmChannels.id, dmChannelId), isNull(schema.dmChannels.deletedAt)))
.get();
if (dmChannel) {
connectionManager.sendToUser(member.userId, {
type: 'dm_channel_created',
dmChannel: {
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
federatedId: dmChannel.federatedId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(u => sanitizeUser(u)),
lastMessage: message,
},
});
}
}
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message,
});
}
}
/**
* Find the existing 1-on-1 DM channel between two users, reopening it if
* the caller had it soft-closed. Creates a new channel atomically when none
* exists. Returns the channel id.
*
* Mirrors the dedup-or-create behavior of the existing `POST /api/dm`
* handler, including:
* - skipping group DMs (ownerId !== null) and channels with !== 2 members
* - excluding soft-deleted channels
* - reopening on the caller side and queueing a `dm_reopen` relay
* - computing a deterministic `federatedId` when either party is federated
* - notifying the target on a fresh create with the same `dm_channel_created`
* payload shape (`id`, `ownerId`, `federatedId`, `createdAt`, `members`,
* `lastMessage: null`).
*
* NOTE: This helper is intentionally duplicated from `POST /api/dm` rather
* than refactored out of it — mixing a behavior-preserving rewrite of a
* heavily-used path with new feature work is the regression pattern this
* codebase avoids.
*/
export function ensureOneOnOneDmChannel(
callerId: string,
targetUser: typeof schema.users.$inferSelect,
db: ReturnType<typeof getDb>,
): string {
// 1. Look for an existing 1-on-1 channel between caller and target.
const callerMemberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, callerId))
.all();
for (const membership of callerMemberships) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, membership.dmChannelId),
eq(schema.dmMembers.userId, targetUser.id),
))
.get();
if (!otherMember) continue;
// Only match 1-on-1 DMs (exactly 2 members). Skip group DMs that
// happen to include the target user.
const memberCount = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
.all()
.length;
if (memberCount !== 2) continue;
const dmChannel = db.select()
.from(schema.dmChannels)
.where(and(
eq(schema.dmChannels.id, membership.dmChannelId),
isNull(schema.dmChannels.deletedAt),
))
.get();
if (!dmChannel) continue;
if (dmChannel.ownerId !== null) continue; // belt-and-braces: skip group DMs
// Reopen if caller had it closed
if (membership.closed === 1) {
db.update(schema.dmMembers)
.set({ closed: 0 })
.where(and(
eq(schema.dmMembers.dmChannelId, membership.dmChannelId),
eq(schema.dmMembers.userId, callerId),
))
.run();
// Relay reopen to federated peers
queueDmCloseRelay(membership.dmChannelId, callerId, 'dm_reopen');
}
return dmChannel.id;
}
// 2. Create new channel atomically.
const dmChannelId = generateSnowflake();
const now = Date.now();
// Compute deterministic federatedId for federated 1-on-1 DMs so that the
// S2S relay can find this channel when the reply arrives, preventing duplicates.
let federatedId: string | null = null;
if (isFederationRelayEnabled()) {
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, callerId)).get();
const callerHomeUserId = callerUser?.homeUserId || callerId;
const targetHomeUserId = targetUser.homeUserId || targetUser.id;
const callerHomeInstance = callerUser?.homeInstance || null;
const targetHomeInstance = targetUser.homeInstance || null;
if (callerHomeInstance || targetHomeInstance) {
federatedId = computeFederatedId(callerHomeUserId, targetHomeUserId);
}
}
db.transaction((tx) => {
tx.insert(schema.dmChannels).values({
id: dmChannelId,
ownerId: null,
federatedId,
createdAt: now,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: callerId,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: targetUser.id,
}).run();
});
// Notify the target so their sidebar updates — same payload shape as POST /api/dm.
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, callerId)).get();
const members = [callerUser, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(u => sanitizeUser(u));
const payload: DmChannel = {
id: dmChannelId,
ownerId: null,
federatedId: federatedId ?? null,
createdAt: now,
members,
lastMessage: null,
};
connectionManager.sendToUser(targetUser.id, {
type: 'dm_channel_created',
dmChannel: payload,
});
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).
*/
/**
* Evicts a user from a DM channel's voice room (if they're in it), broadcasts the
* voice_state_update, and tears down the room/call when it becomes empty.
*
* Shared by self-leave and owner-kick paths so call-state cleanup stays in lockstep.
* No-op if the user isn't currently in this DM's voice room.
*/
function evictUserFromDmVoiceRoom(channelId: string, userId: string): void {
const userRoom = connectionManager.getUserRoom(userId);
if (userRoom && userRoom.roomId === channelId) {
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
connectionManager.sendToDmMembers(channelId, {
type: 'voice_state_update',
channelId,
userId,
action: 'leave',
});
// Auto-end call if DM room is now empty
const updatedRoom = connectionManager.getRoom(channelId);
if (updatedRoom && updatedRoom.participants.size === 0) {
connectionManager.destroyRoom(channelId);
connectionManager.sendToDmMembers(channelId, {
type: 'dm_call_ended',
dmChannelId: channelId,
});
}
}
connectionManager.clearVoiceUserStatus(userId);
}
}
/**
* Transfers group-DM ownership from `previousOwnerId` to `newOwnerId`.
*
* Shared by the leave-path's auto-transfer (when an owner leaves a non-empty group)
* and the manual `POST /api/dm/:id/transfer` endpoint. Performs:
*
* 1. Channel UPDATE (ownerId + ownerHomeUserId + ownerHomeInstance)
* 2. owner_changed system-message INSERT (authored by `actorUserId`)
* 3. appendMutationLog + queueOutboxEvent for the `ownership_transfer` federation event
*
* Steps 13 run inside a single `db.transaction(...)` for atomicity. Broadcasts
* (`dm_owner_updated` and `dm_message_created`) are side-effects and run AFTER
* the transaction commits.
*
* Caller responsibilities (NOT done here):
* - Validation (membership, 1-on-1 rejection, self-transfer rejection, etc.)
* - Voice-room eviction
* - Member-row deletion (the leave-path handles this separately)
*
* `actorUserId` controls the `userId` on the inserted system message — i.e., who
* appears as having performed the change. In both current call sites the actor IS
* the previous owner (the leaver, or the owner calling the transfer endpoint), so
* it defaults to `previousOwnerId`. The option is parameterizable for future paths
* that may transfer ownership on behalf of someone else (e.g., admin tooling).
*/
function transferGroupDmOwnership(
channelId: string,
previousOwnerId: string,
newOwnerId: string,
options?: { actorUserId?: string },
): void {
const db = getDb();
const actorUserId = options?.actorUserId ?? previousOwnerId;
// Pre-flight: fetch the channel + user rows the helper needs.
const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, channelId)).get();
if (!dmChannel) {
// Caller validates; defensive no-op.
return;
}
const previousOwnerRow = db.select().from(schema.users).where(eq(schema.users.id, previousOwnerId)).get();
const newOwnerRow = db.select().from(schema.users).where(eq(schema.users.id, newOwnerId)).get();
const actorUserRow = actorUserId === previousOwnerId
? previousOwnerRow
: db.select().from(schema.users).where(eq(schema.users.id, actorUserId)).get();
const newOwnerBaseName = newOwnerRow?.username?.includes('@')
? newOwnerRow.username.split('@')[0]
: (newOwnerRow?.username ?? 'Unknown');
const newOwnerDisplayName = newOwnerRow?.displayName ?? newOwnerBaseName;
// Federation targets must be computed BEFORE the channel update so we use the
// current home-identity columns; membership doesn't change in a transfer, so a
// post-transaction snapshot would also be valid, but pre-computing here keeps
// the federation gate close to the data it depends on.
let fedTargetOrigins: string[] | undefined;
const federationActive = isFederationRelayEnabled() && !!dmChannel.federatedId;
if (federationActive) {
fedTargetOrigins = getGroupDmTargetOrigins(channelId);
}
const domainOrigin = isFederationRelayEnabled() ? getOurOrigin() : null;
const newOwnerHomeUserId = newOwnerRow?.homeUserId || newOwnerId;
// Canonicalize to a full origin URL. `users.homeInstance` is stored as a
// bare host (e.g. `orbit.ddns.net`) for federated users, but
// `dm_channels.ownerHomeInstance` is compared against `sourceInstance`
// (always a full URL) in S2S authority checks. Storing the bare form here
// caused legitimate `ownership_transfer` events to be rejected with
// `unauthorized_source` after back-and-forth transfers — see the historical
// bug entry in `docs/systems/dm-system.md`.
const newOwnerHomeInstance = canonicalizeHomeInstance(
newOwnerRow?.homeInstance || domainOrigin || '',
);
const ownerSysMsgId = generateSnowflake();
const ownerNow = Date.now();
const ownerSysContent = JSON.stringify({
event: 'owner_changed',
newOwnerId,
newOwnerDisplayName,
});
// Wire homeInstance values are canonicalized to full URLs so receivers store
// the canonical form too. Future authority checks then compare full-URL to
// full-URL without needing defensive normalization at every site.
const previousOwnerHomeInstanceWire =
canonicalizeHomeInstance(previousOwnerRow?.homeInstance || domainOrigin || '') ?? '';
const transferPayload: FederationRelayEvent | null = federationActive
? {
eventType: 'ownership_transfer',
dmChannelId: channelId,
messageId: `ownership_transfer:${newOwnerId}:${ownerNow}`,
federatedId: dmChannel.federatedId!,
encryptionVersion: 0,
timestamp: ownerNow,
ownership: {
newOwner: {
homeUserId: newOwnerHomeUserId,
homeInstance: newOwnerHomeInstance ?? (domainOrigin ?? ''),
},
previousOwner: {
homeUserId: previousOwnerRow?.homeUserId || previousOwnerId,
homeInstance: previousOwnerHomeInstanceWire,
},
},
}
: null;
// Atomic: channel UPDATE + system message INSERT + mutation log + outbox queue.
db.transaction((tx) => {
tx.update(schema.dmChannels)
.set({
ownerId: newOwnerId,
ownerHomeUserId: newOwnerHomeUserId,
ownerHomeInstance: newOwnerHomeInstance || null,
})
.where(eq(schema.dmChannels.id, channelId))
.run();
tx.insert(schema.dmMessages).values({
id: ownerSysMsgId,
dmChannelId: channelId,
userId: actorUserId,
content: ownerSysContent,
type: 'system',
createdAt: ownerNow,
}).run();
if (transferPayload) {
appendMutationLog(
transferPayload.messageId,
channelId,
'ownership_transfer',
JSON.stringify(transferPayload),
);
queueOutboxEvent(
transferPayload.messageId,
channelId,
'ownership_transfer',
JSON.stringify(transferPayload),
fedTargetOrigins,
);
}
});
// Snapshot the post-update member list for broadcasts (membership unchanged by transfer).
const members = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, channelId))
.all();
// Broadcast dm_owner_updated to local members. Include the new owner's
// home identity so receiving clients can update `dm.ownerHomeInstance`
// (and thus keep `getOwnerInstanceForDm` correct for the next owner-only
// request) without waiting for a fresh `ready` payload on reconnect.
for (const member of members) {
connectionManager.sendToUser(member.userId, {
type: 'dm_owner_updated',
dmChannelId: channelId,
newOwnerId,
newOwnerHomeUserId,
newOwnerHomeInstance: newOwnerHomeInstance ?? null,
});
}
// Broadcast the owner_changed system message to local members.
for (const member of members) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message: {
id: ownerSysMsgId,
dmChannelId: channelId,
userId: actorUserId,
content: ownerSysContent,
type: 'system',
createdAt: ownerNow,
user: actorUserRow ? sanitizeUser(actorUserRow) : undefined,
attachments: [],
embeds: [],
reactions: [],
} as any,
});
}
}
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 && dmChannel.ownerId === actorUserId) {
const nextOwner = remainingMembers[0];
if (nextOwner) {
transferGroupDmOwnership(channelId, actorUserId, nextOwner.userId, { actorUserId });
}
}
// 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<void> {
// Centralized auth for all DM routes
app.addHook('preHandler', authenticate);
// GET /api/dm - List user's DM channels
app.get('/api/dm', async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.userId, request.userId),
eq(schema.dmMembers.closed, 0),
))
.all();
if (memberships.length === 0) {
return reply.code(200).send([]);
}
const dmChannelIds = memberships.map(m => m.dmChannelId);
// Batch fetch all DM channels (exclude soft-deleted)
const channelRows = db.select().from(schema.dmChannels)
.where(and(inArray(schema.dmChannels.id, dmChannelIds), isNull(schema.dmChannels.deletedAt))).all();
const channelMap = new Map(channelRows.map(c => [c.id, c]));
// Batch fetch all DM members
const allMemberRows = db.select().from(schema.dmMembers)
.where(inArray(schema.dmMembers.dmChannelId, dmChannelIds)).all();
const membersByChannel = new Map<string, string[]>();
for (const m of allMemberRows) {
if (!membersByChannel.has(m.dmChannelId)) membersByChannel.set(m.dmChannelId, []);
membersByChannel.get(m.dmChannelId)!.push(m.userId);
}
// Batch fetch all unique users
const allUserIds = [...new Set(allMemberRows.map(m => m.userId))];
const userRows = allUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
: [];
const userMap = new Map(userRows.map(u => [u.id, u]));
// Batch fetch last message per DM channel:
// Get the max created_at per channel, then fetch matching messages
const maxTimestamps = db.select({
dmChannelId: schema.dmMessages.dmChannelId,
maxCreatedAt: sql<number>`MAX(${schema.dmMessages.createdAt})`.as('max_created_at'),
})
.from(schema.dmMessages)
.where(inArray(schema.dmMessages.dmChannelId, dmChannelIds))
.groupBy(schema.dmMessages.dmChannelId)
.all();
const lastMessageMap = new Map<string, { id: string; dmChannelId: string; userId: string; content: string | null; createdAt: number; type: 'user' | 'system' }>();
if (maxTimestamps.length > 0) {
// Build conditions to fetch the actual message rows matching max timestamps
const conditions = maxTimestamps.map(t =>
and(eq(schema.dmMessages.dmChannelId, t.dmChannelId), eq(schema.dmMessages.createdAt, t.maxCreatedAt!))
);
const lastMessages = db.select()
.from(schema.dmMessages)
.where(or(...conditions))
.all();
for (const m of lastMessages) {
// In case of ties, keep the first one per channel
if (!lastMessageMap.has(m.dmChannelId)) {
lastMessageMap.set(m.dmChannelId, {
id: m.id, dmChannelId: m.dmChannelId, userId: m.userId,
content: m.content, createdAt: m.createdAt,
type: m.type === 'system' ? 'system' : 'user',
});
}
}
}
// Batch fetch attachments for last messages
const lastMsgIds = [...lastMessageMap.values()].map(m => m.id);
const lastMsgAttachments = lastMsgIds.length > 0
? db.select({
dmMessageId: schema.attachments.dmMessageId,
type: schema.attachments.mimetype,
filename: schema.attachments.originalName,
}).from(schema.attachments).where(inArray(schema.attachments.dmMessageId, lastMsgIds)).all()
: [];
const lastMsgAttachmentMap = new Map<string, Array<{ type: string; filename: string }>>();
for (const a of lastMsgAttachments) {
if (!a.dmMessageId) continue;
const arr = lastMsgAttachmentMap.get(a.dmMessageId) ?? [];
arr.push({ type: a.type, filename: a.filename });
lastMsgAttachmentMap.set(a.dmMessageId, arr);
}
// Assemble results
const dmChannels: DmChannel[] = [];
for (const channelId of dmChannelIds) {
const channel = channelMap.get(channelId);
if (!channel) continue;
const memberIds = membersByChannel.get(channelId) ?? [];
const members = memberIds
.map(id => userMap.get(id))
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(u => sanitizeUser(u));
const lastMsg = lastMessageMap.get(channelId) ?? null;
dmChannels.push({
id: channel.id,
ownerId: channel.ownerId ?? null,
createdAt: channel.createdAt,
members,
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
userId: lastMsg.userId,
content: lastMsg.content,
createdAt: lastMsg.createdAt,
type: lastMsg.type,
attachments: lastMsgAttachmentMap.get(lastMsg.id) ?? [],
} : null,
});
}
// Sort by last message timestamp (newest first)
dmChannels.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
return bTime - aTime;
});
return reply.code(200).send(dmChannels);
});
// POST /api/dm - Create or get existing DM channel
app.post<{ Body: CreateDmRequest }>('/api/dm', async (request, reply) => {
const { userId, homeUserId, homeInstance } = request.body;
const db = getDb();
let targetUser: typeof schema.users.$inferSelect | undefined;
if (homeUserId && homeInstance) {
// Limbo-window guard (federation instance-epoch self-healing §5.3).
// If the target's home instance was reset-detected but the admin has not yet
// re-peered, an UNRESOLVED `federation_reset_events` row exists for its origin.
// Creating a DM now would bind to stale, dead-incarnation identity state, so
// surface a clear `peer_reset_pending` instead of silently forming a doomed
// channel. Checked BEFORE stub creation so no un-flagged stub is left behind.
//
// The journal is keyed by the peer's `federation_peers.origin` (the exact string
// `markPeerReset` stores). `resolveOriginFromHostname` returns that stored origin
// verbatim, giving an O(1) point lookup on the origin PRIMARY KEY; the common case
// (no reset) is a single indexed miss and the normal path proceeds unchanged.
const canon = canonicalizeHomeInstance(homeInstance);
let peerOrigin: string | null = null;
if (canon) {
try {
peerOrigin = resolveOriginFromHostname(new URL(canon).host);
} catch {
peerOrigin = null;
}
}
if (peerOrigin) {
const pendingReset = db
.select({ origin: schema.federationResetEvents.origin })
.from(schema.federationResetEvents)
.where(and(
eq(schema.federationResetEvents.origin, peerOrigin),
isNull(schema.federationResetEvents.resolvedAt),
))
.get();
if (pendingReset) {
return reply.code(409).send({ error: 'peer_reset_pending', statusCode: 409 });
}
}
// Federated identity: resolve or create a replicated user stub
targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined;
} else if (userId && typeof userId === 'string') {
// Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
} else {
return reply.code(400).send({ error: 'userId or (homeUserId + homeInstance) is required', statusCode: 400 });
}
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
const targetUserId = targetUser.id;
if (targetUserId === request.userId) {
return reply.code(400).send({ error: 'Cannot create DM with yourself', statusCode: 400 });
}
// Check if DM channel already exists between these two users
// (both have membership rows, regardless of closed state)
const myDms = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
for (const myDm of myDms) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
eq(schema.dmMembers.userId, targetUserId),
))
.get();
if (otherMember) {
// Only match 1-on-1 DMs (exactly 2 members). Skip group DMs that
// happen to include the target user to avoid returning the wrong channel.
const memberCount = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all()
.length;
if (memberCount !== 2) continue;
// DM channel already exists between these users (exclude soft-deleted)
const dmChannel = db.select()
.from(schema.dmChannels)
.where(and(eq(schema.dmChannels.id, myDm.dmChannelId), isNull(schema.dmChannels.deletedAt)))
.get();
if (!dmChannel) continue;
// Reopen if the requesting user had closed it
if (myDm.closed === 1) {
db.update(schema.dmMembers)
.set({ closed: 0 })
.where(and(
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
eq(schema.dmMembers.userId, request.userId),
))
.run();
// Relay reopen to federated peers
queueDmCloseRelay(myDm.dmChannelId, request.userId, 'dm_reopen');
}
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
// Fetch actual last message
const lastMsgRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, myDm.dmChannelId))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMsg = lastMsgRows[0] ?? null;
const result: DmChannel = {
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
federatedId: dmChannel.federatedId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(u => sanitizeUser(u)),
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
userId: lastMsg.userId,
content: lastMsg.content,
createdAt: lastMsg.createdAt,
type: lastMsg.type === 'system' ? 'system' : 'user',
} : null,
};
return reply.code(200).send(result);
}
}
// Create new DM channel with both members atomically
const dmChannelId = generateSnowflake();
const now = Date.now();
// Compute deterministic federatedId for federated 1-on-1 DMs so that the
// S2S relay can find this channel when the reply arrives, preventing duplicates.
let federatedId: string | null = null;
if (isFederationRelayEnabled()) {
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const callerHomeUserId = callerUser?.homeUserId || request.userId;
const targetHomeUserId = targetUser.homeUserId || targetUser.id;
const callerHomeInstance = callerUser?.homeInstance || null;
const targetHomeInstance = targetUser.homeInstance || null;
// If either user is federated, this DM needs a federatedId for S2S relay matching
if (callerHomeInstance || targetHomeInstance) {
federatedId = computeFederatedId(callerHomeUserId, targetHomeUserId);
}
}
db.transaction((tx) => {
tx.insert(schema.dmChannels).values({
id: dmChannelId,
ownerId: null,
federatedId,
createdAt: now,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: request.userId,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: targetUserId,
}).run();
});
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const members = [currentUserRow, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(u => sanitizeUser(u));
const result: DmChannel = {
id: dmChannelId,
ownerId: null,
federatedId: federatedId ?? null,
createdAt: now,
members,
lastMessage: null,
};
// Broadcast dm_channel_created to the other user so their sidebar updates
connectionManager.sendToUser(targetUserId, {
type: 'dm_channel_created',
dmChannel: result,
});
return reply.code(201).send(result);
});
// POST /api/dm/group - Create a new group DM with multiple members
app.post<{ Body: CreateGroupDmRequest }>('/api/dm/group', async (request, reply) => {
const { users: userIdentities, fromDmChannelId } = request.body;
// Validate input is a non-empty array of at least 2 identity objects
if (!Array.isArray(userIdentities) || userIdentities.length < 2) {
return reply.code(400).send({ error: 'users must contain at least 2 entries', statusCode: 400 });
}
if (userIdentities.some(u => !u || typeof u.id !== 'string' || !u.id)) {
return reply.code(400).send({ error: 'All entries must have a non-empty id', statusCode: 400 });
}
// Total members (caller + users) capped at 10
const totalMembers = 1 + userIdentities.length;
if (totalMembers > 10) {
return reply.code(400).send({ error: `Group DMs are limited to 10 members (requested ${totalMembers})`, statusCode: 400 });
}
const db = getDb();
// Resolve each identity to a local user row
const targetUsers: Array<typeof schema.users.$inferSelect> = [];
for (const identity of userIdentities) {
let localUser: typeof schema.users.$inferSelect | undefined;
if (identity.homeUserId && identity.homeInstance) {
// Federated user — resolve via homeUserId, creating a replicated stub if needed
localUser = resolveOrCreateReplicatedUser(identity.homeUserId, identity.homeInstance, db) ?? undefined;
} else {
// Local user — direct ID lookup
localUser = db.select().from(schema.users).where(
and(eq(schema.users.id, identity.id), eq(schema.users.isDeleted, 0)),
).get();
// Fallback: if direct ID lookup fails, try homeUserId resolution
// (handles case where identity.id is a remote snowflake but homeUserId wasn't provided)
if (!localUser) {
localUser = resolveLocalUser(identity.id, db);
}
}
if (!localUser) {
return reply.code(404).send({ error: 'One or more users not found', statusCode: 404 });
}
if (localUser.isDeleted) {
return reply.code(404).send({ error: 'One or more users not found', statusCode: 404 });
}
targetUsers.push(localUser);
}
// Dedup: check no resolved user appears twice
const resolvedIds = new Set(targetUsers.map(u => u.id));
if (resolvedIds.size !== targetUsers.length) {
return reply.code(400).send({ error: 'Duplicate users after identity resolution', statusCode: 400 });
}
// Caller cannot include themselves
if (targetUsers.some(u => u.id === request.userId)) {
return reply.code(400).send({ error: 'Do not include yourself — you are added automatically', statusCode: 400 });
}
// When converting a 1-on-1 DM to a group, existing DM members are exempt
// from the friendship check (DMs don't require friendship).
const exemptUserIds = new Set<string>();
if (fromDmChannelId) {
const sourceDm = db.select().from(schema.dmChannels).where(
and(eq(schema.dmChannels.id, fromDmChannelId), isNull(schema.dmChannels.deletedAt)),
).get();
if (sourceDm && !sourceDm.ownerId) {
// Only exempt members from 1-on-1 DMs (ownerId is null)
if (isDmMember(fromDmChannelId, request.userId)) {
const members = db.select().from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, fromDmChannelId)).all();
for (const m of members) {
exemptUserIds.add(m.userId);
}
}
}
}
// Validate all target users are friends with the caller (exempt existing DM members)
for (const targetUser of targetUsers) {
if (exemptUserIds.has(targetUser.id)) continue;
const friendship = db.select().from(schema.friends).where(
or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId)),
),
).get();
if (!friendship) {
return reply.code(403).send({ error: `You can only add friends to group DMs. ${targetUser.displayName ?? targetUser.username} is not your friend.`, statusCode: 403 });
}
}
// Create group DM channel + members in a single transaction
const dmChannelId = generateSnowflake();
const now = Date.now();
db.transaction((tx) => {
tx.insert(schema.dmChannels).values({
id: dmChannelId,
ownerId: request.userId,
createdAt: now,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: request.userId,
}).run();
for (const targetUser of targetUsers) {
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: targetUser.id,
}).run();
}
});
// Fetch caller's user row once — used for response, federation, and relay
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Federation: assign federatedId if any member is from a remote instance
let federatedId: string | null = null;
if (isFederationRelayEnabled()) {
const domainOrigin = getOurOrigin();
const allUsers = [callerUser, ...targetUsers].filter((u): u is NonNullable<typeof u> => u !== undefined);
const hasRemote = allUsers.some(u => u.homeInstance && u.homeInstance !== domainOrigin);
if (hasRemote) {
federatedId = computeFederatedId();
db.update(schema.dmChannels)
.set({
federatedId,
ownerHomeUserId: callerUser?.homeUserId || request.userId,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(callerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, dmChannelId))
.run();
}
}
// Build response
const allMembers = [callerUser, ...targetUsers]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(u => sanitizeUser(u));
const result: DmChannel = {
id: dmChannelId,
ownerId: request.userId,
federatedId: federatedId ?? null,
createdAt: now,
members: allMembers,
lastMessage: null,
};
// Broadcast dm_channel_created only to LOCAL members.
// Remote members will receive the channel via federation relay → bootstrap
// on their home instance, preventing duplicate channels in their sidebar.
const domainOriginForBroadcast = isFederationRelayEnabled() ? getOurOrigin() : null;
const isLocalMember = (u: { homeInstance?: string | null }) =>
!u.homeInstance || !domainOriginForBroadcast ||
u.homeInstance === domainOriginForBroadcast ||
`https://${u.homeInstance}` === domainOriginForBroadcast;
for (const member of allMembers) {
if (!isLocalMember(member)) continue;
connectionManager.sendToUser(member.id, {
type: 'dm_channel_created',
dmChannel: result,
});
}
// Insert system messages (DB) for all members, but only broadcast to local members.
// Remote instances create their own system messages via federation event handlers.
for (const targetUser of targetUsers) {
if (!targetUser) continue;
const baseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username;
const sysMsg = {
id: generateSnowflake(),
dmChannelId: dmChannelId,
userId: request.userId,
content: JSON.stringify({
event: 'member_added',
targetUserId: targetUser.id,
targetDisplayName: targetUser.displayName ?? baseName,
}),
type: 'system' as const,
createdAt: now,
user: callerUser ? sanitizeUser(callerUser) : undefined,
attachments: [],
embeds: [],
reactions: [],
};
db.insert(schema.dmMessages).values({
id: sysMsg.id,
dmChannelId: sysMsg.dmChannelId,
userId: sysMsg.userId,
content: sysMsg.content,
type: 'system',
createdAt: sysMsg.createdAt,
}).run();
// Only broadcast to local members — remote instances handle their own
for (const member of allMembers) {
if (!isLocalMember(member)) continue;
connectionManager.sendToUser(member.id, {
type: 'dm_message_created',
message: sysMsg as any,
});
}
}
// Federation: relay member_add for each remote member
if (isFederationRelayEnabled() && federatedId) {
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 = normalizeIconForWire(channelRow?.icon ?? null, domainOrigin);
for (const targetUser of targetUsers) {
if (!targetUser.homeInstance || targetUser.homeInstance === domainOrigin) continue;
const memberAddPayload: FederationRelayEvent = {
eventType: 'member_add',
dmChannelId,
messageId: `member_add:${targetUser.id}:${Date.now()}`,
federatedId,
encryptionVersion: 0,
timestamp: Date.now(),
membership: {
user: {
homeUserId: targetUser.homeUserId || targetUser.id,
homeInstance: targetUser.homeInstance || domainOrigin,
},
addedBy: {
homeUserId: callerUser?.homeUserId || request.userId,
homeInstance: callerUser?.homeInstance || domainOrigin,
},
},
group: {
owner: {
homeUserId: callerUser?.homeUserId || request.userId,
homeInstance: callerUser?.homeInstance || domainOrigin,
},
members: allParticipants,
name: channelRow?.name ?? null,
icon: wireIcon,
metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0,
},
};
const targetOrigins = getGroupDmTargetOrigins(dmChannelId);
let finalTargets = targetOrigins;
// Normalize homeInstance to full URL to match peer origin format
const targetHomeOrigin = targetUser.homeInstance?.startsWith('http') ? targetUser.homeInstance : `https://${targetUser.homeInstance}`;
if (finalTargets && targetHomeOrigin !== domainOrigin && !finalTargets.includes(targetHomeOrigin)) {
finalTargets = [...finalTargets, targetHomeOrigin];
}
appendMutationLog(
memberAddPayload.messageId,
dmChannelId,
'member_add',
JSON.stringify(memberAddPayload),
);
queueOutboxEvent(
memberAddPayload.messageId,
dmChannelId,
'member_add',
JSON.stringify(memberAddPayload),
finalTargets,
);
}
}
return reply.code(201).send(result);
});
// PATCH /api/dm/:id — Update group DM metadata (name + icon). Owner-only.
// Body: { name?: string | null, icon?: string | null }
// - name: trimmed; empty → cleared (null); otherwise length must be in
// [GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH].
// - icon: null/empty → cleared; bare filename → must be an attachment
// uploaded by the caller, image/* mimetype, ≤ GROUP_DM_ICON_MAX_BYTES;
// absolute http(s) URL → accepted as-is (federated rebroadcast path).
app.patch<{ Params: { id: string }; Body: { name?: string | null; icon?: string | null } }>('/api/dm/:id', async (request, reply) => {
const { id } = request.params;
const body = request.body ?? {};
const db = getDb();
// Fetch channel
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 DMs (ownerId=NULL) cannot have metadata
if (!dmChannel.ownerId) {
return reply.code(400).send({ error: 'Cannot update metadata on a 1-on-1 DM', statusCode: 400 });
}
// Caller must be a member
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// Caller must be the owner
if (dmChannel.ownerId !== request.userId) {
return reply.code(403).send({ error: 'Only the group owner can update metadata', statusCode: 403 });
}
const nameProvided = Object.prototype.hasOwnProperty.call(body, 'name');
const iconProvided = Object.prototype.hasOwnProperty.call(body, 'icon');
if (!nameProvided && !iconProvided) {
// No fields to update — return the channel unchanged.
return reply.code(200).send({ id: dmChannel.id, name: dmChannel.name, icon: dmChannel.icon, metadataUpdatedAt: dmChannel.metadataUpdatedAt });
}
const oldName = dmChannel.name ?? null;
const oldIcon = dmChannel.icon ?? null;
// ---- Resolve next name (with no-op short-circuit) ----
let nextName: string | null = oldName;
let nameChanged = false;
if (nameProvided) {
const raw = body.name;
let candidate: string | null;
if (raw === null || raw === undefined) {
candidate = null;
} else if (typeof raw !== 'string') {
return reply.code(400).send({ error: 'name must be a string or null', statusCode: 400 });
} else {
const trimmed = raw.trim();
candidate = trimmed.length === 0 ? null : trimmed;
}
// Validate length only if the value is actually changing — a no-op repeat
// of the stored value (even one that wouldn't pass current validation,
// e.g. legacy data) must not 400.
if (candidate !== oldName) {
if (candidate !== null) {
if (candidate.length < GROUP_DM_NAME_MIN_LENGTH || candidate.length > GROUP_DM_NAME_MAX_LENGTH) {
return reply.code(400).send({
error: `Group DM name must be between ${GROUP_DM_NAME_MIN_LENGTH} and ${GROUP_DM_NAME_MAX_LENGTH} characters`,
statusCode: 400,
});
}
}
nextName = candidate;
nameChanged = true;
}
}
// ---- Resolve next icon (with no-op short-circuit) ----
let nextIcon: string | null = oldIcon;
let iconChanged = false;
if (iconProvided) {
const raw = body.icon;
let candidate: string | null;
if (raw === null || raw === undefined) {
candidate = null;
} else if (typeof raw !== 'string') {
return reply.code(400).send({ error: 'icon must be a string or null', statusCode: 400 });
} else {
const trimmed = raw.trim();
if (trimmed.length === 0) {
candidate = null;
} else if (!isValidAssetUrl(trimmed)) {
return reply.code(400).send({ error: 'Invalid icon URL', statusCode: 400 });
} else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
candidate = trimmed;
} else {
// Bare filename or `/api/uploads/<filename>` — normalize to bare filename.
candidate = trimmed.startsWith('/api/uploads/') ? trimmed.slice('/api/uploads/'.length) : trimmed;
}
}
// Only validate attachment ownership/mimetype/size when the icon is
// actually changing AND the new value is a local filename (not an
// absolute URL or null clear).
if (candidate !== oldIcon) {
if (candidate !== null
&& !candidate.startsWith('http://')
&& !candidate.startsWith('https://')) {
const attachment = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.filename, candidate))
.get();
if (!attachment) {
return reply.code(400).send({ error: 'Icon attachment not found', statusCode: 400 });
}
if (attachment.uploaderId !== request.userId) {
return reply.code(403).send({ error: 'You do not own this icon attachment', statusCode: 403 });
}
if (!attachment.mimetype.startsWith(GROUP_DM_ICON_MIME_PREFIX)) {
return reply.code(400).send({ error: 'Icon must be an image', statusCode: 400 });
}
if (attachment.size > GROUP_DM_ICON_MAX_BYTES) {
return reply.code(400).send({
error: `Icon must be smaller than ${Math.floor(GROUP_DM_ICON_MAX_BYTES / (1024 * 1024))} MB`,
statusCode: 400,
});
}
}
nextIcon = candidate;
iconChanged = true;
}
}
// ---- No-op short-circuit ----
if (!nameChanged && !iconChanged) {
return reply.code(200).send({
id: dmChannel.id,
name: dmChannel.name,
icon: dmChannel.icon,
metadataUpdatedAt: dmChannel.metadataUpdatedAt,
});
}
// Caller user row — needed for federation actor identity
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Single eventMessageId so name/icon system messages share a federation correlation root.
const eventMessageId = generateSnowflake();
const sysMessageRows: Array<{
id: string;
sourceMessageId: string;
content: string;
}> = [];
// ---- Transaction: persist channel update + system message rows ----
let metadataUpdatedAt = 0;
db.transaction((tx) => {
metadataUpdatedAt = Date.now();
tx.update(schema.dmChannels)
.set({ name: nextName, icon: nextIcon, metadataUpdatedAt })
.where(eq(schema.dmChannels.id, id))
.run();
if (nameChanged) {
const sysId = generateSnowflake();
const content = JSON.stringify({ event: 'name_changed', oldName, newName: nextName });
tx.insert(schema.dmMessages).values({
id: sysId,
dmChannelId: id,
userId: request.userId,
content,
type: 'system',
sourceMessageId: `${eventMessageId}:name`,
createdAt: metadataUpdatedAt,
}).run();
sysMessageRows.push({ id: sysId, sourceMessageId: `${eventMessageId}:name`, content });
}
if (iconChanged) {
const sysId = generateSnowflake();
const content = JSON.stringify({ event: 'icon_changed' });
tx.insert(schema.dmMessages).values({
id: sysId,
dmChannelId: id,
userId: request.userId,
content,
type: 'system',
sourceMessageId: `${eventMessageId}:icon`,
createdAt: metadataUpdatedAt,
}).run();
sysMessageRows.push({ id: sysId, sourceMessageId: `${eventMessageId}:icon`, content });
}
});
// ---- Broadcast channel update to local members ----
connectionManager.sendToDmMembers(id, {
type: 'dm_channel_updated',
dmChannelId: id,
name: nextName,
icon: nextIcon,
});
// ---- Broadcast each new system message ----
const sanitizedActor = callerUser ? sanitizeUser(callerUser) : undefined;
for (const sys of sysMessageRows) {
connectionManager.sendToDmMembers(id, {
type: 'dm_message_created',
message: {
id: sys.id,
dmChannelId: id,
userId: request.userId,
content: sys.content,
type: 'system',
createdAt: metadataUpdatedAt,
user: sanitizedActor,
attachments: [],
embeds: [],
reactions: [],
} as DmMessageWithUser,
});
}
// ---- Federation relay ----
if (isFederationRelayEnabled()) {
const domainOrigin = getOurOrigin();
queueGroupMetadataRelay(id, {
name: nextName,
icon: nextIcon,
metadataUpdatedAt,
actor: {
userId: request.userId,
homeUserId: callerUser?.homeUserId ?? request.userId,
homeInstance: callerUser?.homeInstance ?? domainOrigin,
},
});
}
// ---- Old icon cleanup (mirror users.ts:463-466 precedent) ----
if (iconChanged && oldIcon && !oldIcon.startsWith('http')) {
deleteUploadFile(oldIcon);
deleteAttachmentByFilename(oldIcon);
}
// Clean up the attachment record for the newly-set icon — the file is
// now referenced via dm_channels.icon (protected by the storage janitor),
// so the standalone attachment row is unnecessary. Mirrors users.ts:473.
if (iconChanged && nextIcon && !nextIcon.startsWith('http')) {
deleteAttachmentByFilename(nextIcon);
}
return reply.code(200).send({
id,
name: nextName,
icon: nextIcon,
metadataUpdatedAt,
});
});
// DELETE /api/dm/:id - Close (hide) a DM channel for the requesting user
app.delete<{ Params: { id: string } }>('/api/dm/:id', async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Verify the user is a member of this DM channel
const membership = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, request.userId),
))
.get();
if (!membership) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// Soft close: set closed flag (preserves membership for future message delivery)
db.update(schema.dmMembers)
.set({ closed: 1 })
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, request.userId),
))
.run();
// Broadcast dm_channel_closed to self for multi-tab sync
connectionManager.sendToUser(request.userId, {
type: 'dm_channel_closed',
dmChannelId: id,
});
// Relay close to federated peers
queueDmCloseRelay(id, request.userId, 'dm_close');
return reply.code(200).send({ success: true });
});
// POST /api/dm/:id/members - Add a user to an existing DM channel (group DM upgrade)
app.post<{ Params: { id: string }; Body: AddDmMemberRequest }>('/api/dm/:id/members', async (request, reply) => {
const { id } = request.params;
const { userId: targetUserIdRaw, homeUserId, homeInstance } = request.body;
const db = getDb();
let targetUser: typeof schema.users.$inferSelect | undefined;
if (homeUserId && homeInstance) {
// Federated identity: resolve or create a replicated user stub
targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined;
} else if (targetUserIdRaw && typeof targetUserIdRaw === 'string') {
// Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get();
} else {
return reply.code(400).send({ error: 'userId or (homeUserId + homeInstance) is required', statusCode: 400 });
}
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
const targetUserId = targetUser.id;
// Validate caller is a member
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// 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 });
}
// 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 });
}
// Any group DM member can add friends (not just the owner)
// Validate the adder and target are friends
const friendship = db.select().from(schema.friends).where(
or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUserId)),
and(eq(schema.friends.userId, targetUserId), eq(schema.friends.friendId, request.userId)),
),
).get();
if (!friendship) {
return reply.code(403).send({ error: 'You can only add friends to group DMs', statusCode: 403 });
}
// Validate target is not already a member
const existingMembership = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, targetUserId),
))
.get();
if (existingMembership) {
return reply.code(400).send({ error: 'User is already a member of this DM channel', statusCode: 400 });
}
// Validate member count < 10
const currentMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
if (currentMembers.length >= 10) {
return reply.code(400).send({ error: 'Group DM cannot exceed 10 members', statusCode: 400 });
}
// Insert dm_members row for new user
db.insert(schema.dmMembers).values({
dmChannelId: id,
userId: targetUserId,
}).run();
// If the channel doesn't have a federatedId and now has a remote member, assign one
if (!dmChannel.federatedId && isFederationRelayEnabled()) {
const domainOrigin = getOurOrigin();
const participants = getDmParticipants(id);
const hasRemote = participants.some(p => p.homeInstance !== domainOrigin);
if (hasRemote) {
const newFederatedId = computeFederatedId();
const ownerUser = db.select().from(schema.users).where(eq(schema.users.id, dmChannel.ownerId!)).get();
db.update(schema.dmChannels)
.set({
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, id))
.run();
// Refresh for subsequent federation code
dmChannel = {
...dmChannel,
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
};
}
}
// Build full DmChannel response with all members
const allMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
const memberUserIds = allMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
// Fetch last message
const lastMsgRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMsg = lastMsgRows[0] ?? null;
const result: DmChannel = {
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
federatedId: dmChannel.federatedId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(u => sanitizeUser(u)),
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
userId: lastMsg.userId,
content: lastMsg.content,
createdAt: lastMsg.createdAt,
type: lastMsg.type === 'system' ? 'system' : 'user',
} : null,
};
const newUser = sanitizeUser(targetUser);
// Broadcast dm_member_added to all existing members (before the new one)
for (const member of currentMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_member_added',
dmChannelId: id,
user: newUser,
});
}
// Send dm_channel_created to the new member so their sidebar picks it up
connectionManager.sendToUser(targetUserId, {
type: 'dm_channel_created',
dmChannel: result,
});
// Insert & broadcast system message for member addition
const addBaseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username;
const addSysMsg = {
id: generateSnowflake(),
dmChannelId: id,
userId: request.userId,
content: JSON.stringify({
event: 'member_added',
targetUserId: targetUserId,
targetDisplayName: targetUser.displayName ?? addBaseName,
}),
type: 'system' as const,
createdAt: Date.now(),
user: db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(),
attachments: [],
embeds: [],
reactions: [],
};
db.insert(schema.dmMessages).values({
id: addSysMsg.id,
dmChannelId: addSysMsg.dmChannelId,
userId: addSysMsg.userId,
content: addSysMsg.content,
type: 'system',
createdAt: addSysMsg.createdAt,
}).run();
connectionManager.sendToDmMembers(id, {
type: 'dm_message_created',
message: { ...addSysMsg, user: addSysMsg.user ? sanitizeUser(addSysMsg.user) : undefined } as any,
});
// Federation: relay member_add to peers
if (isFederationRelayEnabled() && dmChannel.federatedId) {
const domainOrigin = getOurOrigin();
const allParticipants = getDmParticipants(id);
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 = normalizeIconForWire(channelRow?.icon ?? null, domainOrigin);
const memberAddPayload: FederationRelayEvent = {
eventType: 'member_add',
dmChannelId: id,
messageId: `member_add:${targetUserId}:${Date.now()}`,
federatedId: dmChannel.federatedId,
encryptionVersion: 0,
timestamp: Date.now(),
membership: {
user: {
homeUserId: addedUser?.homeUserId || targetUserId,
homeInstance: addedUser?.homeInstance || domainOrigin,
},
addedBy: {
homeUserId: adderUser?.homeUserId || request.userId,
homeInstance: adderUser?.homeInstance || domainOrigin,
},
},
group: {
owner: {
homeUserId: dmChannel.ownerHomeUserId || request.userId,
homeInstance: dmChannel.ownerHomeInstance || domainOrigin,
},
members: allParticipants,
name: channelRow?.name ?? null,
icon: wireIcon,
metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0,
},
};
// Include the new member's instance in targets even if not previously in the group
const targetOrigins = getGroupDmTargetOrigins(id);
// Normalize homeInstance to full URL to match peer origin format
const rawNewMemberInstance = addedUser?.homeInstance || domainOrigin;
const newMemberInstance = rawNewMemberInstance.startsWith('http') ? rawNewMemberInstance : `https://${rawNewMemberInstance}`;
let finalTargets = targetOrigins;
if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) {
finalTargets = [...finalTargets, newMemberInstance];
}
appendMutationLog(
memberAddPayload.messageId,
id,
'member_add',
JSON.stringify(memberAddPayload),
);
queueOutboxEvent(
memberAddPayload.messageId,
id,
'member_add',
JSON.stringify(memberAddPayload),
finalTargets,
);
}
// Active call sync: if there's an active call in this DM, notify the new member
const room = connectionManager.getRoom(id);
if (room && room.roomType === 'dm') {
const meta = room.metadata as { state: string; callerId: string };
if (meta.state === 'active' || meta.state === 'ringing') {
// Look up caller name
const callerRow = db.select().from(schema.users).where(eq(schema.users.id, meta.callerId)).get();
const callerName = callerRow?.displayName ?? callerRow?.username ?? meta.callerId;
connectionManager.sendToUser(targetUserId, {
type: 'dm_call_incoming',
dmChannelId: id,
callerId: meta.callerId,
callerName,
});
}
}
return reply.code(200).send(result);
});
// DELETE /api/dm/:id/members - Leave a group DM
app.delete<{ Params: { id: string } }>('/api/dm/:id/members', async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Validate caller is a member
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// 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
evictUserFromDmVoiceRoom(id, request.userId);
removeDmMember(id, request.userId, request.userId, 'leave');
// Send dm_channel_closed to the leaving user
connectionManager.sendToUser(request.userId, {
type: 'dm_channel_closed',
dmChannelId: id,
});
return reply.code(200).send({ success: true });
});
// DELETE /api/dm/:id/members/:targetUserId - Owner kicks a member from a group DM.
//
// The `:targetUserId` URL segment carries either a local user id OR a
// federated home user id. When the optional `homeInstance` query string is
// present, the segment is interpreted as a home id and resolved via
// `resolveOrCreateReplicatedUser(targetUserId, homeInstance)` — same pattern
// as POST /api/dm/:id/transfer and POST /api/dm/:id/members. This is
// necessary when the client only knows the target's home identity (the
// common case for federated members rendered through `useCanonicalUserView`,
// whose `id` is the home id, not this instance's local replicated id).
// Without this path, the membership check `isDmMember(id, targetUserId)`
// fails because `dm_members.userId` on the owner instance is its local
// replicated id, not the federated home id.
app.delete<{
Params: { id: string; targetUserId: string };
Querystring: { homeInstance?: string };
}>('/api/dm/:id/members/:targetUserId', async (request, reply) => {
const { id, targetUserId: rawTargetSegment } = request.params;
const homeInstanceQuery = request.query?.homeInstance;
const db = getDb();
// Resolve the target to a local user row. When `homeInstance` is supplied,
// treat the URL segment as a homeUserId. Otherwise, treat it as a local
// user id and look it up directly.
let targetUserRow: typeof schema.users.$inferSelect | undefined;
if (typeof homeInstanceQuery === 'string' && homeInstanceQuery.length > 0) {
targetUserRow = resolveOrCreateReplicatedUser(rawTargetSegment, homeInstanceQuery, db) ?? undefined;
} else {
targetUserRow = db.select().from(schema.users).where(eq(schema.users.id, rawTargetSegment)).get();
}
if (!targetUserRow) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
const targetUserId = targetUserRow.id;
// 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
evictUserFromDmVoiceRoom(id, 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 });
});
// POST /api/dm/:id/transfer - Owner transfers ownership to another group member without leaving.
//
// Body accepts either a local id (`newOwnerId`) or a federated identity
// (`homeUserId` + `homeInstance`). Federated identification mirrors the
// `addDmMember` pattern (see `POST /api/dm/:id/members` above) and is
// required when the client only knows the target's home identity — for
// example when the channel-serving instance and the owner-serving instance
// disagree on the local replicated user id, or when `useCanonicalUserView`
// surfaces the user's home view (whose `id` is the home id, NOT this
// instance's local replicated id). Without this path, transferring ownership
// to a federated member always failed with "user is not part of the DM"
// because the owner instance's `dm_members.userId` is its OWN local id, not
// the federated home id.
app.post<{
Params: { id: string };
Body: {
newOwnerId?: unknown;
homeUserId?: unknown;
homeInstance?: unknown;
};
}>('/api/dm/:id/transfer', async (request, reply) => {
const { id } = request.params;
const body = (request.body ?? {}) as {
newOwnerId?: unknown;
homeUserId?: unknown;
homeInstance?: unknown;
};
const rawNewOwnerId = body.newOwnerId;
const rawHomeUserId = body.homeUserId;
const rawHomeInstance = body.homeInstance;
const hasFederatedArgs =
typeof rawHomeUserId === 'string' && rawHomeUserId.length > 0 &&
typeof rawHomeInstance === 'string' && rawHomeInstance.length > 0;
const hasLocalArg = typeof rawNewOwnerId === 'string' && rawNewOwnerId.length > 0;
if (!hasFederatedArgs && !hasLocalArg) {
return reply.code(400).send({
error: 'newOwnerId or (homeUserId + homeInstance) is required',
statusCode: 400,
});
}
const db = getDb();
// Resolve the new owner to a local user row. Federated identification
// takes precedence when both forms are supplied — it's strictly more
// specific (homeUserId + homeInstance disambiguates across instances),
// so the explicit federated args wins over a possibly-stale local id.
let newOwnerRow: typeof schema.users.$inferSelect | undefined;
if (hasFederatedArgs) {
newOwnerRow = resolveOrCreateReplicatedUser(
rawHomeUserId as string,
rawHomeInstance as string,
db,
) ?? undefined;
} else {
newOwnerRow = db.select().from(schema.users).where(eq(schema.users.id, rawNewOwnerId as string)).get();
}
if (!newOwnerRow) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
const newOwnerId = newOwnerRow.id;
// 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 transfer ownership of a 1-on-1 DM', statusCode: 400 });
}
// Caller must be the current owner
if (dmChannel.ownerId !== request.userId) {
return reply.code(403).send({ error: 'Only the group owner can transfer ownership', statusCode: 403 });
}
// Self-transfer is a no-op
if (newOwnerId === dmChannel.ownerId) {
return reply.code(400).send({ error: 'Cannot transfer to current owner', statusCode: 400 });
}
// Target must be a current member
if (!isDmMember(id, newOwnerId)) {
return reply.code(400).send({ error: 'Target user is not a member of this DM channel', statusCode: 400 });
}
const previousOwnerId = dmChannel.ownerId;
transferGroupDmOwnership(id, previousOwnerId, newOwnerId);
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;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.dmMessages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.dmMessages)
.where(and(
eq(schema.dmMessages.dmChannelId, id),
lt(schema.dmMessages.id, before)
))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(limit)
.all();
} else {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(limit)
.all();
}
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
// Batch fetch attachments by dmMessageId
const messageIds = messageRows.map(m => m.id);
const allAttachments = db.select()
.from(schema.attachments)
.where(inArray(schema.attachments.dmMessageId, messageIds))
.all();
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of allAttachments) {
const mid = att.dmMessageId ?? '';
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
attachmentMap.get(mid)!.push(att);
}
// Batch fetch reactions
const reactionsMap = fetchDmReactionsForMessages(messageIds);
// Batch fetch embeds
const embedMap = fetchDmEmbedsForMessages(messageIds);
// Batch fetch reply-to messages
const replyToIds = messageRows
.map(m => m.replyToId)
.filter((id): id is string => id !== null && id !== undefined);
const uniqueReplyIds = [...new Set(replyToIds)];
const replyToMap = new Map<string, DmMessageWithUser>();
if (uniqueReplyIds.length > 0) {
const replyMessages = db.select()
.from(schema.dmMessages)
.where(inArray(schema.dmMessages.id, uniqueReplyIds))
.all();
const replyUserIds = [...new Set(replyMessages.map(m => m.userId))];
const replyUsers = replyUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all()
: [];
const replyUserMap = new Map(replyUsers.map(u => [u.id, u]));
for (const rm of replyMessages) {
const rUser = replyUserMap.get(rm.userId);
if (!rUser) continue;
replyToMap.set(rm.id, {
id: rm.id,
dmChannelId: rm.dmChannelId,
userId: rm.userId,
replyToId: rm.replyToId,
content: rm.content,
type: (rm.type ?? 'user') as 'user' | 'system',
editedAt: rm.editedAt,
createdAt: rm.createdAt,
user: sanitizeUser(rUser),
attachments: [],
embeds: [],
reactions: [],
});
}
}
const messages: DmMessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
const reactions = reactionsMap.get(m.id) ?? [];
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []);
})
.filter((m): m is DmMessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/dm/space-invite — Send a space invite card to a friend via DM.
// Trust model: the snapshot is fetched server-to-server from the space's home
// instance via fetchSpaceInviteSnapshot — never trust client-supplied snapshot data.
app.post<{ Body: SpaceInviteRequest }>('/api/dm/space-invite', {
config: {
rateLimit: {
max: 30,
timeWindow: '60 seconds',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const body = request.body;
if (!body || !body.spaceId || !body.inviteCode || !body.target) {
return reply.code(400).send({ error: 'invalid_body', statusCode: 400 });
}
const db = getDb();
const callerId = request.userId;
// 1. Resolve target user (local id OR federated homeUserId+homeInstance)
let targetUser: typeof schema.users.$inferSelect | null = null;
if ('userId' in body.target) {
targetUser = db.select().from(schema.users)
.where(eq(schema.users.id, body.target.userId)).get() ?? null;
} else if ('homeUserId' in body.target && 'homeInstance' in body.target) {
targetUser = resolveOrCreateReplicatedUser(
body.target.homeUserId,
body.target.homeInstance,
db,
);
} else {
return reply.code(400).send({ error: 'invalid_target', statusCode: 400 });
}
if (!targetUser) {
return reply.code(404).send({ error: 'user_not_found', statusCode: 404 });
}
if (targetUser.id === callerId) {
return reply.code(400).send({ error: 'cannot_invite_self', statusCode: 400 });
}
// 2. Friendship check (anti-spam, NOT a permission gate — see spec).
const friendship = db.select().from(schema.friends).where(
or(
and(eq(schema.friends.userId, callerId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, callerId)),
),
).get();
if (!friendship) {
return reply.code(400).send({ error: 'not_a_friend', statusCode: 400 });
}
// 3. Snapshot lookup. For local spaces, read the DB directly — fetching
// our own /preview endpoint over HTTPS fails inside Docker (NAT loopback).
// Cross-instance previews still go through the SSRF-validated HTTP path.
const ourOrigin = getOurOrigin();
const spaceOrigin = body.spaceInstanceOrigin || ourOrigin;
const isLocal = !body.spaceInstanceOrigin || body.spaceInstanceOrigin === ourOrigin;
const snapshot = isLocal
? getLocalInviteSnapshot(body.inviteCode)
: await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
if (!snapshot) {
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}
if (snapshot.spaceId !== body.spaceId) {
// spaceId mismatch — code points at a different space than the client claimed.
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}
// Request-only spaces are approval-gated and have no usable invite links, so
// refuse to send an invite card that would dead-end at the recipient's join
// guard. This is checked against our LOCAL spaces table by id, independent of
// the caller-supplied spaceInstanceOrigin: if the space is genuinely local and
// request-only we reject even when the origin is spoofed to look remote. A
// truly remote space is absent from this table (undefined → allowed); its own
// home instance enforces the same rule when the recipient tries to join.
const localSpace = db.select({ visibility: schema.spaces.visibility })
.from(schema.spaces).where(eq(schema.spaces.id, body.spaceId)).get();
if (localSpace?.visibility === 'request') {
return reply.code(403).send({ error: 'space_requires_approval', statusCode: 403 });
}
// 4. Resolve / create the 1-on-1 DM (delegate to dedup helper).
const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db);
// 5. Build content + insert system message.
const now = Date.now();
const messageId = generateSnowflake();
const payload: SpaceInviteSystemPayload = {
event: 'space_invite',
spaceId: body.spaceId,
spaceInstanceOrigin: spaceOrigin,
inviteCode: body.inviteCode,
snapshot: {
spaceName: snapshot.spaceName,
icon: snapshot.icon,
avatarColor: snapshot.avatarColor,
memberCount: snapshot.memberCount,
description: snapshot.description,
instanceName: snapshot.instanceName,
},
};
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId,
userId: callerId,
content: JSON.stringify(payload),
type: 'system',
createdAt: now,
}).run();
// 6. Hydrate + broadcast locally.
const message = getDmMessageWithUser(messageId);
if (!message) {
return reply.code(500).send({ error: 'message_lookup_failed', statusCode: 500 });
}
broadcastDmMessage(dmChannelId, message);
// 7. Federation relay if the recipient is on a remote instance.
if (isFederationRelayEnabled() && targetUser.homeInstance && targetUser.homeInstance !== ourOrigin) {
queueDmRelay(message, dmChannelId, 'create');
}
const response: SpaceInviteResponse = { dmChannelId, messageId, message };
return reply.code(200).send(response);
});
// POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
config: {
rateLimit: {
max: 5,
timeWindow: '5 seconds',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if (isDeadOneOnOne(id, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
if (!hasContent && !hasAttachments) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
// Verify attachment ownership before linking
if (attachmentIds && attachmentIds.length > 0) {
for (const attId of attachmentIds) {
const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get();
if (!att || att.messageId || att.dmMessageId) {
return reply.code(400).send({ error: 'Invalid or already-used attachment', statusCode: 400 });
}
if (att.uploaderId && att.uploaderId !== request.userId) {
return reply.code(400).send({ error: 'You do not own this attachment', statusCode: 400 });
}
}
}
// Insert message and link attachments atomically
db.transaction((tx) => {
tx.insert(schema.dmMessages).values({
id: messageId,
dmChannelId: id,
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
createdAt: now,
}).run();
if (attachmentIds && attachmentIds.length > 0) {
for (const attId of attachmentIds) {
tx.update(schema.attachments)
.set({ dmMessageId: messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
}
});
const message = getDmMessageWithUser(messageId);
if (!message) {
return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
}
// Broadcast to all DM members (including those who closed the channel)
broadcastDmMessage(id, message);
// Federation: queue for relay
queueDmRelay(message, id, 'create');
// Resolve embeds asynchronously after responding
setImmediate(() => {
resolveEmbeds(messageId, content?.trim() || null, id, true, null).catch(() => {});
});
return reply.code(201).send(message);
});
// PATCH /api/dm/messages/:id - Edit a DM message
app.patch<{ Params: { id: string }; Body: { content: string } }>('/api/dm/messages/:id', async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
}
if (content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
if (!msg) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (msg.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
if (isDeadOneOnOne(msg.dmChannelId, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
const now = Date.now();
db.update(schema.dmMessages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.dmMessages.id, id))
.run();
// Delete old embeds synchronously so the broadcast reflects the edit
db.delete(schema.embeds).where(eq(schema.embeds.dmMessageId, id)).run();
const updated = getDmMessageWithUser(id);
if (!updated) {
return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 });
}
// Broadcast to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_updated',
message: updated,
});
}
// Federation: queue for relay
queueDmRelay(updated, msg.dmChannelId, 'update');
// Resolve new embeds asynchronously (old ones already deleted above)
setImmediate(() => {
resolveEmbeds(id, content.trim(), msg.dmChannelId, true, null).catch(() => {});
});
return reply.code(200).send(updated);
});
// DELETE /api/dm/messages/:id - Delete a DM message
app.delete<{ Params: { id: string } }>('/api/dm/messages/:id', async (request, reply) => {
const { id } = request.params;
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
if (!msg) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (msg.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
}
if (isDeadOneOnOne(msg.dmChannelId, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
// Collect attachment filenames before deleting
const attachmentRows = db.select({ filename: schema.attachments.filename })
.from(schema.attachments)
.where(eq(schema.attachments.dmMessageId, id))
.all();
// Delete attachments, reactions, and message atomically
db.transaction((tx) => {
tx.delete(schema.attachments)
.where(eq(schema.attachments.dmMessageId, id))
.run();
tx.delete(schema.dmReactions)
.where(eq(schema.dmReactions.dmMessageId, id))
.run();
tx.delete(schema.dmMessages)
.where(eq(schema.dmMessages.id, id))
.run();
});
// Clean up files from disk after transaction commits
deleteAttachmentFiles(attachmentRows);
// Broadcast to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_deleted',
messageId: id,
dmChannelId: msg.dmChannelId,
});
}
// Federation: log mutation and queue for relay
appendMutationLog(id, msg.dmChannelId, 'delete');
queueOutboxEvent(id, msg.dmChannelId, 'delete', JSON.stringify({ deleted: true }));
return reply.code(200).send({ success: true });
});
}