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

@@ -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();