fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

@@ -21,7 +21,9 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
## Relationships

View File

@@ -0,0 +1,39 @@
import {
afterEach,
describe,
expect,
it
} from 'vitest';
import { getSessionTokenTtlMs } from './session-auth.service';
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
describe('session-auth.service', () => {
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
afterEach(() => {
if (originalTtl === undefined) {
delete process.env.SESSION_TOKEN_TTL_MS;
} else {
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
}
});
it('defaults session tokens to a very long lifetime', () => {
delete process.env.SESSION_TOKEN_TTL_MS;
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
});
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
process.env.SESSION_TOKEN_TTL_MS = '3600000';
expect(getSessionTokenTtlMs()).toBe(3_600_000);
});
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
});
});

View File

@@ -4,7 +4,7 @@ import { SessionTokenEntity } from '../entities/SessionTokenEntity';
import { getUserById } from '../cqrs';
import type { AuthUserPayload } from '../cqrs/types';
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
export interface IssuedSessionToken {
token: string;

View File

@@ -0,0 +1,102 @@
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast';
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(
connectionId: string,
oderId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const user: ConnectedUser = {
oderId,
ws: createMockWs(),
authenticated: true,
serverIds: new Set(['server-1']),
displayName: 'Test User',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
describe('broadcastToServer', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('delivers chat_message to every connection in the server except the sender connection', () => {
createConnectedUser('conn-a1', 'user-1');
const connA2 = createConnectedUser('conn-a2', 'user-1');
const connB = createConnectedUser('conn-b', 'user-2');
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
excludeConnectionId: 'conn-a1'
});
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
});
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
const connA1 = createConnectedUser('conn-a1', 'user-1');
const connA2 = createConnectedUser('conn-a2', 'user-1');
const connB = createConnectedUser('conn-b', 'user-2');
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
excludeIdentityOderId: 'user-1'
});
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
});
});
describe('findVoiceActiveConnection', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('returns the connection marked voiceActive for the user', () => {
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
expect(findVoiceActiveConnection('user-1')).toBe(active);
});
it('returns undefined when no voiceActive connection exists', () => {
createConnectedUser('conn-1', 'user-1');
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
});
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
const fallback = createConnectedUser('conn-1', 'user-1');
expect(findUserByOderId('user-1')).toBe(fallback);
});
});

View File

@@ -7,19 +7,35 @@ interface WsMessage {
type: string;
}
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
export interface BroadcastOptions {
/** Skip only the sending WebSocket connection. */
excludeConnectionId?: string;
/** Skip every open connection for this identity (presence events). */
excludeIdentityOderId?: string;
}
// Deduplicate by oderId so users with multiple connections (e.g. from
// different signal URLs routing to the same server) receive the
// broadcast only once.
const sentToOderIds = new Set<string>();
export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
console.log(
`Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
`identity ${options?.excludeIdentityOderId ?? 'none'}:`,
message.type
);
connectedUsers.forEach((user) => {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
sentToOderIds.add(user.oderId);
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
connectedUsers.forEach((user, connectionId) => {
if (
!user.serverIds.has(serverId)
|| connectionId === options?.excludeConnectionId
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|| user.ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
user.ws.send(JSON.stringify(message));
} catch (error) {
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
}
});
}
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
}
}
export function notifyOtherConnectionsForOderId(
oderId: string,
message: WsMessage,
excludeConnectionId?: string
): void {
connectedUsers.forEach((user, connectionId) => {
if (
connectionId === excludeConnectionId
|| user.oderId !== oderId
|| user.ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
user.ws.send(JSON.stringify(message));
} catch (error) {
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
}
});
}
export function findUserByOderId(oderId: string) {
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
}
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
let voiceActiveMatch: ConnectedUser | undefined;
connectedUsers.forEach((user) => {
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
voiceActiveMatch = user;
}
});
return voiceActiveMatch;
}
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
let match: ConnectedUser | undefined;
connectedUsers.forEach((user) => {

View File

@@ -0,0 +1,219 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { handleWebSocketMessage } from './handler';
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
usersShareServerMembership: vi.fn(async () => true)
}));
vi.mock('../services/session-auth.service', () => ({
consumeSessionToken: vi.fn(async (token: string) => {
if (token !== 'test-token') {
return null;
}
return {
token,
user: {
id: 'user-1',
username: 'alice',
displayName: 'Alice',
passwordHash: 'hash',
createdAt: Date.now()
},
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000
};
})
}));
vi.mock('../services/plugin-support.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
return {
...actual,
getPluginRequirementsSnapshot: vi.fn(async () => ({
requirements: [],
eventDefinitions: []
}))
};
});
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => { ws.closeCalled = true; },
terminate: () => { ws.closeCalled = true; },
closeCalled: false,
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
return ws;
}
function createConnectedUser(
connectionId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const ws = createMockWs();
const user: ConnectedUser = {
oderId: connectionId,
ws,
authenticated: false,
serverIds: new Set(),
displayName: 'Alice',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getSentMessages(user: ConnectedUser): string[] {
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
}
describe('server websocket handler - multi-client sessions', () => {
beforeEach(() => {
connectedUsers.clear();
vi.clearAllMocks();
});
it('relays voice_state to other connections for the same user', async () => {
const sender = createConnectedUser('conn-a1', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-a'
});
const passive = createConnectedUser('conn-a2', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-b'
});
getSentMessages(passive).length = 0;
await handleWebSocketMessage('conn-a1', {
type: 'voice_state',
serverId: 'server-1',
voiceState: {
isConnected: true,
roomId: 'voice-1',
serverId: 'server-1',
clientInstanceId: 'device-a'
}
});
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
const voiceState = messages.find((message) => message.type === 'voice_state');
expect(voiceState).toBeDefined();
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
});
it('forwards RTC offers to the voice-active connection for the target user', async () => {
const sender = createConnectedUser('conn-sender', {
authenticated: true,
oderId: 'user-2',
serverIds: new Set(['server-1'])
});
createConnectedUser('conn-passive', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-passive'
});
const active = createConnectedUser('conn-active', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
voiceActive: true,
clientInstanceId: 'device-active'
});
getSentMessages(active).length = 0;
await handleWebSocketMessage('conn-sender', {
type: 'offer',
targetUserId: 'user-1',
serverId: 'server-1',
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
});
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
expect(messages.some((message) => message.type === 'offer')).toBe(true);
});
it('relays voice_client_takeover to other connections for the same user', async () => {
createConnectedUser('conn-requester', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-b'
});
const active = createConnectedUser('conn-active', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
voiceActive: true,
clientInstanceId: 'device-a'
});
getSentMessages(active).length = 0;
await handleWebSocketMessage('conn-requester', {
type: 'voice_client_takeover',
clientInstanceId: 'device-b'
});
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
expect(takeover?.clientInstanceId).toBe('device-b');
});
it('evicts a stale connection with the same identity scope and client instance', async () => {
const stale = createConnectedUser('conn-stale', {
authenticated: true,
oderId: 'user-1',
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
createConnectedUser('conn-new', {
authenticated: false,
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
await handleWebSocketMessage('conn-new', {
type: 'identify',
token: 'test-token',
oderId: 'user-1',
displayName: 'Alice',
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
expect(connectedUsers.has('conn-stale')).toBe(false);
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
});
});

View File

@@ -5,7 +5,8 @@ import {
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
isOderIdConnectedToServer,
notifyOtherConnectionsForOderId
} from './broadcast';
import {
authorizeWebSocketJoin,
@@ -72,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
};
}
function normalizeClientInstanceId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function readVoiceConnected(message: WsMessage): boolean {
const voiceState = message['voiceState'];
if (!voiceState || typeof voiceState !== 'object') {
return message['isConnected'] === true;
}
return (voiceState as { isConnected?: boolean }).isConnected === true;
}
function evictStaleClientInstanceConnections(
oderId: string,
connectionScope: string | undefined,
clientInstanceId: string | undefined,
keepConnectionId: string
): void {
if (!clientInstanceId) {
return;
}
connectedUsers.forEach((candidate, connectionId) => {
if (
connectionId === keepConnectionId
|| candidate.oderId !== oderId
|| candidate.connectionScope !== connectionScope
|| candidate.clientInstanceId !== clientInstanceId
) {
return;
}
try {
candidate.ws.close();
} catch {
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
}
connectedUsers.delete(connectionId);
});
}
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
connectedUsers.forEach((candidate, connectionId) => {
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
return;
}
candidate.voiceActive = false;
connectedUsers.set(connectionId, candidate);
});
}
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
user.ws.send(JSON.stringify({
type: 'voice_state',
...snapshot
}));
}
function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
@@ -198,13 +267,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
const newOderId = session.user.id;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
const newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt;
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
user.oderId = newOderId;
user.authenticated = true;
user.clientInstanceId = newClientInstanceId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
@@ -223,6 +296,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`);
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
otherConnectionId !== connectionId
&& otherUser.oderId === newOderId
&& otherUser.voiceActive
&& otherUser.voiceStateSnapshot
)?.[1]?.voiceStateSnapshot;
if (voiceSnapshot) {
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
}
if (
user.displayName === previousDisplayName
&& user.description === previousDescription
@@ -240,7 +324,7 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
...buildPresenceUserPayload(user),
serverId
},
user.oderId
{ excludeConnectionId: connectionId }
);
}
}
@@ -287,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
...buildPresenceUserPayload(user),
serverId: sid
},
user.oderId
{ excludeIdentityOderId: user.oderId }
);
}
}
@@ -338,7 +422,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
serverId: leaveSid,
serverIds: remainingServerIds
},
user.oderId
{ excludeIdentityOderId: user.oderId }
);
}
@@ -394,7 +478,7 @@ async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promi
}
}
function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
if (chatSid && user.serverIds.has(chatSid)) {
@@ -404,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
message: message['message'],
senderId: user.oderId,
senderName: user.displayName,
clientInstanceId: user.clientInstanceId,
timestamp: Date.now()
});
}, { excludeConnectionId: connectionId });
}
}
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!serverId || !user.serverIds.has(serverId)) {
return;
}
const isConnected = readVoiceConnected(message);
if (isConnected) {
clearVoiceActiveForOderId(user.oderId, connectionId);
user.voiceActive = true;
user.voiceStateSnapshot = {
...message,
type: 'voice_state',
serverId,
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName)
};
} else {
user.voiceActive = false;
user.voiceStateSnapshot = undefined;
}
connectedUsers.set(connectionId, user);
broadcastToServer(
serverId,
{
@@ -425,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName)
},
user.oderId
{ excludeConnectionId: connectionId }
);
}
function handleTyping(user: ConnectedUser, message: WsMessage): void {
function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
notifyOtherConnectionsForOderId(user.oderId, {
type: 'voice_client_takeover',
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
}, connectionId);
}
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
const isTyping = message['isTyping'] !== false;
@@ -443,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
channelId,
isTyping,
oderId: user.oderId,
displayName: user.displayName
displayName: user.displayName,
clientInstanceId: user.clientInstanceId
},
user.oderId
{ excludeConnectionId: connectionId }
);
}
}
@@ -475,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
oderId: user.oderId,
status
},
user.oderId
{ excludeConnectionId: connectionId }
);
}
}
@@ -520,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
}
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
const pluginId = readMessageId(message['pluginId']);
const eventName = readMessageId(message['eventName']);
@@ -565,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
sourceUserId: user.oderId,
emittedAt: Date.now()
},
user.oderId
{ excludeConnectionId: connectionId }
);
} catch (error) {
sendPluginError(user, error, message);
@@ -623,15 +736,19 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break;
case 'chat_message':
handleChatMessage(user, message);
handleChatMessage(user, message, connectionId);
break;
case 'voice_state':
handleVoiceState(user, message);
handleVoiceState(user, message, connectionId);
break;
case 'voice_client_takeover':
handleVoiceClientTakeover(user, message, connectionId);
break;
case 'typing':
handleTyping(user, message);
handleTyping(user, message, connectionId);
break;
case 'status_update':
@@ -647,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break;
case 'plugin_event':
await handlePluginEvent(user, message);
await handlePluginEvent(user, message, connectionId);
break;
default:

View File

@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
displayName: user.displayName,
serverId: sid,
serverIds: remainingServerIds
}, user.oderId);
}, { excludeIdentityOderId: user.oderId });
});
try {

View File

@@ -22,6 +22,12 @@ export interface ConnectedUser {
status?: 'online' | 'away' | 'busy' | 'offline';
/** Latest server icon timestamp this connection can provide over P2P. */
serverIconUpdatedAtByServerId?: Map<string, number>;
/** Stable per-install client id sent by the product client. */
clientInstanceId?: string;
/** Whether this connection currently owns active voice/WebRTC for the user. */
voiceActive?: boolean;
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
voiceStateSnapshot?: Record<string, unknown>;
/** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number;
}