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
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:
@@ -51,7 +51,6 @@ describe('dispatchIncomingMessage multi-device sync', () => {
|
||||
savedRooms: [{ id: 'room-a' }],
|
||||
getClientInstanceId: () => 'device-a'
|
||||
});
|
||||
|
||||
const action = await firstValueFrom(
|
||||
dispatchIncomingMessage(
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
59
toju-app/src/app/store/rooms/server-registration.rules.ts
Normal file
59
toju-app/src/app/store/rooms/server-registration.rules.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
@@ -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 }>(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user