fix: Major bug cleanup pass 1
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s

This commit is contained in:
2026-06-09 17:59:54 +02:00
parent 80d7728e66
commit eb51f043ac
127 changed files with 2731 additions and 322 deletions

View File

@@ -51,7 +51,6 @@ describe('dispatchIncomingMessage multi-device sync', () => {
savedRooms: [{ id: 'room-a' }],
getClientInstanceId: () => 'device-a'
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{

View File

@@ -2,10 +2,7 @@
* Message store helpers - delegates pure domain logic to `domains/chat/domain/`
* and provides DB-dependent hydration/merge operations at the application level.
*/
import {
Message,
type MessageRevision
} from '../../shared-kernel';
import { Message, type MessageRevision } from '../../shared-kernel';
import { DatabaseService } from '../../infrastructure/persistence';
import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/rules/message.rules';
import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules';
@@ -14,10 +11,7 @@ import {
getMessageRevision,
shouldApplyIncomingRevision
} from '../../domains/chat/domain/rules/message-integrity.rules';
import {
materializeMessageFromRevision,
revisionBeatsMessage
} from '../../domains/chat/domain/rules/message-revision.builder.rules';
import { materializeMessageFromRevision, revisionBeatsMessage } from '../../domains/chat/domain/rules/message-revision.builder.rules';
// Re-export domain logic so existing callers keep working
export {

View File

@@ -36,12 +36,15 @@ import {
updateRoomMemberRole,
upsertRoomMember
} from './room-members.helpers';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
@Injectable()
export class RoomMembersSyncEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly signalServerAuth = inject(SignalServerAuthService);
/** Ensure the local user is recorded in a room as soon as it becomes active. */
ensureCurrentMemberOnRoomEntry$ = createEffect(() =>
@@ -175,7 +178,7 @@ export class RoomMembersSyncEffects {
if (!room)
return EMPTY;
const myId = currentUser?.oderId || currentUser?.id;
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl);
switch (signalingMessage.type) {
case 'server_users': {
@@ -185,7 +188,7 @@ export class RoomMembersSyncEffects {
let members = room.members ?? [];
for (const user of signalingMessage.users as { oderId: string; displayName: string }[]) {
if (!user?.oderId || user.oderId === myId)
if (!user?.oderId || isSelfPresenceUserId(user.oderId, selfIds))
continue;
members = upsertRoomMember(members, this.buildPresenceMember(room, user));
@@ -197,7 +200,7 @@ export class RoomMembersSyncEffects {
}
case 'user_joined': {
if (!signalingMessage.oderId || signalingMessage.oderId === myId)
if (!signalingMessage.oderId || isSelfPresenceUserId(signalingMessage.oderId, selfIds))
return EMPTY;
const joinedUser = {

View File

@@ -145,10 +145,18 @@ describe('RoomSignalingConnection', () => {
});
it('tries fallback endpoints when the primary endpoint is offline', async () => {
const signalServerAuthorize = {
ensureCredentialForServerUrl: vi.fn(async () => true)
};
const signalServerAuth = {
resolveActorUserIdForServer: vi.fn((_url: string, fallback: string) => fallback)
};
const connection = new RoomSignalingConnection(
webrtc as unknown as RealtimeSessionFacade,
serverDirectory as unknown as ServerDirectoryFacade,
store as unknown as Store
store as unknown as Store,
signalServerAuthorize as never,
signalServerAuth as never
);
connection.beginRoomNavigation(room.id);
@@ -158,4 +166,39 @@ describe('RoomSignalingConnection', () => {
expect(webrtc.connectToSignalingServer).toHaveBeenCalledWith('wss://signal-sweden.toju.app');
expect(webrtc.joinRoom).toHaveBeenCalledWith(room.id, user.oderId, 'wss://signal-sweden.toju.app');
});
it('joins with the per-server actor user id when provisioned on a foreign signal server', async () => {
const foreignRoom: Room = {
...room,
sourceUrl: 'https://signal-sweden.toju.app'
};
const signalServerAuthorize = {
ensureCredentialForServerUrl: vi.fn(async () => true)
};
const signalServerAuth = {
resolveActorUserIdForServer: vi.fn(() => 'foreign-user-id')
};
serverDirectory.ensureEndpointVersionCompatibility = vi.fn(async () => true);
webrtc.isSignalingConnectedTo = vi.fn(() => true);
const connection = new RoomSignalingConnection(
webrtc as unknown as RealtimeSessionFacade,
serverDirectory as unknown as ServerDirectoryFacade,
store as unknown as Store,
signalServerAuthorize as never,
signalServerAuth as never
);
connection.beginRoomNavigation(foreignRoom.id);
await connection.connectToRoomSignaling(foreignRoom, user, user.oderId, [foreignRoom]);
expect(webrtc.joinRoom).toHaveBeenCalledWith(foreignRoom.id, 'foreign-user-id', 'wss://signal-sweden.toju.app');
expect(webrtc.identify).toHaveBeenCalledWith(
'foreign-user-id',
user.displayName,
'wss://signal-sweden.toju.app',
expect.any(Object)
);
});
});

View File

@@ -9,6 +9,8 @@ import {
CLIENT_UPDATE_REQUIRED_MESSAGE
} from '../../domains/server-directory';
import { RealtimeSessionFacade } from '../../core/realtime';
import { SignalServerAuthorizeService } from '../../domains/authentication/application/services/signal-server-authorize.service';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { RoomsActions } from './rooms.actions';
import { resolveUserDisplayName, extractRoomIdFromUrl } from './rooms.helpers';
@@ -34,7 +36,9 @@ export class RoomSignalingConnection {
constructor(
private readonly webrtc: RealtimeSessionFacade,
private readonly serverDirectory: ServerDirectoryFacade,
private readonly store: Store
private readonly store: Store,
private readonly signalServerAuthorize: SignalServerAuthorizeService,
private readonly signalServerAuth: SignalServerAuthService
) {}
// ── Navigation versioning ──────────────────────────────────────
@@ -361,7 +365,17 @@ export class RoomSignalingConnection {
}
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
if (source.sourceUrl) {
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(source.sourceUrl);
if (!hasCredential) {
return false;
}
}
const homeOderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
const oderId = this.signalServerAuth.resolveActorUserIdForServer(source.sourceUrl, homeOderId);
const displayName = resolveUserDisplayName(user);
const description = user?.description;
const profileUpdatedAt = user?.profileUpdatedAt;

View File

@@ -45,6 +45,8 @@ import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
import {
buildSignalingUser,
buildKnownUserExtras,
@@ -56,7 +58,6 @@ import {
getPersistedCurrentUserId
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
import { SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
1_500,
@@ -79,6 +80,7 @@ export class RoomStateSyncEffects {
private audioService = inject(NotificationAudioService);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceClientTakeoverService = inject(VoiceClientTakeoverService);
private signalServerAuth = inject(SignalServerAuthService);
private clientInstanceService = inject(ClientInstanceService);
/**
@@ -114,9 +116,9 @@ export class RoomStateSyncEffects {
allUsers
]) => {
const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
const room = resolveRoom(signalingMessage.serverId, currentRoom, savedRooms);
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room?.sourceUrl);
const viewedServerId = currentRoom?.id;
const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId);
switch (signalingMessage.type) {
@@ -125,7 +127,7 @@ export class RoomStateSyncEffects {
return EMPTY;
const syncedUsers = signalingMessage.users
.filter((user) => user.oderId !== myId)
.filter((user) => !isSelfPresenceUserId(user.oderId, selfIds))
.map((user) =>
buildSignalingUser(user, {
...buildKnownUserExtras(room, user.oderId),
@@ -152,7 +154,7 @@ export class RoomStateSyncEffects {
}
case 'user_joined': {
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
if (!signalingMessage.serverId || isSelfPresenceUserId(signalingMessage.oderId, selfIds))
return EMPTY;
if (!signalingMessage.oderId)
@@ -281,7 +283,7 @@ export class RoomStateSyncEffects {
const serverId = signalingMessage.serverId;
for (const user of signalingMessage.users) {
if (!user.oderId || user.oderId === myId) {
if (!user.oderId || isSelfPresenceUserId(user.oderId, selfIds)) {
continue;
}
@@ -330,10 +332,6 @@ export class RoomStateSyncEffects {
);
}
case 'auth_required':
case 'auth_error':
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
default:
return EMPTY;
}

View File

@@ -52,7 +52,10 @@ import {
findRoomMember
} from './room-members.helpers';
import { defaultChannels } from './room-channels.defaults';
import { buildServerRegistrationPayload } from './server-registration.rules';
import { RoomSignalingConnection } from './room-signaling-connection';
import { SignalServerAuthorizeService } from '../../domains/authentication/application/services/signal-server-authorize.service';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import {
resolveRoomChannels,
resolveTextChannelId,
@@ -74,11 +77,15 @@ export class RoomsEffects {
private webrtc = inject(RealtimeSessionFacade);
private serverDirectory = inject(ServerDirectoryFacade);
private readonly i18n = inject(AppI18nService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly signalingConnection = new RoomSignalingConnection(
this.webrtc,
this.serverDirectory,
this.store
this.store,
this.signalServerAuthorize,
this.signalServerAuth
);
/** Loads all saved rooms from the local database. */
@@ -245,28 +252,21 @@ export class RoomsEffects {
map(() => {
// Register with central server (using the same room ID for discoverability)
this.serverDirectory
.registerServer({
id: room.id, // Use the same ID as the local room
name: room.name,
description: room.description,
ownerId: currentUser.id,
ownerPublicKey: currentUser.oderId,
hostName: currentUser.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate,
userCount: room.userCount,
maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [],
channels: room.channels ?? defaultChannels()
}, endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
} : undefined
.registerServer(
buildServerRegistrationPayload(room, currentUser, normalizedPassword),
endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
} : undefined
)
.subscribe();
.subscribe({
error: (error) => {
// Registration is best-effort, but never swallow the failure
// silently: otherwise the creator lands in a room view for a
// server that was never persisted (invites/search 404).
console.error('Failed to register created server with directory:', error);
}
});
return RoomsActions.createRoomSuccess({ room });
})

View File

@@ -0,0 +1,79 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Room } from '../../shared-kernel';
import { buildServerRegistrationPayload } from './server-registration.rules';
function makeRoom(overrides: Partial<Room> = {}): Room {
return {
id: 'room-1',
name: 'My Server',
description: 'desc',
topic: 'gaming',
hostId: 'user-1',
hasPassword: false,
isPrivate: false,
createdAt: 1,
userCount: 1,
maxUsers: 50,
channels: [{ id: 'c1', name: 'general', type: 'text', position: 0 }],
...overrides
} as Room;
}
describe('buildServerRegistrationPayload', () => {
it('uses oderId as the owner public key when present', () => {
const payload = buildServerRegistrationPayload(
makeRoom(),
{ id: 'user-1', oderId: 'oder-1', displayName: 'Alice' },
''
);
expect(payload.ownerId).toBe('user-1');
expect(payload.ownerPublicKey).toBe('oder-1');
});
it('falls back to the user id when oderId is missing so the server accepts the registration', () => {
const payload = buildServerRegistrationPayload(
makeRoom(),
{ id: 'user-1', oderId: undefined, displayName: 'Alice' },
''
);
// Server returns 400 "Missing required fields" without a truthy ownerPublicKey.
expect(payload.ownerPublicKey).toBe('user-1');
});
it('normalizes password presence', () => {
const withPw = buildServerRegistrationPayload(
makeRoom(),
{ id: 'user-1', oderId: 'oder-1', displayName: 'Alice' },
'secret'
);
const withoutPw = buildServerRegistrationPayload(
makeRoom(),
{ id: 'user-1', oderId: 'oder-1', displayName: 'Alice' },
''
);
expect(withPw.password).toBe('secret');
expect(withPw.hasPassword).toBe(true);
expect(withoutPw.password).toBeNull();
expect(withoutPw.hasPassword).toBe(false);
});
it('carries the room channels and identity through to the payload', () => {
const payload = buildServerRegistrationPayload(
makeRoom({ id: 'room-9', name: 'Beta' }),
{ id: 'user-2', oderId: 'oder-2', displayName: 'Bob' },
''
);
expect(payload.id).toBe('room-9');
expect(payload.name).toBe('Beta');
expect(payload.hostName).toBe('Bob');
expect(payload.channels).toHaveLength(1);
});
});

View File

@@ -0,0 +1,59 @@
import type { Room } from '../../shared-kernel';
import { defaultChannels } from './room-channels.defaults';
export interface ServerRegistrationOwner {
id: string;
oderId?: string;
displayName: string;
}
export interface ServerRegistrationPayload {
id: string;
name: string;
description?: string;
ownerId: string;
ownerPublicKey: string;
hostName: string;
password: string | null;
hasPassword: boolean;
isPrivate: boolean;
userCount: number;
maxUsers: number;
icon?: string;
iconUpdatedAt?: number;
tags: string[];
channels: NonNullable<Room['channels']>;
}
/**
* Build the `/api/servers` registration body for a freshly created room.
*
* `ownerPublicKey` must be truthy or the server rejects the request with
* `400 Missing required fields`, which (because registration is fire-and-forget)
* leaves the creator inside a room view for a server that was never persisted.
* `oderId` can be absent on restored sessions, so fall back to the user id the
* same way the rest of the app resolves an actor identity (`oderId || id`).
*/
export function buildServerRegistrationPayload(
room: Room,
owner: ServerRegistrationOwner,
normalizedPassword: string
): ServerRegistrationPayload {
return {
id: room.id,
name: room.name,
description: room.description,
ownerId: owner.id,
ownerPublicKey: owner.oderId || owner.id,
hostName: owner.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate ?? false,
userCount: room.userCount,
maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [],
channels: room.channels ?? defaultChannels()
};
}

View File

@@ -15,11 +15,19 @@ import {
CameraState,
GameActivity
} from '../../shared-kernel';
import type { LoginResponse } from '../../domains/authentication/domain/models/authentication.model';
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Authenticate User': props<{ user: User }>(),
'Authenticate User': props<{ user: User; loginResponse?: LoginResponse }>(),
'Authorize Signal Server': props<{
serverUrl: string;
response: LoginResponse;
provisioned?: boolean;
}>(),
'Revoke Signal Server Credential': props<{ serverUrl: string }>(),
'Signal Server Auth Failed': props<{ signalUrl: string }>(),
'Load Current User': emptyProps(),
'Load Current User Success': props<{ user: User }>(),
'Load Current User Failure': props<{ error: string }>(),

View File

@@ -53,6 +53,7 @@ import { clearStoredCurrentUserId, setStoredCurrentUserId } from '../../core/sto
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
import { AppI18nService } from '../../core/i18n';
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
import { buildLoginReturnQueryParams } from '../../domains/authentication/domain/logic/auth-navigation.rules';
@@ -74,14 +75,16 @@ export class UsersEffects {
private webrtc = inject(RealtimeSessionFacade);
private readonly i18n = inject(AppI18nService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly signalServerAuthRetries = new Map<string, { count: number; windowStart: number }>();
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly router = inject(Router);
/** Prepares persisted state for a successful login before exposing the user in-memory. */
authenticateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.authenticateUser),
switchMap(({ user }) =>
from(this.prepareAuthenticatedUserStorage(user)).pipe(
switchMap(({ user, loginResponse }) =>
from(this.prepareAuthenticatedUserStorage(user, loginResponse)).pipe(
mergeMap(() => [
MessagesActions.clearMessages(),
UsersActions.resetUsersState(),
@@ -99,6 +102,119 @@ export class UsersEffects {
)
);
/** Stores credentials for a foreign signal server without resetting local state. */
authorizeSignalServer$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.authorizeSignalServer),
tap(({ serverUrl, response, provisioned }) => {
this.signalServerAuth.upsertCredentialFromLogin(serverUrl, response, { provisioned });
})
),
{ dispatch: false }
);
/** Clears credentials for a single signal server. */
revokeSignalServerCredential$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.revokeSignalServerCredential),
tap(({ serverUrl }) => {
this.signalServerAuth.clearCredential(serverUrl);
})
),
{ dispatch: false }
);
/** Re-provisions or logs out depending on which signal server rejected auth. */
signalServerAuthFailed$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.signalServerAuthFailed),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ signalUrl }, currentUser]) => {
const normalizedSignalUrl = signalUrl.replace(/^ws/i, 'http').replace(/\/+$/, '');
const homeSignalServerUrl = currentUser?.homeSignalServerUrl?.replace(/\/+$/, '');
// A rejection while we still hold a valid credential is almost always
// transient (a message raced ahead of identify on a (re)connect). Re-identify
// with the existing credential instead of tearing down the session or
// provisioning a duplicate account. Bounded so a genuinely invalid token
// (server-side revocation) still falls through to expiry/provisioning.
if (
currentUser
&& this.signalServerAuth.hasValidCredential(normalizedSignalUrl)
&& this.shouldRetrySignalIdentify(normalizedSignalUrl)
) {
this.webrtc.identify(
currentUser.oderId || currentUser.id,
this.resolveDisplayName(currentUser),
signalUrl,
{
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt,
homeSignalServerUrl: currentUser.homeSignalServerUrl
}
);
return EMPTY;
}
this.signalServerAuthRetries.delete(normalizedSignalUrl);
this.signalServerAuth.clearCredential(normalizedSignalUrl);
if (homeSignalServerUrl && normalizedSignalUrl === homeSignalServerUrl) {
clearStoredCurrentUserId();
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
}
return from(this.signalServerAuth.ensureProvisioned(normalizedSignalUrl, currentUser)).pipe(
mergeMap((result) => {
if (result.kind === 'provisioned' || result.kind === 'existing') {
if (currentUser) {
this.webrtc.identify(
currentUser.oderId || currentUser.id,
this.resolveDisplayName(currentUser),
signalUrl,
{
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt,
homeSignalServerUrl: currentUser.homeSignalServerUrl
}
);
}
return EMPTY;
}
return EMPTY;
}),
catchError(() => EMPTY)
);
})
)
);
/** Provisions missing credentials for active signal servers after home login loads. */
provisionActiveSignalServers$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.loadCurrentUserSuccess),
tap(({ user }) => {
this.signalServerAuth.migrateHomeCredential(user);
for (const endpoint of this.serverDirectory.activeServers()) {
if (endpoint.status === 'incompatible' || !endpoint.isActive) {
continue;
}
void this.signalServerAuth.ensureProvisioned(endpoint.url, user).catch(() => undefined);
}
})
),
{ dispatch: false }
);
// Load current user from storage
/** Loads the persisted current user from the local database on startup. */
loadCurrentUser$ = createEffect(() =>
@@ -166,11 +282,25 @@ export class UsersEffects {
};
}
private async prepareAuthenticatedUserStorage(user: User): Promise<void> {
private async prepareAuthenticatedUserStorage(
user: User,
loginResponse?: {
id: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
}
): Promise<void> {
setStoredCurrentUserId(user.id);
await this.db.initialize();
await this.db.setCurrentUserId(user.id);
await this.db.saveUser(user);
if (user.homeSignalServerUrl && loginResponse) {
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
await this.signalServerAuth.ensureHomeProvisionSecret(user);
}
}
/** Loads all users associated with a specific room from the local database. */
@@ -553,6 +683,32 @@ export class UsersEffects {
);
}
/**
* Bounded guard for re-identifying after a transient signal-server auth rejection.
* Allows a small number of retries per rolling window so a genuinely revoked token
* eventually falls through to session-expiry / re-provisioning instead of looping.
*/
private shouldRetrySignalIdentify(normalizedSignalUrl: string): boolean {
const MAX_RETRIES = 3;
const WINDOW_MS = 30_000;
const now = Date.now();
const existing = this.signalServerAuthRetries.get(normalizedSignalUrl);
if (!existing || now - existing.windowStart > WINDOW_MS) {
this.signalServerAuthRetries.set(normalizedSignalUrl, { count: 1, windowStart: now });
return true;
}
if (existing.count >= MAX_RETRIES) {
return false;
}
existing.count += 1;
return true;
}
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
const displayName = user.displayName?.trim();