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:
@@ -323,6 +323,8 @@ export interface ElectronApi {
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||
storeProvisionSecret?: (homeUserId: string, secret: string) => Promise<boolean>;
|
||||
getProvisionSecret?: (homeUserId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export type ElectronWindow = Window & {
|
||||
|
||||
@@ -18,6 +18,12 @@ export class AuthTokenStoreService {
|
||||
}
|
||||
|
||||
getToken(serverUrl: string): string | null {
|
||||
const entry = this.getTokenEntry(serverUrl);
|
||||
|
||||
return entry?.token ?? null;
|
||||
}
|
||||
|
||||
getTokenEntry(serverUrl: string): StoredAuthToken | null {
|
||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||
const entry = this.readStore()[normalizedUrl];
|
||||
|
||||
@@ -30,7 +36,7 @@ export class AuthTokenStoreService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.token;
|
||||
return entry;
|
||||
}
|
||||
|
||||
clearToken(serverUrl: string): void {
|
||||
|
||||
@@ -34,7 +34,13 @@ export class AuthenticationService {
|
||||
}
|
||||
|
||||
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
||||
this.authTokenStore.setToken(this.resolveServerUrl(serverId), response.token, response.expiresAt);
|
||||
const serverUrl = this.resolveServerUrl(serverId);
|
||||
|
||||
this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt);
|
||||
}
|
||||
|
||||
resolveServerUrlFor(serverId?: string): string {
|
||||
return this.resolveServerUrl(serverId);
|
||||
}
|
||||
|
||||
private endpointFor(serverId?: string): string {
|
||||
|
||||
@@ -101,10 +101,7 @@ export class MessageSigningService {
|
||||
const stored = this.readStoredKeyPair();
|
||||
|
||||
if (stored) {
|
||||
const [publicKey, privateKey] = await Promise.all([
|
||||
crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']),
|
||||
crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])
|
||||
]);
|
||||
const [publicKey, privateKey] = await Promise.all([crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']), crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])]);
|
||||
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
@@ -114,10 +111,7 @@ export class MessageSigningService {
|
||||
true,
|
||||
['sign', 'verify']
|
||||
);
|
||||
const [publicKeyJwk, privateKeyJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', generated.publicKey),
|
||||
crypto.subtle.exportKey('jwk', generated.privateKey)
|
||||
]);
|
||||
const [publicKeyJwk, privateKeyJwk] = await Promise.all([crypto.subtle.exportKey('jwk', generated.publicKey), crypto.subtle.exportKey('jwk', generated.privateKey)]);
|
||||
|
||||
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { ProvisionSecretStoreService } from './provision-secret-store.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
|
||||
describe('ProvisionSecretStoreService', () => {
|
||||
let service: ProvisionSecretStoreService;
|
||||
let electronBridge: ElectronBridgeService;
|
||||
|
||||
beforeEach(() => {
|
||||
const sessionStorageMap = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { sessionStorageMap.set(key, value); },
|
||||
removeItem: (key: string) => { sessionStorageMap.delete(key); },
|
||||
clear: () => { sessionStorageMap.clear(); }
|
||||
});
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: false,
|
||||
getApi: () => null,
|
||||
requireApi: () => {
|
||||
throw new Error('Electron API is not available in this runtime.');
|
||||
}
|
||||
} as ElectronBridgeService;
|
||||
|
||||
service = new ProvisionSecretStoreService(electronBridge);
|
||||
});
|
||||
|
||||
it('stores and retrieves provision secrets in session storage when electron is unavailable', async () => {
|
||||
await service.storeSecret('home-user-1', 'secret-abc');
|
||||
await expect(service.getSecret('home-user-1')).resolves.toBe('secret-abc');
|
||||
});
|
||||
|
||||
it('uses electron secure storage when available', async () => {
|
||||
const storeProvisionSecret = vi.fn(async () => true);
|
||||
const getProvisionSecret = vi.fn(async () => 'electron-secret');
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: true,
|
||||
getApi: () => ({
|
||||
storeProvisionSecret,
|
||||
getProvisionSecret
|
||||
}),
|
||||
requireApi: () => ({
|
||||
storeProvisionSecret,
|
||||
getProvisionSecret
|
||||
})
|
||||
} as unknown as ElectronBridgeService;
|
||||
|
||||
service = new ProvisionSecretStoreService(electronBridge);
|
||||
|
||||
await service.storeSecret('home-user-1', 'secret-abc');
|
||||
await expect(service.getSecret('home-user-1')).resolves.toBe('electron-secret');
|
||||
|
||||
expect(storeProvisionSecret).toHaveBeenCalledWith('home-user-1', 'secret-abc');
|
||||
expect(getProvisionSecret).toHaveBeenCalledWith('home-user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
|
||||
const SESSION_STORAGE_PREFIX = 'metoyou.provisionSecret.';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProvisionSecretStoreService {
|
||||
private readonly electronBridge: ElectronBridgeService;
|
||||
|
||||
constructor(electronBridge: ElectronBridgeService = inject(ElectronBridgeService)) {
|
||||
this.electronBridge = electronBridge;
|
||||
}
|
||||
|
||||
async storeSecret(homeUserId: string, secret: string): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api?.storeProvisionSecret) {
|
||||
await api.storeProvisionSecret(homeUserId, secret);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(this.sessionKey(homeUserId), secret);
|
||||
}
|
||||
|
||||
async getSecret(homeUserId: string): Promise<string | null> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api?.getProvisionSecret) {
|
||||
return api.getProvisionSecret(homeUserId);
|
||||
}
|
||||
|
||||
return sessionStorage.getItem(this.sessionKey(homeUserId));
|
||||
}
|
||||
|
||||
async hasSecret(homeUserId: string): Promise<boolean> {
|
||||
const secret = await this.getSecret(homeUserId);
|
||||
|
||||
return !!secret;
|
||||
}
|
||||
|
||||
private sessionKey(homeUserId: string): string {
|
||||
return `${SESSION_STORAGE_PREFIX}${homeUserId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateProvisionSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
||||
import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules';
|
||||
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
|
||||
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||
import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service';
|
||||
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
|
||||
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
|
||||
|
||||
export type EnsureProvisionedResult =
|
||||
| { kind: 'existing'; credential: SignalServerCredential }
|
||||
| { kind: 'provisioned'; result: ProvisionResult }
|
||||
| { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' }
|
||||
| { kind: 'collision'; error: ProvisionUsernameCollisionError };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SignalServerAuthService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly provisionSecretStore = inject(ProvisionSecretStoreService);
|
||||
private readonly provisioner = inject(SignalServerProvisionerService);
|
||||
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
|
||||
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
|
||||
|
||||
getCredential(serverUrl: string): SignalServerCredential | null {
|
||||
return this.credentialStore.getCredential(serverUrl);
|
||||
}
|
||||
|
||||
hasValidCredential(serverUrl: string): boolean {
|
||||
return this.credentialStore.hasValidCredential(serverUrl);
|
||||
}
|
||||
|
||||
upsertCredentialFromLogin(
|
||||
serverUrl: string,
|
||||
response: LoginResponse,
|
||||
options: { provisioned?: boolean } = {}
|
||||
): SignalServerCredential {
|
||||
return this.provisioner.upsertManualCredential(serverUrl, response, options.provisioned ?? false);
|
||||
}
|
||||
|
||||
clearCredential(serverUrl: string): void {
|
||||
this.credentialStore.clearCredential(serverUrl);
|
||||
}
|
||||
|
||||
migrateHomeCredential(user: Pick<User, 'id' | 'username' | 'displayName' | 'homeSignalServerUrl'>): void {
|
||||
const homeSignalServerUrl = user.homeSignalServerUrl?.trim();
|
||||
|
||||
if (!homeSignalServerUrl || this.credentialStore.hasValidCredential(homeSignalServerUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenEntry = this.authTokenStore.getTokenEntry(homeSignalServerUrl);
|
||||
|
||||
if (!tokenEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.credentialStore.upsertCredential({
|
||||
serverUrl: homeSignalServerUrl,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
token: tokenEntry.token,
|
||||
expiresAt: tokenEntry.expiresAt,
|
||||
provisioned: false
|
||||
});
|
||||
}
|
||||
|
||||
async ensureHomeProvisionSecret(homeUser: Pick<User, 'id'>, existingSecret?: string | null): Promise<string> {
|
||||
const stored = existingSecret ?? await this.provisionSecretStore.getSecret(homeUser.id);
|
||||
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const generated = generateProvisionSecret();
|
||||
|
||||
await this.provisionSecretStore.storeSecret(homeUser.id, generated);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise<EnsureProvisionedResult> {
|
||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||
const existing = this.credentialStore.getCredential(normalizedUrl);
|
||||
|
||||
if (existing) {
|
||||
return { kind: 'existing', credential: existing };
|
||||
}
|
||||
|
||||
const inFlight = this.provisionInFlight.get(normalizedUrl);
|
||||
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const provisionPromise = this.runProvision(normalizedUrl, homeUser);
|
||||
|
||||
this.provisionInFlight.set(normalizedUrl, provisionPromise);
|
||||
|
||||
try {
|
||||
return await provisionPromise;
|
||||
} finally {
|
||||
this.provisionInFlight.delete(normalizedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
resolveActorUserIdForServer(serverUrl: string | undefined, fallbackUserId: string): string {
|
||||
if (!serverUrl?.trim()) {
|
||||
return fallbackUserId;
|
||||
}
|
||||
|
||||
const credential = this.getCredential(serverUrl);
|
||||
|
||||
return credential?.userId ?? fallbackUserId;
|
||||
}
|
||||
|
||||
resolveSelfPresenceUserIdsForRoom(
|
||||
currentUser: Pick<User, 'id' | 'oderId'> | null | undefined,
|
||||
roomSourceUrl: string | undefined
|
||||
): ReadonlySet<string> {
|
||||
const homeOderId = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
return resolveSelfPresenceUserIds({
|
||||
homeUserId: currentUser?.id,
|
||||
homeOderId,
|
||||
actorUserId: homeOderId
|
||||
? this.resolveActorUserIdForServer(roomSourceUrl, homeOderId)
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
|
||||
resolveCredentialForSignalUrl(
|
||||
signalUrl: string,
|
||||
homeUser?: Pick<User, 'id' | 'homeSignalServerUrl' | 'displayName'> | null
|
||||
): ResolvedSignalIdentity | null {
|
||||
const httpUrl = signalUrl.replace(/^ws/i, 'http');
|
||||
|
||||
return resolveSignalIdentity(
|
||||
this.credentialStore.getCredential(httpUrl),
|
||||
this.authTokenStore.getTokenEntry(httpUrl),
|
||||
homeUser
|
||||
);
|
||||
}
|
||||
|
||||
private async runProvision(
|
||||
normalizedUrl: string,
|
||||
homeUser?: User | null
|
||||
): Promise<EnsureProvisionedResult> {
|
||||
const user = homeUser ?? await firstValueFrom(this.store.select(selectCurrentUser));
|
||||
|
||||
if (!user) {
|
||||
return { kind: 'skipped', reason: 'no-home-user' };
|
||||
}
|
||||
|
||||
const provisionSecret = await this.provisionSecretStore.getSecret(user.id);
|
||||
|
||||
if (!provisionSecret) {
|
||||
return { kind: 'skipped', reason: 'no-provision-secret' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.provisioner.provisionOnServer({
|
||||
serverUrl: normalizedUrl,
|
||||
homeUser: user,
|
||||
provisionSecret
|
||||
});
|
||||
|
||||
if (result.usedSuffix) {
|
||||
this.provisionNotice.publish({
|
||||
serverName: this.resolveServerDisplayName(normalizedUrl),
|
||||
preferredUsername: user.username,
|
||||
provisionedUsername: result.username
|
||||
});
|
||||
}
|
||||
|
||||
return { kind: 'provisioned', result };
|
||||
} catch (error) {
|
||||
if (error instanceof ProvisionUsernameCollisionError) {
|
||||
return { kind: 'collision', error };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private resolveServerDisplayName(serverUrl: string): string {
|
||||
try {
|
||||
return new URL(serverUrl).hostname;
|
||||
} catch {
|
||||
return serverUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
||||
import { SignalServerAuthService } from './signal-server-auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SignalServerAuthorizeService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
async ensureCredentialForServerUrl(serverUrl: string): Promise<boolean> {
|
||||
if (this.signalServerAuth.hasValidCredential(serverUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentUser = await firstValueFrom(this.store.select(selectCurrentUser));
|
||||
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
|
||||
|
||||
if (result.kind === 'existing' || result.kind === 'provisioned') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.kind === 'collision') {
|
||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.kind === 'skipped' && result.reason === 'no-provision-secret') {
|
||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||
name: this.buildEndpointName(serverUrl),
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(returnUrl, undefined, {
|
||||
mode: AUTH_MODE_AUTHORIZE,
|
||||
serverId: endpoint.id
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private buildEndpointName(serverUrl: string): string {
|
||||
try {
|
||||
return new URL(serverUrl).hostname;
|
||||
} catch {
|
||||
return serverUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach
|
||||
} from 'vitest';
|
||||
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||
|
||||
describe('SignalServerCredentialStoreService', () => {
|
||||
let service: SignalServerCredentialStoreService;
|
||||
|
||||
beforeEach(() => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); }
|
||||
});
|
||||
|
||||
service = new SignalServerCredentialStoreService(new AuthTokenStoreService());
|
||||
});
|
||||
|
||||
it('stores and retrieves credentials by normalized server url', () => {
|
||||
service.upsertCredential({
|
||||
serverUrl: 'https://signal.example.com/',
|
||||
userId: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'token-abc',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
provisioned: true
|
||||
});
|
||||
|
||||
const credential = service.getCredential('https://signal.example.com');
|
||||
|
||||
expect(credential?.userId).toBe('user-1');
|
||||
expect(credential?.token).toBe('token-abc');
|
||||
});
|
||||
|
||||
it('clears expired credentials on read', () => {
|
||||
service.upsertCredential({
|
||||
serverUrl: 'https://signal.example.com',
|
||||
userId: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'expired',
|
||||
expiresAt: Date.now() - 1,
|
||||
provisioned: false
|
||||
});
|
||||
|
||||
expect(service.getCredential('https://signal.example.com')).toBeNull();
|
||||
expect(service.hasValidCredential('https://signal.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes credentials for a single server url', () => {
|
||||
service.upsertCredential({
|
||||
serverUrl: 'https://signal-a.example.com',
|
||||
userId: 'user-a',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'token-a',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
provisioned: true
|
||||
});
|
||||
|
||||
service.upsertCredential({
|
||||
serverUrl: 'https://signal-b.example.com',
|
||||
userId: 'user-b',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'token-b',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
provisioned: true
|
||||
});
|
||||
|
||||
service.clearCredential('https://signal-a.example.com');
|
||||
|
||||
expect(service.getCredential('https://signal-a.example.com')).toBeNull();
|
||||
expect(service.getCredential('https://signal-b.example.com')?.token).toBe('token-b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||
|
||||
const STORAGE_KEY = 'metoyou.signalServerCredentials';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SignalServerCredentialStoreService {
|
||||
private readonly authTokenStore: AuthTokenStoreService;
|
||||
|
||||
constructor(authTokenStore: AuthTokenStoreService = inject(AuthTokenStoreService)) {
|
||||
this.authTokenStore = authTokenStore;
|
||||
}
|
||||
|
||||
upsertCredential(credential: SignalServerCredential): void {
|
||||
const normalizedUrl = this.normalizeServerUrl(credential.serverUrl);
|
||||
const store = this.readStore();
|
||||
|
||||
store[normalizedUrl] = {
|
||||
...credential,
|
||||
serverUrl: normalizedUrl
|
||||
};
|
||||
|
||||
this.writeStore(store);
|
||||
this.authTokenStore.setToken(normalizedUrl, credential.token, credential.expiresAt);
|
||||
}
|
||||
|
||||
getCredential(serverUrl: string): SignalServerCredential | null {
|
||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||
const entry = this.readStore()[normalizedUrl];
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.clearCredential(serverUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
hasValidCredential(serverUrl: string): boolean {
|
||||
return this.getCredential(serverUrl) !== null;
|
||||
}
|
||||
|
||||
clearCredential(serverUrl: string): void {
|
||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||
const store = this.readStore();
|
||||
const nextStore = Object.fromEntries(
|
||||
Object.entries(store).filter(([key]) => key !== normalizedUrl)
|
||||
) as Record<string, SignalServerCredential>;
|
||||
|
||||
this.writeStore(nextStore);
|
||||
this.authTokenStore.clearToken(normalizedUrl);
|
||||
}
|
||||
|
||||
listValidCredentials(): SignalServerCredential[] {
|
||||
const now = Date.now();
|
||||
|
||||
return Object.values(this.readStore()).filter((entry) => entry.expiresAt > now);
|
||||
}
|
||||
|
||||
private readStore(): Record<string, SignalServerCredential> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, SignalServerCredential>;
|
||||
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private writeStore(store: Record<string, SignalServerCredential>): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
}
|
||||
|
||||
private normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface SignalServerProvisionNotice {
|
||||
serverName: string;
|
||||
preferredUsername: string;
|
||||
provisionedUsername: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SignalServerProvisionNoticeService {
|
||||
readonly notice = signal<SignalServerProvisionNotice | null>(null);
|
||||
|
||||
publish(notice: SignalServerProvisionNotice): void {
|
||||
this.notice.set(notice);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.notice.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { SignalServerProvisionerService } from './signal-server-provisioner.service';
|
||||
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
describe('SignalServerProvisionerService', () => {
|
||||
let service: SignalServerProvisionerService;
|
||||
let httpPost: ReturnType<typeof vi.fn>;
|
||||
let credentialStore: SignalServerCredentialStoreService;
|
||||
|
||||
const homeUser: User = {
|
||||
id: 'a3f2b1c4-5678-90ab-cdef-1234567890ab',
|
||||
oderId: 'a3f2b1c4-5678-90ab-cdef-1234567890ab',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
homeSignalServerUrl: 'https://home.example.com'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); }
|
||||
});
|
||||
|
||||
httpPost = vi.fn();
|
||||
credentialStore = new SignalServerCredentialStoreService(new AuthTokenStoreService());
|
||||
service = new SignalServerProvisionerService(
|
||||
{ post: httpPost } as unknown as HttpClient,
|
||||
credentialStore
|
||||
);
|
||||
});
|
||||
|
||||
it('registers on a foreign server when the preferred username is available', async () => {
|
||||
httpPost.mockReturnValue(of({
|
||||
id: 'foreign-user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'foreign-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}));
|
||||
|
||||
const result = await service.provisionOnServer({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
homeUser,
|
||||
provisionSecret: 'provision-secret'
|
||||
});
|
||||
|
||||
expect(result.username).toBe('alice');
|
||||
expect(result.usedSuffix).toBe(false);
|
||||
expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1');
|
||||
expect(httpPost).toHaveBeenCalledWith(
|
||||
'https://foreign.example.com/api/users/register',
|
||||
{
|
||||
username: 'alice',
|
||||
password: 'provision-secret',
|
||||
displayName: 'Alice'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('logs in when the preferred username was provisioned earlier', async () => {
|
||||
httpPost
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||
.mockReturnValueOnce(of({
|
||||
id: 'foreign-user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'foreign-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}));
|
||||
|
||||
const result = await service.provisionOnServer({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
homeUser,
|
||||
provisionSecret: 'provision-secret'
|
||||
});
|
||||
|
||||
expect(result.username).toBe('alice');
|
||||
expect(httpPost).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://foreign.example.com/api/users/login',
|
||||
{
|
||||
username: 'alice',
|
||||
password: 'provision-secret'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('registers with a suffixed username when the preferred name belongs to someone else', async () => {
|
||||
httpPost
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
||||
.mockReturnValueOnce(of({
|
||||
id: 'foreign-user-2',
|
||||
username: 'alice-a3f2b1',
|
||||
displayName: 'Alice',
|
||||
token: 'foreign-token-2',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}));
|
||||
|
||||
const result = await service.provisionOnServer({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
homeUser,
|
||||
provisionSecret: 'provision-secret'
|
||||
});
|
||||
|
||||
expect(result.username).toBe('alice-a3f2b1');
|
||||
expect(result.usedSuffix).toBe(true);
|
||||
expect(httpPost).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://foreign.example.com/api/users/register',
|
||||
{
|
||||
username: 'alice-a3f2b1',
|
||||
password: 'provision-secret',
|
||||
displayName: 'Alice'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when all username candidates are exhausted', async () => {
|
||||
httpPost
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||
|
||||
await expect(service.provisionOnServer({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
homeUser,
|
||||
provisionSecret: 'provision-secret'
|
||||
})).rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
|
||||
});
|
||||
|
||||
it('returns an existing credential without making network calls', async () => {
|
||||
credentialStore.upsertCredential({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
userId: 'foreign-user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
token: 'foreign-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
provisioned: true
|
||||
});
|
||||
|
||||
const result = await service.provisionOnServer({
|
||||
serverUrl: 'https://foreign.example.com',
|
||||
homeUser,
|
||||
provisionSecret: 'provision-secret'
|
||||
});
|
||||
|
||||
expect(result.username).toBe('alice');
|
||||
expect(httpPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||
import { ProvisionUsernameCollisionError, buildProvisionUsernameCandidates } from '../../domain/logic/signal-server-provision.rules';
|
||||
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||
|
||||
export interface ProvisionResult {
|
||||
credential: SignalServerCredential;
|
||||
username: string;
|
||||
usedSuffix: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SignalServerProvisionerService {
|
||||
private readonly http: HttpClient;
|
||||
private readonly credentialStore: SignalServerCredentialStoreService;
|
||||
|
||||
constructor(
|
||||
http: HttpClient = inject(HttpClient),
|
||||
credentialStore: SignalServerCredentialStoreService = inject(SignalServerCredentialStoreService)
|
||||
) {
|
||||
this.http = http;
|
||||
this.credentialStore = credentialStore;
|
||||
}
|
||||
|
||||
async provisionOnServer(params: {
|
||||
serverUrl: string;
|
||||
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
|
||||
provisionSecret: string;
|
||||
}): Promise<ProvisionResult> {
|
||||
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
|
||||
const existing = this.credentialStore.getCredential(normalizedUrl);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
credential: existing,
|
||||
username: existing.username,
|
||||
usedSuffix: existing.username !== params.homeUser.username.trim()
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id);
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
const usedSuffix = index > 0;
|
||||
|
||||
try {
|
||||
const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName);
|
||||
|
||||
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
||||
} catch (error) {
|
||||
if (!this.isHttpStatus(error, 409)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.login(normalizedUrl, candidate, params.provisionSecret);
|
||||
|
||||
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
||||
} catch (loginError) {
|
||||
if (!this.isHttpStatus(loginError, 401)) {
|
||||
throw loginError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProvisionUsernameCollisionError(normalizedUrl, candidates);
|
||||
}
|
||||
|
||||
upsertManualCredential(
|
||||
serverUrl: string,
|
||||
response: LoginResponse,
|
||||
provisioned = false
|
||||
): SignalServerCredential {
|
||||
const credential: SignalServerCredential = {
|
||||
serverUrl: this.normalizeServerUrl(serverUrl),
|
||||
userId: response.id,
|
||||
username: response.username,
|
||||
displayName: response.displayName,
|
||||
token: response.token,
|
||||
expiresAt: response.expiresAt,
|
||||
provisioned
|
||||
};
|
||||
|
||||
this.credentialStore.upsertCredential(credential);
|
||||
return credential;
|
||||
}
|
||||
|
||||
private async register(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string
|
||||
): Promise<LoginResponse> {
|
||||
return firstValueFrom(
|
||||
this.http.post<LoginResponse>(`${serverUrl}/api/users/register`, {
|
||||
username,
|
||||
password,
|
||||
displayName
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async login(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<LoginResponse> {
|
||||
return firstValueFrom(
|
||||
this.http.post<LoginResponse>(`${serverUrl}/api/users/login`, {
|
||||
username,
|
||||
password
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private persistProvisionResult(
|
||||
serverUrl: string,
|
||||
response: LoginResponse,
|
||||
usedSuffix: boolean
|
||||
): ProvisionResult {
|
||||
const credential = this.upsertManualCredential(serverUrl, response, true);
|
||||
|
||||
return {
|
||||
credential,
|
||||
username: response.username,
|
||||
usedSuffix
|
||||
};
|
||||
}
|
||||
|
||||
private isHttpStatus(error: unknown, status: number): boolean {
|
||||
return error instanceof HttpErrorResponse && error.status === status;
|
||||
}
|
||||
|
||||
private normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
export const DEFAULT_POST_AUTH_URL = '/dashboard';
|
||||
export const AUTH_MODE_AUTHORIZE = 'authorize';
|
||||
|
||||
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
|
||||
const MAX_RETURN_URL_DEPTH = 10;
|
||||
@@ -79,15 +80,27 @@ export function resolveSafeReturnUrl(
|
||||
|
||||
export function buildLoginReturnQueryParams(
|
||||
currentUrl: string,
|
||||
fallback = DEFAULT_POST_AUTH_URL
|
||||
): { returnUrl?: string } {
|
||||
fallback = DEFAULT_POST_AUTH_URL,
|
||||
extra: Record<string, string | undefined> = {}
|
||||
): Record<string, string> {
|
||||
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
if (safeReturnUrl === fallback) {
|
||||
return {};
|
||||
if (safeReturnUrl !== fallback) {
|
||||
queryParams['returnUrl'] = safeReturnUrl;
|
||||
}
|
||||
|
||||
return { returnUrl: safeReturnUrl };
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
if (value?.trim()) {
|
||||
queryParams[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
export function isAuthorizeAuthMode(mode: string | null | undefined): boolean {
|
||||
return mode?.trim() === AUTH_MODE_AUTHORIZE;
|
||||
}
|
||||
|
||||
export function waitForAuthenticationOutcome(
|
||||
|
||||
@@ -16,13 +16,9 @@ describe('auth-session.rules', () => {
|
||||
} as Pick<User, 'homeSignalServerUrl'>;
|
||||
|
||||
it('collects home and active server urls without duplicates', () => {
|
||||
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual([
|
||||
'https://signal.example.com'
|
||||
]);
|
||||
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual([
|
||||
'http://localhost:3001',
|
||||
'https://signal.example.com'
|
||||
]);
|
||||
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual(['https://signal.example.com']);
|
||||
|
||||
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual(['http://localhost:3001', 'https://signal.example.com']);
|
||||
});
|
||||
|
||||
it('requires a valid token for a known server url', () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { isSelfPresenceUserId, resolveSelfPresenceUserIds } from './self-presence-identity.rules';
|
||||
|
||||
describe('resolveSelfPresenceUserIds', () => {
|
||||
it('includes home user id and oderId', () => {
|
||||
const ids = resolveSelfPresenceUserIds({
|
||||
homeUserId: 'home-id',
|
||||
homeOderId: 'peer-a'
|
||||
});
|
||||
|
||||
expect([...ids]).toEqual(['home-id', 'peer-a']);
|
||||
});
|
||||
|
||||
it('includes the per-server actor user id when provisioned on a foreign server', () => {
|
||||
const ids = resolveSelfPresenceUserIds({
|
||||
homeUserId: 'home-id',
|
||||
homeOderId: 'peer-a',
|
||||
actorUserId: 'foreign-id'
|
||||
});
|
||||
|
||||
expect([...ids]).toEqual([
|
||||
'home-id',
|
||||
'peer-a',
|
||||
'foreign-id'
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates when actor id matches home id', () => {
|
||||
const ids = resolveSelfPresenceUserIds({
|
||||
homeUserId: 'same-id',
|
||||
homeOderId: 'same-id',
|
||||
actorUserId: 'same-id'
|
||||
});
|
||||
|
||||
expect([...ids]).toEqual(['same-id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelfPresenceUserId', () => {
|
||||
it('returns true when the user id is part of the self set', () => {
|
||||
const selfIds = resolveSelfPresenceUserIds({
|
||||
homeUserId: 'home-id',
|
||||
actorUserId: 'foreign-id'
|
||||
});
|
||||
|
||||
expect(isSelfPresenceUserId('foreign-id', selfIds)).toBe(true);
|
||||
expect(isSelfPresenceUserId('other-id', selfIds)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface SelfPresenceIdentityInput {
|
||||
homeUserId?: string;
|
||||
homeOderId?: string;
|
||||
actorUserId?: string;
|
||||
}
|
||||
|
||||
/** Collect every user id that represents the local user on a room's signal server. */
|
||||
export function resolveSelfPresenceUserIds(input: SelfPresenceIdentityInput): ReadonlySet<string> {
|
||||
const ids = new Set<string>();
|
||||
|
||||
if (input.homeUserId?.trim()) {
|
||||
ids.add(input.homeUserId.trim());
|
||||
}
|
||||
|
||||
if (input.homeOderId?.trim()) {
|
||||
ids.add(input.homeOderId.trim());
|
||||
}
|
||||
|
||||
if (input.actorUserId?.trim()) {
|
||||
ids.add(input.actorUserId.trim());
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function isSelfPresenceUserId(
|
||||
userId: string | undefined,
|
||||
selfIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
return !!userId?.trim() && selfIds.has(userId.trim());
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { resolveSignalIdentity } from './signal-server-credential-resolution.rules';
|
||||
|
||||
describe('resolveSignalIdentity', () => {
|
||||
const homeUser = {
|
||||
id: 'home-user-1',
|
||||
displayName: 'Alice',
|
||||
homeSignalServerUrl: 'https://signal.example.com'
|
||||
};
|
||||
|
||||
it('prefers the per-signal credential when present', () => {
|
||||
const resolved = resolveSignalIdentity(
|
||||
{ userId: 'provisioned-1', token: 'cred-token', displayName: 'Alice On Foreign' },
|
||||
{ token: 'legacy-token' },
|
||||
homeUser
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
userId: 'provisioned-1',
|
||||
token: 'cred-token',
|
||||
displayName: 'Alice On Foreign',
|
||||
homeSignalServerUrl: 'https://signal.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the legacy session token using the home identity when no credential exists', () => {
|
||||
const resolved = resolveSignalIdentity(
|
||||
null,
|
||||
{ token: 'legacy-token' },
|
||||
homeUser
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
userId: 'home-user-1',
|
||||
token: 'legacy-token',
|
||||
displayName: 'Alice',
|
||||
homeSignalServerUrl: 'https://signal.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when neither a credential nor a legacy token is available', () => {
|
||||
expect(resolveSignalIdentity(null, null, homeUser)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not fall back to the legacy token without a known home user id', () => {
|
||||
expect(resolveSignalIdentity(null, { token: 'legacy-token' }, null)).toBeNull();
|
||||
expect(
|
||||
resolveSignalIdentity(null, { token: 'legacy-token' }, { displayName: 'Alice' })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ResolvableHomeUser {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
homeSignalServerUrl?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedSignalIdentity {
|
||||
userId: string;
|
||||
token: string;
|
||||
displayName: string;
|
||||
homeSignalServerUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the identity (oder id + session token) used to `identify` on a signal
|
||||
* server.
|
||||
*
|
||||
* Order of precedence:
|
||||
* 1. The per-signal-server credential (the authoritative source for both home
|
||||
* and provisioned foreign servers).
|
||||
* 2. The legacy single-session token store, reconstructed with the home user's
|
||||
* identity. This keeps `identify` working for sessions restored from disk
|
||||
* that pre-date the per-signal credential store (otherwise the client never
|
||||
* authenticates and the user appears alone in every room).
|
||||
*
|
||||
* Foreign servers are never reconstructed from the legacy token: their account
|
||||
* id is the provisioned id, which only the per-signal credential carries.
|
||||
*/
|
||||
export function resolveSignalIdentity(
|
||||
credential: { userId: string; token: string; displayName: string } | null,
|
||||
legacyToken: { token: string } | null,
|
||||
homeUser: ResolvableHomeUser | null | undefined
|
||||
): ResolvedSignalIdentity | null {
|
||||
if (credential) {
|
||||
return {
|
||||
userId: credential.userId,
|
||||
token: credential.token,
|
||||
displayName: credential.displayName,
|
||||
homeSignalServerUrl: homeUser?.homeSignalServerUrl
|
||||
};
|
||||
}
|
||||
|
||||
if (legacyToken && homeUser?.id) {
|
||||
return {
|
||||
userId: homeUser.id,
|
||||
token: legacyToken.token,
|
||||
displayName: homeUser.displayName ?? '',
|
||||
homeSignalServerUrl: homeUser.homeSignalServerUrl
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import {
|
||||
ProvisionUsernameCollisionError,
|
||||
buildProvisionUsernameCandidates,
|
||||
shortHomeUserId
|
||||
} from './signal-server-provision.rules';
|
||||
|
||||
describe('signal-server-provision.rules', () => {
|
||||
it('derives a stable short id from a home user uuid', () => {
|
||||
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
|
||||
});
|
||||
|
||||
it('orders username candidates with preferred first then suffixed fallback', () => {
|
||||
expect(
|
||||
buildProvisionUsernameCandidates('alice', 'a3f2b1c4-5678-90ab-cdef-1234567890ab')
|
||||
).toEqual(['alice', 'alice-a3f2b1']);
|
||||
});
|
||||
|
||||
it('deduplicates candidates when suffix would repeat preferred username', () => {
|
||||
expect(
|
||||
buildProvisionUsernameCandidates('alice-a3f2b1', 'a3f2b1c4-5678-90ab-cdef-1234567890ab')
|
||||
).toEqual(['alice-a3f2b1']);
|
||||
});
|
||||
|
||||
it('exposes attempted usernames on collision errors', () => {
|
||||
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
|
||||
|
||||
expect(error.name).toBe('ProvisionUsernameCollisionError');
|
||||
expect(error.serverUrl).toBe('https://signal.example.com');
|
||||
expect(error.attemptedUsernames).toEqual(['alice', 'alice-a3f2b1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export class ProvisionUsernameCollisionError extends Error {
|
||||
constructor(
|
||||
readonly serverUrl: string,
|
||||
readonly attemptedUsernames: readonly string[]
|
||||
) {
|
||||
super(`Could not provision account on ${serverUrl}`);
|
||||
this.name = 'ProvisionUsernameCollisionError';
|
||||
}
|
||||
}
|
||||
|
||||
export function shortHomeUserId(homeUserId: string): string {
|
||||
return homeUserId.replace(/-/g, '').slice(0, 6)
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function buildProvisionUsernameCandidates(
|
||||
preferredUsername: string,
|
||||
homeUserId: string
|
||||
): string[] {
|
||||
const trimmed = preferredUsername.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = [trimmed];
|
||||
const suffix = shortHomeUserId(homeUserId);
|
||||
|
||||
if (suffix && !trimmed.endsWith(`-${suffix}`)) {
|
||||
candidates.push(`${trimmed}-${suffix}`);
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface SignalServerCredential {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
provisioned: boolean;
|
||||
}
|
||||
@@ -5,9 +5,19 @@
|
||||
name="lucideLogIn"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
|
||||
<h1 class="text-lg font-semibold text-foreground">
|
||||
@if (isAuthorizeMode()) {
|
||||
{{ 'auth.authorize.title' | translate: { serverName: authorizeServerName() } }}
|
||||
} @else {
|
||||
{{ 'auth.login.title' | translate }}
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (isAuthorizeMode()) {
|
||||
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
@@ -21,7 +22,9 @@ import {
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import {
|
||||
AUTH_MODE_AUTHORIZE,
|
||||
buildLoginReturnQueryParams,
|
||||
isAuthorizeAuthMode,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
@@ -56,6 +59,13 @@ export class LoginComponent implements OnInit {
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
readonly isAuthorizeMode = signal(false);
|
||||
readonly authorizeServerName = computed(() => {
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
const endpoint = this.servers().find((server) => server.id === sid);
|
||||
|
||||
return endpoint?.name ?? this.appI18n.instant('auth.authorize.defaultServerName');
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private auth = inject(AuthenticationService);
|
||||
@@ -68,6 +78,19 @@ export class LoginComponent implements OnInit {
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
ngOnInit(): void {
|
||||
const mode = this.route.snapshot.queryParamMap.get('mode');
|
||||
const requestedServerId = this.route.snapshot.queryParamMap.get('serverId')?.trim();
|
||||
|
||||
this.isAuthorizeMode.set(isAuthorizeAuthMode(mode));
|
||||
|
||||
if (requestedServerId) {
|
||||
this.serverId = requestedServerId;
|
||||
}
|
||||
|
||||
if (this.isAuthorizeMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.select(selectCurrentUser).pipe(
|
||||
filter(Boolean),
|
||||
take(1)
|
||||
@@ -88,8 +111,24 @@ export class LoginComponent implements OnInit {
|
||||
password: this.password,
|
||||
serverId: sid }).subscribe({
|
||||
next: async (resp) => {
|
||||
if (sid)
|
||||
const serverUrl = this.auth.resolveServerUrlFor(sid);
|
||||
|
||||
if (this.isAuthorizeMode()) {
|
||||
this.store.dispatch(UsersActions.authorizeSignalServer({
|
||||
serverUrl,
|
||||
response: resp,
|
||||
provisioned: false
|
||||
}));
|
||||
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sid) {
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
}
|
||||
|
||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||
?? this.serversSvc.activeServer()?.url;
|
||||
@@ -104,7 +143,7 @@ export class LoginComponent implements OnInit {
|
||||
homeSignalServerUrl
|
||||
};
|
||||
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user, loginResponse: resp }));
|
||||
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||
|
||||
@@ -126,7 +165,10 @@ export class LoginComponent implements OnInit {
|
||||
/** Navigate to the registration page. */
|
||||
goRegister() {
|
||||
this.router.navigate(['/register'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
|
||||
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
|
||||
serverId: this.serverId
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
name="lucideUserPlus"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
|
||||
<h1 class="text-lg font-semibold text-foreground">
|
||||
@if (isAuthorizeMode()) {
|
||||
{{ 'auth.authorize.registerTitle' | translate: { serverName: authorizeServerName() } }}
|
||||
} @else {
|
||||
{{ 'auth.register.title' | translate }}
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (isAuthorizeMode()) {
|
||||
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -16,7 +18,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import {
|
||||
AUTH_MODE_AUTHORIZE,
|
||||
buildLoginReturnQueryParams,
|
||||
isAuthorizeAuthMode,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
@@ -42,7 +46,7 @@ import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/d
|
||||
/**
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
export class RegisterComponent implements OnInit {
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
@@ -51,6 +55,13 @@ export class RegisterComponent {
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
readonly isAuthorizeMode = signal(false);
|
||||
readonly authorizeServerName = computed(() => {
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
const endpoint = this.servers().find((server) => server.id === sid);
|
||||
|
||||
return endpoint?.name ?? this.appI18n.instant('auth.authorize.defaultServerName');
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private auth = inject(AuthenticationService);
|
||||
@@ -62,6 +73,17 @@ export class RegisterComponent {
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
ngOnInit(): void {
|
||||
const mode = this.route.snapshot.queryParamMap.get('mode');
|
||||
const requestedServerId = this.route.snapshot.queryParamMap.get('serverId')?.trim();
|
||||
|
||||
this.isAuthorizeMode.set(isAuthorizeAuthMode(mode));
|
||||
|
||||
if (requestedServerId) {
|
||||
this.serverId = requestedServerId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and submit the registration form, then navigate to search on success. */
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
@@ -72,8 +94,24 @@ export class RegisterComponent {
|
||||
displayName: this.displayName.trim(),
|
||||
serverId: sid }).subscribe({
|
||||
next: async (resp) => {
|
||||
if (sid)
|
||||
const serverUrl = this.auth.resolveServerUrlFor(sid);
|
||||
|
||||
if (this.isAuthorizeMode()) {
|
||||
this.store.dispatch(UsersActions.authorizeSignalServer({
|
||||
serverUrl,
|
||||
response: resp,
|
||||
provisioned: false
|
||||
}));
|
||||
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sid) {
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
}
|
||||
|
||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||
?? this.serversSvc.activeServer()?.url;
|
||||
@@ -88,7 +126,7 @@ export class RegisterComponent {
|
||||
homeSignalServerUrl
|
||||
};
|
||||
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user, loginResponse: resp }));
|
||||
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||
|
||||
@@ -110,7 +148,10 @@ export class RegisterComponent {
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
|
||||
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
|
||||
serverId: this.serverId
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export * from './application/services/authentication.service';
|
||||
export * from './application/services/auth-token-store.service';
|
||||
export * from './application/services/signal-server-auth.service';
|
||||
export * from './application/services/signal-server-authorize.service';
|
||||
export * from './application/services/signal-server-credential-store.service';
|
||||
export * from './application/services/signal-server-provisioner.service';
|
||||
export * from './application/services/signal-server-provision-notice.service';
|
||||
export * from './application/services/provision-secret-store.service';
|
||||
export * from './domain/models/authentication.model';
|
||||
export * from './domain/models/signal-server-credential.model';
|
||||
export * from './domain/logic/signal-server-provision.rules';
|
||||
export * from './domain/logic/auth-navigation.rules';
|
||||
|
||||
@@ -21,14 +21,14 @@ export interface InventoryIntegritySnapshot {
|
||||
headHash: string;
|
||||
}
|
||||
|
||||
export type RemoteInventoryItem = {
|
||||
export interface RemoteInventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc?: number;
|
||||
ac?: number;
|
||||
revision?: number;
|
||||
headHash?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageRevisionAction = MessageRevisionType;
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
vi
|
||||
} from 'vitest';
|
||||
import type { MessageRevision } from '../../../../shared-kernel';
|
||||
import {
|
||||
attachRevisionSignatureIfPossible,
|
||||
shouldAcceptRevisionWithoutRegisteredKey
|
||||
} from './message-revision-signing.rules';
|
||||
import { attachRevisionSignatureIfPossible, shouldAcceptRevisionWithoutRegisteredKey } from './message-revision-signing.rules';
|
||||
|
||||
describe('message-revision-signing.rules', () => {
|
||||
const revision: MessageRevision = {
|
||||
@@ -43,6 +40,7 @@ describe('message-revision-signing.rules', () => {
|
||||
...revision,
|
||||
signature: 'signature'
|
||||
}, null)).toBe(true);
|
||||
|
||||
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,7 @@ import { findMissingIds } from './message-sync.rules';
|
||||
|
||||
describe('message-sync.rules', () => {
|
||||
it('requests ids with newer revision or mismatched head hash', () => {
|
||||
const localMap = new Map([
|
||||
['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }],
|
||||
['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]
|
||||
]);
|
||||
|
||||
const localMap = new Map([['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }], ['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]]);
|
||||
const missing = findMissingIds([
|
||||
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
|
||||
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('CustomEmojiPickerComponent', () => {
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
|
||||
import { CustomEmojiService } from '../../application/custom-emoji.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import {
|
||||
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
|
||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||
|
||||
@@ -9,7 +9,11 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { MobileCallSessionService, MobileMediaService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileMediaService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
@@ -575,6 +579,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
...provideAppI18nForTests()
|
||||
]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import {
|
||||
VoiceSessionFacade,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../../voice-session';
|
||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
import type { DirectMessageConversation } from '../../../direct-message';
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
|
||||
@@ -15,7 +15,11 @@ import type { User } from '../../../../shared-kernel';
|
||||
@Component({
|
||||
selector: 'app-friend-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
|
||||
templateUrl: './friend-button.component.html'
|
||||
})
|
||||
|
||||
@@ -23,7 +23,11 @@ import { ExperimentalVlcPlayerHandle, ExperimentalVlcRuntimeService } from '../.
|
||||
@Component({
|
||||
selector: 'app-experimental-vlc-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
|
||||
@@ -232,6 +232,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
const service = runInInjectionContext(injector, () => new GameActivityService());
|
||||
const context = {
|
||||
|
||||
@@ -21,7 +21,11 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
|
||||
@Component({
|
||||
selector: 'app-notifications-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideBell,
|
||||
|
||||
@@ -125,6 +125,7 @@ describe('PluginClientApiService', () => {
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'chat-message',
|
||||
message: expect.objectContaining({
|
||||
@@ -132,6 +133,7 @@ describe('PluginClientApiService', () => {
|
||||
roomId: 'room-1'
|
||||
})
|
||||
}));
|
||||
|
||||
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
LocalPluginRegistrationResult,
|
||||
RegisteredPlugin
|
||||
} from '../../domain/models/plugin-runtime.models';
|
||||
import { isSecurePluginRemoteUrl } from '../../domain/rules/plugin-source-url.rules';
|
||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
@@ -24,10 +25,7 @@ import { PluginClientApiService } from './plugin-client-api.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
import {
|
||||
fileUrlToPath,
|
||||
grantPluginReadRoots
|
||||
} from '../../domain/rules/plugin-local-file.rules';
|
||||
import { fileUrlToPath, grantPluginReadRoots } from '../../domain/rules/plugin-local-file.rules';
|
||||
|
||||
interface ActivePluginRuntime {
|
||||
context: TojuPluginActivationContext;
|
||||
@@ -379,7 +377,7 @@ export class PluginHostService {
|
||||
return { module, moduleObjectUrl };
|
||||
}
|
||||
|
||||
if (!entrypointUrl.startsWith('file://') && !entrypointUrl.startsWith('https://')) {
|
||||
if (!entrypointUrl.startsWith('file://') && !isSecurePluginRemoteUrl(entrypointUrl)) {
|
||||
throw new Error('Remote plugin entrypoints must use HTTPS');
|
||||
}
|
||||
|
||||
@@ -388,7 +386,7 @@ export class PluginHostService {
|
||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
||||
};
|
||||
} catch (error) {
|
||||
if (!entrypointUrl.startsWith('https://')) {
|
||||
if (!isSecurePluginRemoteUrl(entrypointUrl)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -420,7 +418,7 @@ export class PluginHostService {
|
||||
}
|
||||
|
||||
private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> {
|
||||
if (!entrypointUrl.startsWith('https://')) {
|
||||
if (!isSecurePluginRemoteUrl(entrypointUrl)) {
|
||||
throw new Error('Remote plugin entrypoints must use HTTPS');
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,22 @@ describe('PluginStoreService', () => {
|
||||
url: plugin.readmeUrl
|
||||
});
|
||||
});
|
||||
|
||||
it('allows localhost HTTP plugin source URLs for local dev and E2E', async () => {
|
||||
const localSourceUrl = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
||||
|
||||
mockFetchResponses(fetchMock, {
|
||||
[localSourceUrl]: jsonResponse({
|
||||
title: 'Local E2E Source',
|
||||
plugins: []
|
||||
})
|
||||
});
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
await expect(service.addSourceUrl(localSourceUrl)).resolves.toBeUndefined();
|
||||
expect(service.sourceUrls()).toContain(localSourceUrl);
|
||||
});
|
||||
});
|
||||
|
||||
function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void {
|
||||
@@ -312,8 +328,8 @@ function createService(
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const service = injector.get(PluginStoreService);
|
||||
|
||||
injector.get(AppI18nService).initialize();
|
||||
|
||||
return service;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { isLocalDevPluginSourceUrl } from '../../domain/rules/plugin-source-url.rules';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { AppI18nService } from '../../../../core/i18n';
|
||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||
@@ -45,10 +46,7 @@ import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
import { PluginRequirementService } from './plugin-requirement.service';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import {
|
||||
fileUrlToPath,
|
||||
grantPluginReadRoots
|
||||
} from '../../domain/rules/plugin-local-file.rules';
|
||||
import { fileUrlToPath, grantPluginReadRoots } from '../../domain/rules/plugin-local-file.rules';
|
||||
|
||||
const STORE_SCHEMA_VERSION = 2;
|
||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
||||
@@ -172,8 +170,8 @@ export class PluginStoreService {
|
||||
}
|
||||
|
||||
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
|
||||
await this.ensurePluginSourceReadRoot(sourceUrl);
|
||||
this.saveState();
|
||||
await this.ensurePluginSourceReadRoot(sourceUrl);
|
||||
await this.refreshSources();
|
||||
}
|
||||
|
||||
@@ -514,7 +512,7 @@ export class PluginStoreService {
|
||||
return await this.readLocalFileUrl(url);
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://')) {
|
||||
if (!url.startsWith('https://') && !isLocalDevPluginSourceUrl(url)) {
|
||||
throw new Error('Remote plugin store requests must use HTTPS');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
collectPluginReadRoots,
|
||||
fileUrlToPath,
|
||||
@@ -15,18 +19,12 @@ describe('plugin-local-file.rules', () => {
|
||||
expect(collectPluginReadRoots(
|
||||
'file:///home/ludde/Desktop/TestPlugin/plugin-source.json',
|
||||
'file:///home/ludde/Desktop/TestPlugin/dist/main.js'
|
||||
)).toEqual([
|
||||
'/home/ludde/Desktop/TestPlugin',
|
||||
'/home/ludde/Desktop/TestPlugin/dist'
|
||||
]);
|
||||
)).toEqual(['/home/ludde/Desktop/TestPlugin', '/home/ludde/Desktop/TestPlugin/dist']);
|
||||
});
|
||||
|
||||
it('treats directory file URLs as their own read roots', () => {
|
||||
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual([
|
||||
'/home/ludde/Desktop/TestPlugin'
|
||||
]);
|
||||
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual([
|
||||
'/home/ludde/Desktop/TestPlugin'
|
||||
]);
|
||||
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual(['/home/ludde/Desktop/TestPlugin']);
|
||||
|
||||
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual(['/home/ludde/Desktop/TestPlugin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ export function pluginFileParentDir(filePath: string): string {
|
||||
}
|
||||
|
||||
export function pluginReadRootForFileUrl(fileUrl: string): string {
|
||||
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '');
|
||||
const basename = filePath.split('/').pop() ?? '';
|
||||
|
||||
if (fileUrl.endsWith('/') || !basename.includes('.')) {
|
||||
@@ -18,7 +19,7 @@ export function pluginReadRootForFileUrl(fileUrl: string): string {
|
||||
return pluginFileParentDir(filePath);
|
||||
}
|
||||
|
||||
export function collectPluginReadRoots(...fileUrls: Array<string | undefined>): string[] {
|
||||
export function collectPluginReadRoots(...fileUrls: (string | undefined)[]): string[] {
|
||||
const roots = new Set<string>();
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
@@ -34,7 +35,7 @@ export function collectPluginReadRoots(...fileUrls: Array<string | undefined>):
|
||||
|
||||
export async function grantPluginReadRoots(
|
||||
api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined,
|
||||
...fileUrls: Array<string | undefined>
|
||||
...fileUrls: (string | undefined)[]
|
||||
): Promise<void> {
|
||||
if (!api?.grantPluginReadRoot) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isLocalDevPluginSourceUrl, isSecurePluginRemoteUrl } from './plugin-source-url.rules';
|
||||
|
||||
describe('plugin source URL rules', () => {
|
||||
it('treats localhost HTTP URLs as local dev plugin sources', () => {
|
||||
expect(isLocalDevPluginSourceUrl('http://localhost:4200/plugins/e2e-plugin-source.json')).toBe(true);
|
||||
expect(isLocalDevPluginSourceUrl('http://127.0.0.1:4200/plugins/e2e-plugin-source.json')).toBe(true);
|
||||
expect(isLocalDevPluginSourceUrl('http://example.com/plugins.json')).toBe(false);
|
||||
expect(isLocalDevPluginSourceUrl('https://localhost/plugins.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts HTTPS and localhost HTTP as secure remote plugin URLs', () => {
|
||||
expect(isSecurePluginRemoteUrl('https://plugins.example.test/index.json')).toBe(true);
|
||||
expect(isSecurePluginRemoteUrl('http://localhost:4200/plugins/e2e-plugin-source.json')).toBe(true);
|
||||
expect(isSecurePluginRemoteUrl('http://example.com/plugins.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export function isLocalDevPluginSourceUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
return parsed.protocol === 'http:'
|
||||
&& (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSecurePluginRemoteUrl(url: string): boolean {
|
||||
return url.startsWith('https://') || isLocalDevPluginSourceUrl(url);
|
||||
}
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
@Component({
|
||||
selector: 'app-profile-avatar-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ModalBackdropComponent, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ModalBackdropComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
templateUrl: './profile-avatar-editor.component.html'
|
||||
})
|
||||
export class ProfileAvatarEditorComponent {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
|
||||
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
|
||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||
import { SignalServerAuthService } from '../../../authentication/application/services/signal-server-auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryService {
|
||||
@@ -41,6 +42,7 @@ export class ServerDirectoryService {
|
||||
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
|
||||
private readonly endpointHealth = inject(ServerEndpointHealthService);
|
||||
private readonly api = inject(ServerDirectoryApiService);
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
private readonly initialServerHealthCheck: Promise<void>;
|
||||
private shouldSearchAllServers = true;
|
||||
|
||||
@@ -217,6 +219,10 @@ export class ServerDirectoryService {
|
||||
healthResult.serverTag
|
||||
);
|
||||
|
||||
if (healthResult.status === 'online' && endpoint.isActive) {
|
||||
void this.signalServerAuth.ensureProvisioned(endpoint.url).catch(() => undefined);
|
||||
}
|
||||
|
||||
return healthResult.status === 'online';
|
||||
}
|
||||
|
||||
@@ -286,7 +292,13 @@ export class ServerDirectoryService {
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.api.requestJoin(request, selector);
|
||||
const actorUserId = this.resolveActorUserId(request.userId, selector);
|
||||
|
||||
return this.api.requestJoin({
|
||||
...request,
|
||||
userId: actorUserId,
|
||||
userPublicKey: actorUserId
|
||||
}, selector);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
@@ -326,7 +338,7 @@ export class ServerDirectoryService {
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.api.notifyLeave(serverId, userId, selector);
|
||||
return this.api.notifyLeave(serverId, this.resolveActorUserId(userId, selector), selector);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
@@ -353,4 +365,27 @@ export class ServerDirectoryService {
|
||||
this.shouldSearchAllServers = true;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActorUserId(userId: string, selector?: ServerSourceSelector): string {
|
||||
return this.signalServerAuth.resolveActorUserIdForServer(
|
||||
this.resolveSelectorServerUrl(selector),
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSelectorServerUrl(selector?: ServerSourceSelector): string | undefined {
|
||||
const sourceUrl = selector?.sourceUrl?.trim();
|
||||
|
||||
if (sourceUrl) {
|
||||
return this.endpointState.sanitiseUrl(sourceUrl);
|
||||
}
|
||||
|
||||
const sourceId = selector?.sourceId?.trim();
|
||||
|
||||
if (sourceId) {
|
||||
return this.servers().find((endpoint) => endpoint.id === sourceId)?.url;
|
||||
}
|
||||
|
||||
return this.activeServer()?.url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ import { ServerDirectoryFacade } from '../../application/facades/server-director
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
|
||||
|
||||
/**
|
||||
@@ -114,6 +111,7 @@ export class CreateServerDialogComponent {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
...provideAppI18nForTests()
|
||||
]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -26,19 +27,22 @@ import { buildLoginReturnQueryParams } from '../../../authentication/domain/logi
|
||||
templateUrl: './invite.component.html'
|
||||
})
|
||||
export class InviteComponent implements OnInit {
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||
readonly message = signal(this.i18n.instant('servers.invite.messages.loading'));
|
||||
readonly message = signal('');
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.message.set(this.i18n.instant('servers.invite.messages.loading'));
|
||||
|
||||
const inviteContext = this.resolveInviteContext();
|
||||
|
||||
if (!inviteContext) {
|
||||
@@ -127,6 +131,15 @@ export class InviteComponent implements OnInit {
|
||||
this.message.set(this.i18n.instant('servers.invite.messages.joining', { name: invite.server.name }));
|
||||
|
||||
const currentUser = await this.hydrateCurrentUser();
|
||||
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(context.sourceUrl);
|
||||
|
||||
if (!hasCredential) {
|
||||
this.status.set('redirecting');
|
||||
this.message.set(this.i18n.instant('servers.invite.messages.redirectingAuthorize'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: invite.server.id,
|
||||
userId: currentUserId,
|
||||
|
||||
@@ -114,6 +114,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
...provideAppI18nForTests()
|
||||
]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
const component = runInInjectionContext(injector, () => injector.get(ServerBrowserComponent));
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ export class ServerBrowserComponent implements OnInit {
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
private injector = inject(Injector);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
@@ -530,6 +532,14 @@ export class ServerBrowserComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
if (server.sourceUrl) {
|
||||
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
|
||||
|
||||
if (!hasCredential) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.serverDirectory.requestJoin(
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('ThemeService theme application', () => {
|
||||
useValue: createDocumentStub(styleElements)
|
||||
}
|
||||
]);
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
|
||||
service = injector.get(ThemeService);
|
||||
|
||||
@@ -347,7 +347,12 @@ export class ThemeService {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.commitTheme(result.value, stringifyTheme(result.value), this.appI18n.instant('theme.status.presetApplied', { name: result.value.meta.name }));
|
||||
this.commitTheme(
|
||||
result.value,
|
||||
stringifyTheme(result.value),
|
||||
this.appI18n.instant('theme.status.presetApplied', { name: result.value.meta.name })
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ import { ThemeRegistryService } from '../../application/services/theme-registry.
|
||||
@Component({
|
||||
selector: 'app-theme-picker-overlay',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
|
||||
templateUrl: './theme-picker-overlay.component.html'
|
||||
})
|
||||
export class ThemePickerOverlayComponent {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import type { VoiceState } from '../../../../shared-kernel';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
|
||||
@@ -45,10 +45,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
|
||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||
import { ViewportService } from '../../core/platform';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../domains/voice-session';
|
||||
import { isLocalVoiceOwner, isVoiceOnAnotherClient } from '../../domains/voice-session';
|
||||
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
lucidePackage
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { SignalServerAuthService } from '../../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { isSelfPresenceUserId } from '../../../domains/authentication/domain/logic/self-presence-identity.rules';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
@@ -140,6 +142,7 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
||||
})
|
||||
export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private store = inject(Store);
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
private router = inject(Router);
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
@@ -208,7 +211,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.addIdentifiers(onlineIdentifiers, user);
|
||||
}
|
||||
|
||||
this.addIdentifiers(onlineIdentifiers, this.currentUser());
|
||||
this.addSelfPresenceIdentifiers(onlineIdentifiers);
|
||||
|
||||
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||
});
|
||||
@@ -408,10 +411,33 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
return (
|
||||
!!current &&
|
||||
((typeof entity.id === 'string' && entity.id === current.id) || (typeof entity.oderId === 'string' && entity.oderId === current.oderId))
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
);
|
||||
|
||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||
}
|
||||
|
||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addIdentifiers(identifiers, current);
|
||||
|
||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
)) {
|
||||
identifiers.add(selfId);
|
||||
}
|
||||
}
|
||||
|
||||
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
||||
|
||||
@@ -280,6 +280,7 @@ export class ServersRailComponent {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
@Component({
|
||||
selector: 'app-bans-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX
|
||||
|
||||
@@ -23,7 +23,11 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||
@Component({
|
||||
selector: 'app-data-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDatabase,
|
||||
|
||||
@@ -31,7 +31,11 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
|
||||
@Component({
|
||||
selector: 'app-debugging-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideBug,
|
||||
|
||||
@@ -14,10 +14,7 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
type="button"
|
||||
(click)="removeEntry(entry.id)"
|
||||
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
|
||||
[title]="'settings.network.ice.moveDown' | translate"
|
||||
[title]="'settings.network.ice.remove' | translate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
|
||||
@@ -81,6 +81,7 @@ export class IceServerSettingsComponent {
|
||||
? 'settings.network.ice.errors.urlPrefixStun'
|
||||
: 'settings.network.ice.errors.urlPrefixTurn'
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
@Component({
|
||||
selector: 'app-local-api-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
templateUrl: './local-api-settings.component.html'
|
||||
})
|
||||
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -39,6 +39,27 @@
|
||||
{{ 'settings.network.serverEndpoints.descriptionModal' | translate }}
|
||||
</p>
|
||||
|
||||
@if (provisionNotice(); as notice) {
|
||||
<div class="mb-3 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-xs text-foreground">
|
||||
{{
|
||||
'auth.provision.usernameCollision'
|
||||
| translate
|
||||
: {
|
||||
serverName: notice.serverName,
|
||||
preferredUsername: notice.preferredUsername,
|
||||
provisionedUsername: notice.provisionedUsername
|
||||
}
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 text-primary hover:underline"
|
||||
(click)="dismissProvisionNotice()"
|
||||
>
|
||||
{{ 'common.dismiss' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-2 mb-3">
|
||||
@for (server of servers(); track server.id) {
|
||||
@@ -70,11 +91,21 @@
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
<p class="text-[10px] text-muted-foreground">{{ authStatusKey(server.url) | translate }}</p>
|
||||
@if (server.status === 'incompatible') {
|
||||
<p class="text-[10px] text-destructive">{{ 'settings.network.serverEndpoints.incompatible' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
@if (!signalServerAuth.hasValidCredential(server.url)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="authorizeServer(server.url)"
|
||||
class="px-2 py-1 text-[10px] rounded-lg bg-secondary text-foreground hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
{{ 'settings.network.serverEndpoints.auth.signIn' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (!server.isActive && server.status !== 'incompatible') {
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,6 +174,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-signal-server-button"
|
||||
(click)="addServer()"
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
class="grid h-9 w-9 place-items-center self-end rounded-lg bg-primary text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||
import { SignalServerAuthService } from '../../../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { SignalServerProvisionNoticeService } from '../../../../domains/authentication/application/services/signal-server-provision-notice.service';
|
||||
import { SignalServerAuthorizeService } from '../../../../domains/authentication/application/services/signal-server-authorize.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
@@ -55,6 +55,10 @@ import {
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||
readonly provisionNotice = this.provisionNoticeService.notice;
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
@@ -135,6 +139,22 @@ export class NetworkSettingsComponent {
|
||||
}
|
||||
}
|
||||
|
||||
authStatusKey(serverUrl: string): string {
|
||||
if (this.signalServerAuth.hasValidCredential(serverUrl)) {
|
||||
return 'settings.network.serverEndpoints.auth.authorized';
|
||||
}
|
||||
|
||||
return 'settings.network.serverEndpoints.auth.needsSignIn';
|
||||
}
|
||||
|
||||
async authorizeServer(serverUrl: string): Promise<void> {
|
||||
await this.signalServerAuthorize.navigateToAuthorize(serverUrl, '/settings');
|
||||
}
|
||||
|
||||
dismissProvisionNotice(): void {
|
||||
this.provisionNoticeService.clear();
|
||||
}
|
||||
|
||||
saveConnectionSettings(): void {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
|
||||
@@ -39,10 +39,7 @@ import {
|
||||
withUpdatedRole
|
||||
} from '../../../../domains/access-control';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shared/directives';
|
||||
|
||||
function upsertRoleChannelOverride(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
|
||||
@@ -26,10 +26,7 @@ import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
|
||||
@@ -29,10 +29,7 @@ import { VoiceConnectionFacade } from '../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../shared/directives';
|
||||
import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
|
||||
@@ -51,17 +51,11 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
||||
types: [
|
||||
{
|
||||
id: 'INCOMING_CALL_ACTIONS',
|
||||
actions: [
|
||||
{ id: 'answer', title: mobileLabel('mobile.notifications.answer') },
|
||||
{ id: 'hangup', title: mobileLabel('mobile.notifications.decline') }
|
||||
]
|
||||
actions: [{ id: 'answer', title: mobileLabel('mobile.notifications.answer') }, { id: 'hangup', title: mobileLabel('mobile.notifications.decline') }]
|
||||
},
|
||||
{
|
||||
id: 'ACTIVE_CALL_ACTIONS',
|
||||
actions: [
|
||||
{ id: 'mute', title: mobileLabel('mobile.notifications.mute') },
|
||||
{ id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }
|
||||
]
|
||||
actions: [{ id: 'mute', title: mobileLabel('mobile.notifications.mute') }, { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }]
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -167,4 +161,4 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
||||
this.actionHandler = handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +137,7 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
|
||||
'ALTER TABLE messages ADD COLUMN kind TEXT',
|
||||
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
|
||||
];
|
||||
|
||||
const SCHEMA_V3_MESSAGE_COLUMNS = [
|
||||
'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE messages ADD COLUMN headHash TEXT'
|
||||
];
|
||||
const SCHEMA_V3_MESSAGE_COLUMNS = ['ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE messages ADD COLUMN headHash TEXT'];
|
||||
|
||||
/** Returns DDL statements that still need to run for the stored schema version. */
|
||||
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type Room,
|
||||
type User
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji, MessageRevision } from '../../shared-kernel';
|
||||
import type {
|
||||
ChatAttachmentMeta,
|
||||
CustomEmoji,
|
||||
MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import {
|
||||
attachmentToValues,
|
||||
|
||||
@@ -119,6 +119,8 @@ The signaling layer gets peers to exchange SDP offers/answers and ICE candidates
|
||||
|
||||
Each signaling URL gets its own `SignalingManager` (one WebSocket each). `SignalingTransportHandler` picks the right socket based on which server the message is for. `ServerSignalingCoordinator` tracks which peers belong to which servers and which signaling URLs, so we know when it is safe to tear down a peer connection after leaving a server.
|
||||
|
||||
**Identify-before-join invariant.** The server drops any `join_server` / `view_server` that arrives on a connection that has not yet `identify`-ed, so a join that races ahead of identify is silently lost and the user never appears in the presence roster. On every (re)connect, `SignalingManager.reIdentifyAndRejoin` therefore re-`identify`s and only then re-joins. For this to work the manager's credential lookup (`SignalingTransportHandler.getIdentifyCredentialsForSignalUrl`) must resolve a credential as soon as one exists for that signal URL — it falls back to the credential store when the per-URL identify cache has not been populated yet. Do not narrow that lookup to only the in-memory cache; doing so lets a fresh socket emit a join before any identify and reintroduces the dropped-presence bug.
|
||||
|
||||
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
|
||||
|
||||
Server-relayed fallbacks are intentionally narrow. Room chat (`chat_message`), direct-message events (`direct-message`, `direct-message-status`, `direct-message-mutation`), and voice presence (`voice_state`) may flow over signaling so users can still see written chat and voice roster state while P2P data channels are down. Media, attachments, message inventory sync, screen/camera state, and plugin data-channel traffic remain peer-plane responsibilities.
|
||||
|
||||
@@ -40,8 +40,11 @@ import { ServerSignalingCoordinator } from './signaling/server-signaling-coordin
|
||||
import { SignalingManager } from './signaling/signaling.manager';
|
||||
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
|
||||
import { WebRtcStateController } from './state/webrtc-state-controller';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication';
|
||||
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { ClientInstanceService } from '../../core/platform/client-instance.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -51,8 +54,10 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
private readonly iceServerSettings = inject(IceServerSettingsService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly clientInstance = inject(ClientInstanceService);
|
||||
private currentHomeUser: { id: string; homeSignalServerUrl?: string; displayName: string } | null = null;
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
private readonly state = new WebRtcStateController();
|
||||
@@ -110,6 +115,16 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly remoteScreenShareRequestController: RemoteScreenShareRequestController;
|
||||
|
||||
constructor() {
|
||||
this.store.select(selectCurrentUser).subscribe((user) => {
|
||||
this.currentHomeUser = user
|
||||
? {
|
||||
id: user.id,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl,
|
||||
displayName: user.displayName
|
||||
}
|
||||
: null;
|
||||
});
|
||||
|
||||
// Create managers with null callbacks first to break circular initialization
|
||||
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
||||
|
||||
@@ -133,9 +148,9 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
|
||||
this.signalingCoordinator = new ServerSignalingCoordinator({
|
||||
createManager: (_signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager(
|
||||
createManager: (signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager(
|
||||
this.logger,
|
||||
() => this.signalingTransportHandler.getIdentifyCredentials(),
|
||||
() => this.signalingTransportHandler.getIdentifyCredentialsForSignalUrl(signalUrl),
|
||||
getLastJoinedServer,
|
||||
getMemberServerIds
|
||||
),
|
||||
@@ -149,20 +164,21 @@ export class WebRTCService implements OnDestroy {
|
||||
signalingCoordinator: this.signalingCoordinator,
|
||||
logger: this.logger,
|
||||
getLocalPeerId: () => this.state.getLocalPeerId(),
|
||||
resolveSessionToken: (signalUrl) => {
|
||||
if (signalUrl) {
|
||||
return this.authTokenStore.getToken(signalUrl.replace(/^ws/, 'http'));
|
||||
resolveCredential: (signalUrl) => {
|
||||
if (!signalUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const { signalUrl: connectedUrl } of this.signalingCoordinator.getConnectedSignalingManagers()) {
|
||||
const token = this.authTokenStore.getToken(connectedUrl.replace(/^ws/, 'http'));
|
||||
return this.signalServerAuth.resolveCredentialForSignalUrl(signalUrl, this.currentHomeUser);
|
||||
},
|
||||
getHomeCredential: () => {
|
||||
const homeSignalServerUrl = this.currentHomeUser?.homeSignalServerUrl;
|
||||
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
if (!homeSignalServerUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.signalServerAuth.resolveCredentialForSignalUrl(homeSignalServerUrl, this.currentHomeUser);
|
||||
},
|
||||
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
|
||||
});
|
||||
@@ -283,6 +299,11 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
if (message.type === 'auth_required' || message.type === 'auth_error') {
|
||||
this.store.dispatch(UsersActions.signalServerAuthFailed({ signalUrl }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalingMessage$.next(message);
|
||||
this.signalingMessageHandler.handleMessage(message, signalUrl);
|
||||
}
|
||||
|
||||
@@ -9,35 +9,78 @@ import { IdentifyCredentials } from '../realtime.types';
|
||||
import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
|
||||
export interface ResolvedSignalCredential {
|
||||
userId: string;
|
||||
token: string;
|
||||
displayName: string;
|
||||
homeSignalServerUrl?: string;
|
||||
}
|
||||
|
||||
interface SignalingTransportHandlerDependencies<TMessage> {
|
||||
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getLocalPeerId(): string;
|
||||
resolveSessionToken(signalUrl?: string): string | null;
|
||||
resolveCredential(signalUrl?: string): ResolvedSignalCredential | null;
|
||||
getHomeCredential(): ResolvedSignalCredential | null;
|
||||
getClientInstanceId(): string;
|
||||
}
|
||||
|
||||
export class SignalingTransportHandler<TMessage> {
|
||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||
private readonly lastIdentifyCredentialsBySignalUrl = new Map<string, IdentifyCredentials>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: SignalingTransportHandlerDependencies<TMessage>
|
||||
) {}
|
||||
|
||||
getIdentifyCredentials(): IdentifyCredentials | null {
|
||||
return this.lastIdentifyCredentials;
|
||||
const homeCredential = this.dependencies.getHomeCredential();
|
||||
|
||||
if (!homeCredential) {
|
||||
const firstCredential = this.lastIdentifyCredentialsBySignalUrl.values().next().value;
|
||||
|
||||
return firstCredential ?? null;
|
||||
}
|
||||
|
||||
return this.toIdentifyCredentials(homeCredential);
|
||||
}
|
||||
|
||||
getIdentifyCredentialsForSignalUrl(signalUrl: string): IdentifyCredentials | null {
|
||||
const stored = this.lastIdentifyCredentialsBySignalUrl.get(signalUrl);
|
||||
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Fall back to resolving the credential directly from the credential store so a
|
||||
// freshly (re)connected socket can re-identify *before* it sends any join_server /
|
||||
// view_server. Without this, a new socket can come up and emit a join while the
|
||||
// per-URL identify cache is still empty, and the server drops that join as
|
||||
// unauthenticated, leaving the user permanently absent from the presence roster.
|
||||
const resolved = this.dependencies.resolveCredential(signalUrl);
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
oderId: resolved.userId,
|
||||
token: resolved.token,
|
||||
displayName: resolved.displayName,
|
||||
homeSignalServerUrl: resolved.homeSignalServerUrl,
|
||||
clientInstanceId: this.dependencies.getClientInstanceId()
|
||||
};
|
||||
}
|
||||
|
||||
getIdentifyOderId(): string {
|
||||
return this.lastIdentifyCredentials?.oderId || this.dependencies.getLocalPeerId();
|
||||
return this.getIdentifyCredentials()?.oderId || this.dependencies.getLocalPeerId();
|
||||
}
|
||||
|
||||
getIdentifyDisplayName(): string {
|
||||
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
return this.getIdentifyCredentials()?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
getIdentifyDescription(): string | undefined {
|
||||
return this.lastIdentifyCredentials?.description;
|
||||
return this.getIdentifyCredentials()?.description;
|
||||
}
|
||||
|
||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||
@@ -195,35 +238,15 @@ export class SignalingTransportHandler<TMessage> {
|
||||
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
|
||||
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
|
||||
: undefined;
|
||||
const token = this.dependencies.resolveSessionToken(signalUrl);
|
||||
|
||||
if (!token) {
|
||||
this.dependencies.logger.warn('Skipping identify because no session token is available', { signalUrl, oderId });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientInstanceId = this.dependencies.getClientInstanceId();
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
token,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
clientInstanceId
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token,
|
||||
oderId,
|
||||
this.identifyOnSignalUrl(signalUrl, {
|
||||
fallbackOderId: oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: signalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
@@ -237,17 +260,87 @@ export class SignalingTransportHandler<TMessage> {
|
||||
}
|
||||
|
||||
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
|
||||
manager.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token,
|
||||
oderId,
|
||||
const credentials = this.identifyOnSignalUrl(managerSignalUrl, {
|
||||
fallbackOderId: oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: managerSignalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
continue;
|
||||
}
|
||||
|
||||
manager.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token: credentials.token,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: managerSignalUrl,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private identifyOnSignalUrl(
|
||||
signalUrl: string,
|
||||
params: {
|
||||
fallbackOderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
clientInstanceId: string;
|
||||
}
|
||||
): IdentifyCredentials | null {
|
||||
const resolved = this.dependencies.resolveCredential(signalUrl);
|
||||
|
||||
if (!resolved) {
|
||||
this.dependencies.logger.warn('Skipping identify because no session token is available', {
|
||||
signalUrl,
|
||||
oderId: params.fallbackOderId
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const credentials: IdentifyCredentials = {
|
||||
oderId: resolved.userId,
|
||||
token: resolved.token,
|
||||
displayName: resolved.displayName || params.displayName,
|
||||
description: params.description,
|
||||
profileUpdatedAt: params.profileUpdatedAt,
|
||||
homeSignalServerUrl: params.homeSignalServerUrl ?? resolved.homeSignalServerUrl,
|
||||
clientInstanceId: params.clientInstanceId
|
||||
};
|
||||
|
||||
this.lastIdentifyCredentialsBySignalUrl.set(signalUrl, credentials);
|
||||
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token: credentials.token,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: signalUrl,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private toIdentifyCredentials(credential: ResolvedSignalCredential): IdentifyCredentials {
|
||||
return {
|
||||
oderId: credential.userId,
|
||||
token: credential.token,
|
||||
displayName: credential.displayName,
|
||||
homeSignalServerUrl: credential.homeSignalServerUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,20 +370,28 @@ export class SignalingManager {
|
||||
private reIdentifyAndRejoin(): void {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token: credentials.token,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
// Never (re)join or view a server before we have re-identified on this
|
||||
// connection. Sending join_server/view_server while unauthenticated makes
|
||||
// the server reply with auth_required/auth_error, which the auth-failure
|
||||
// handler would otherwise treat as a hard session expiry and tear down or
|
||||
// re-provision the home credential. The higher-level identify flow will
|
||||
// populate credentials and re-run this rejoin once available.
|
||||
if (!credentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token: credentials.token,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
|
||||
if (memberIds.size > 0) {
|
||||
|
||||
@@ -34,7 +34,11 @@ import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.compone
|
||||
@Component({
|
||||
selector: 'app-bottom-sheet',
|
||||
standalone: true,
|
||||
imports: [ThemeNodeDirective, ModalBackdropComponent, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
ThemeNodeDirective,
|
||||
ModalBackdropComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
templateUrl: './bottom-sheet.component.html',
|
||||
styleUrl: './bottom-sheet.component.scss'
|
||||
})
|
||||
|
||||
@@ -18,7 +18,11 @@ import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
imports: [ThemeNodeDirective, BottomSheetComponent, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
templateUrl: './context-menu.component.html',
|
||||
styleUrl: './context-menu.component.scss'
|
||||
})
|
||||
|
||||
@@ -11,11 +11,9 @@ import { ModalBackdropComponent } from './modal-backdrop.component';
|
||||
|
||||
function createComponent(): ModalBackdropComponent {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
...provideAppI18nForTests(),
|
||||
ModalBackdropComponent
|
||||
]
|
||||
providers: [...provideAppI18nForTests(), ModalBackdropComponent]
|
||||
});
|
||||
|
||||
initializeAppI18nForTests(injector);
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(ModalBackdropComponent));
|
||||
|
||||
@@ -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