fix: gate all client DM events on active S2S peer status
The client's direct WS connection to remote instances (via Connections) delivered DM events independently of S2S peering. Added activePeerOrigins allowlist to ready payload — all DM event handlers now silently drop events from non-home origins without an active peer. This prevents notifications, sounds, previews, typing indicators, calls, and channel updates from instances where peering was revoked or never established.
This commit is contained in:
@@ -937,6 +937,7 @@ function buildReadyPayload(userId: string): {
|
||||
userActivities: Record<string, Activity[]>;
|
||||
rejectedPeerOrigins: string[];
|
||||
awaitingApprovalPeerOrigins: string[];
|
||||
activePeerOrigins: string[];
|
||||
pendingApprovalCount: number;
|
||||
} {
|
||||
const db = getDb();
|
||||
@@ -1482,6 +1483,14 @@ function buildReadyPayload(userId: string): {
|
||||
.all();
|
||||
const awaitingApprovalPeerOrigins = awaitingApprovalPeers.map(p => p.origin);
|
||||
|
||||
// Active peer origins — client uses this allowlist to gate DM events from remote instances
|
||||
const activePeers = db
|
||||
.select({ origin: schema.federationPeers.origin })
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.status, 'active'))
|
||||
.all();
|
||||
const activePeerOrigins = activePeers.map(p => p.origin);
|
||||
|
||||
// Pending approval count for admin notification
|
||||
let pendingApprovalCount = 0;
|
||||
if (userRow?.isAdmin === 1) {
|
||||
@@ -1492,7 +1501,7 @@ function buildReadyPayload(userId: string): {
|
||||
pendingApprovalCount = countResult?.count ?? 0;
|
||||
}
|
||||
|
||||
return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls, userActivities, rejectedPeerOrigins, awaitingApprovalPeerOrigins, pendingApprovalCount };
|
||||
return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls, userActivities, rejectedPeerOrigins, awaitingApprovalPeerOrigins, activePeerOrigins, pendingApprovalCount };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
|
||||
@@ -390,7 +390,7 @@ export type ClientEvent =
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
|
||||
@@ -28,6 +28,14 @@ export function getAwaitingApprovalPeerOrigins(): Set<string> {
|
||||
return awaitingApprovalPeerOrigins;
|
||||
}
|
||||
|
||||
// Active peer origins — allowlist for processing DM events from remote instances.
|
||||
// Only DMs from peered origins (or the home instance) are processed.
|
||||
const activePeerOrigins = new Set<string>();
|
||||
|
||||
export function getActivePeerOrigins(): Set<string> {
|
||||
return activePeerOrigins;
|
||||
}
|
||||
|
||||
// ─── Connection state ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ConnectionState {
|
||||
@@ -397,6 +405,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate active peer origins (allowlist for remote DM events)
|
||||
activePeerOrigins.clear();
|
||||
if (Array.isArray(event.activePeerOrigins)) {
|
||||
for (const o of event.activePeerOrigins) {
|
||||
activePeerOrigins.add(o);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin toast for pending approval requests
|
||||
if (event.pendingApprovalCount && event.pendingApprovalCount > 0) {
|
||||
const { addToast } = useUIStore.getState();
|
||||
@@ -590,6 +606,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
// ─── DM events (all origins) ────────────────────────────────────────────
|
||||
|
||||
case 'dm_message_created': {
|
||||
// Gate: only process DM events from the home instance or actively peered origins.
|
||||
// This prevents notifications/previews from remote instances where S2S peering
|
||||
// was revoked, deleted, or never established — even if the client still has a
|
||||
// direct WS connection to that instance via Connections.
|
||||
if (!isHome && !activePeerOrigins.has(origin)) {
|
||||
break;
|
||||
}
|
||||
if (!isHome) {
|
||||
normalizeMessageAssets(event.message as any, origin);
|
||||
if ((event.message as any).embeds) {
|
||||
@@ -655,6 +678,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_message_updated':
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
if (!isHome) {
|
||||
normalizeMessageAssets(event.message as any, origin);
|
||||
if ((event.message as any).embeds) {
|
||||
@@ -686,6 +710,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
const { addToast } = useUIStore.getState();
|
||||
rejectedPeerOrigins.add(event.peerOrigin);
|
||||
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
||||
activePeerOrigins.delete(event.peerOrigin);
|
||||
const label = event.peerLabel || event.peerOrigin;
|
||||
addToast(
|
||||
`Cannot relay messages to ${label} — ${event.reason}`,
|
||||
@@ -698,10 +723,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
case 'federation_peer_active': {
|
||||
rejectedPeerOrigins.delete(event.peerOrigin);
|
||||
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
||||
activePeerOrigins.add(event.peerOrigin);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dm_message_deleted':
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
removeMessage(event.messageId, event.dmChannelId);
|
||||
break;
|
||||
|
||||
@@ -726,6 +753,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_embeds_resolved': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
if (!isHome) {
|
||||
for (const embed of event.embeds) {
|
||||
if (embed.image && !embed.image.startsWith('http')) {
|
||||
@@ -746,6 +774,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_typing': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
let dmTypingUsername = event.username as string;
|
||||
if (!isHome && dmTypingUsername && !dmTypingUsername.includes('@')) {
|
||||
try { dmTypingUsername = `${dmTypingUsername}@${new URL(origin).host}`; } catch {}
|
||||
@@ -755,6 +784,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_typing_stop': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
clearTyping(event.dmChannelId as string, event.userId as string);
|
||||
break;
|
||||
}
|
||||
@@ -832,6 +862,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
// ─── DM call events (all origins) ──────────────────────────────────────
|
||||
|
||||
case 'dm_call_incoming': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
// Batch ALL call state into a single set() to prevent:
|
||||
// 1. Ringtone multiplication (multiple subscription triggers from separate set() calls)
|
||||
// 2. Stale callOrigin/federatedCallId from previous calls (always overwritten)
|
||||
@@ -854,6 +885,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_accepted': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
|
||||
const wasOutgoingCall = !!outgoingCall;
|
||||
setIncomingCall(null);
|
||||
@@ -877,6 +909,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_rejected': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
@@ -887,6 +920,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_ended': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
@@ -899,6 +933,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
// ─── DM channel events (all origins) ────────────────────────────────────
|
||||
|
||||
case 'dm_channel_created': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
if (!isHome) {
|
||||
for (const m of event.dmChannel.members) {
|
||||
normalizeUserAssets(m, origin);
|
||||
@@ -915,10 +950,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_channel_closed':
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
removeDmChannel(event.dmChannelId);
|
||||
break;
|
||||
|
||||
case 'dm_member_added': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
if (!isHome) normalizeUserAssets(event.user, origin);
|
||||
const { addDmMember } = useSpaceStore.getState();
|
||||
addDmMember(event.dmChannelId, event.user);
|
||||
@@ -926,12 +963,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_member_removed': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
const { removeDmMember } = useSpaceStore.getState();
|
||||
removeDmMember(event.dmChannelId, event.userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dm_owner_updated': {
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
const { updateDmOwner } = useSpaceStore.getState();
|
||||
updateDmOwner(event.dmChannelId, event.newOwnerId);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user