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[]>;
|
userActivities: Record<string, Activity[]>;
|
||||||
rejectedPeerOrigins: string[];
|
rejectedPeerOrigins: string[];
|
||||||
awaitingApprovalPeerOrigins: string[];
|
awaitingApprovalPeerOrigins: string[];
|
||||||
|
activePeerOrigins: string[];
|
||||||
pendingApprovalCount: number;
|
pendingApprovalCount: number;
|
||||||
} {
|
} {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -1482,6 +1483,14 @@ function buildReadyPayload(userId: string): {
|
|||||||
.all();
|
.all();
|
||||||
const awaitingApprovalPeerOrigins = awaitingApprovalPeers.map(p => p.origin);
|
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
|
// Pending approval count for admin notification
|
||||||
let pendingApprovalCount = 0;
|
let pendingApprovalCount = 0;
|
||||||
if (userRow?.isAdmin === 1) {
|
if (userRow?.isAdmin === 1) {
|
||||||
@@ -1492,7 +1501,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
pendingApprovalCount = countResult?.count ?? 0;
|
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> {
|
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
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_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export function getAwaitingApprovalPeerOrigins(): Set<string> {
|
|||||||
return awaitingApprovalPeerOrigins;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Connection state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectionState {
|
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
|
// Admin toast for pending approval requests
|
||||||
if (event.pendingApprovalCount && event.pendingApprovalCount > 0) {
|
if (event.pendingApprovalCount && event.pendingApprovalCount > 0) {
|
||||||
const { addToast } = useUIStore.getState();
|
const { addToast } = useUIStore.getState();
|
||||||
@@ -590,6 +606,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// ─── DM events (all origins) ────────────────────────────────────────────
|
// ─── DM events (all origins) ────────────────────────────────────────────
|
||||||
|
|
||||||
case 'dm_message_created': {
|
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) {
|
if (!isHome) {
|
||||||
normalizeMessageAssets(event.message as any, origin);
|
normalizeMessageAssets(event.message as any, origin);
|
||||||
if ((event.message as any).embeds) {
|
if ((event.message as any).embeds) {
|
||||||
@@ -655,6 +678,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_message_updated':
|
case 'dm_message_updated':
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
normalizeMessageAssets(event.message as any, origin);
|
normalizeMessageAssets(event.message as any, origin);
|
||||||
if ((event.message as any).embeds) {
|
if ((event.message as any).embeds) {
|
||||||
@@ -686,6 +710,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
const { addToast } = useUIStore.getState();
|
const { addToast } = useUIStore.getState();
|
||||||
rejectedPeerOrigins.add(event.peerOrigin);
|
rejectedPeerOrigins.add(event.peerOrigin);
|
||||||
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
||||||
|
activePeerOrigins.delete(event.peerOrigin);
|
||||||
const label = event.peerLabel || event.peerOrigin;
|
const label = event.peerLabel || event.peerOrigin;
|
||||||
addToast(
|
addToast(
|
||||||
`Cannot relay messages to ${label} — ${event.reason}`,
|
`Cannot relay messages to ${label} — ${event.reason}`,
|
||||||
@@ -698,10 +723,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
case 'federation_peer_active': {
|
case 'federation_peer_active': {
|
||||||
rejectedPeerOrigins.delete(event.peerOrigin);
|
rejectedPeerOrigins.delete(event.peerOrigin);
|
||||||
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
||||||
|
activePeerOrigins.add(event.peerOrigin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_message_deleted':
|
case 'dm_message_deleted':
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
removeMessage(event.messageId, event.dmChannelId);
|
removeMessage(event.messageId, event.dmChannelId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -726,6 +753,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_embeds_resolved': {
|
case 'dm_embeds_resolved': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
for (const embed of event.embeds) {
|
for (const embed of event.embeds) {
|
||||||
if (embed.image && !embed.image.startsWith('http')) {
|
if (embed.image && !embed.image.startsWith('http')) {
|
||||||
@@ -746,6 +774,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_typing': {
|
case 'dm_typing': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
let dmTypingUsername = event.username as string;
|
let dmTypingUsername = event.username as string;
|
||||||
if (!isHome && dmTypingUsername && !dmTypingUsername.includes('@')) {
|
if (!isHome && dmTypingUsername && !dmTypingUsername.includes('@')) {
|
||||||
try { dmTypingUsername = `${dmTypingUsername}@${new URL(origin).host}`; } catch {}
|
try { dmTypingUsername = `${dmTypingUsername}@${new URL(origin).host}`; } catch {}
|
||||||
@@ -755,6 +784,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_typing_stop': {
|
case 'dm_typing_stop': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
clearTyping(event.dmChannelId as string, event.userId as string);
|
clearTyping(event.dmChannelId as string, event.userId as string);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -832,6 +862,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// ─── DM call events (all origins) ──────────────────────────────────────
|
// ─── DM call events (all origins) ──────────────────────────────────────
|
||||||
|
|
||||||
case 'dm_call_incoming': {
|
case 'dm_call_incoming': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
// Batch ALL call state into a single set() to prevent:
|
// Batch ALL call state into a single set() to prevent:
|
||||||
// 1. Ringtone multiplication (multiple subscription triggers from separate set() calls)
|
// 1. Ringtone multiplication (multiple subscription triggers from separate set() calls)
|
||||||
// 2. Stale callOrigin/federatedCallId from previous calls (always overwritten)
|
// 2. Stale callOrigin/federatedCallId from previous calls (always overwritten)
|
||||||
@@ -854,6 +885,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_call_accepted': {
|
case 'dm_call_accepted': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
|
const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
|
||||||
const wasOutgoingCall = !!outgoingCall;
|
const wasOutgoingCall = !!outgoingCall;
|
||||||
setIncomingCall(null);
|
setIncomingCall(null);
|
||||||
@@ -877,6 +909,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_call_rejected': {
|
case 'dm_call_rejected': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
||||||
setIncomingCall(null);
|
setIncomingCall(null);
|
||||||
setOutgoingCall(null);
|
setOutgoingCall(null);
|
||||||
@@ -887,6 +920,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_call_ended': {
|
case 'dm_call_ended': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
||||||
setIncomingCall(null);
|
setIncomingCall(null);
|
||||||
setOutgoingCall(null);
|
setOutgoingCall(null);
|
||||||
@@ -899,6 +933,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// ─── DM channel events (all origins) ────────────────────────────────────
|
// ─── DM channel events (all origins) ────────────────────────────────────
|
||||||
|
|
||||||
case 'dm_channel_created': {
|
case 'dm_channel_created': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
for (const m of event.dmChannel.members) {
|
for (const m of event.dmChannel.members) {
|
||||||
normalizeUserAssets(m, origin);
|
normalizeUserAssets(m, origin);
|
||||||
@@ -915,10 +950,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_channel_closed':
|
case 'dm_channel_closed':
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
removeDmChannel(event.dmChannelId);
|
removeDmChannel(event.dmChannelId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_member_added': {
|
case 'dm_member_added': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
if (!isHome) normalizeUserAssets(event.user, origin);
|
if (!isHome) normalizeUserAssets(event.user, origin);
|
||||||
const { addDmMember } = useSpaceStore.getState();
|
const { addDmMember } = useSpaceStore.getState();
|
||||||
addDmMember(event.dmChannelId, event.user);
|
addDmMember(event.dmChannelId, event.user);
|
||||||
@@ -926,12 +963,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_member_removed': {
|
case 'dm_member_removed': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { removeDmMember } = useSpaceStore.getState();
|
const { removeDmMember } = useSpaceStore.getState();
|
||||||
removeDmMember(event.dmChannelId, event.userId);
|
removeDmMember(event.dmChannelId, event.userId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_owner_updated': {
|
case 'dm_owner_updated': {
|
||||||
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { updateDmOwner } = useSpaceStore.getState();
|
const { updateDmOwner } = useSpaceStore.getState();
|
||||||
updateDmOwner(event.dmChannelId, event.newOwnerId);
|
updateDmOwner(event.dmChannelId, event.newOwnerId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user