From eb51f043ac755f49440f73b1a1d45d963fd3ac46 Mon Sep 17 00:00:00 2001 From: Myx Date: Tue, 9 Jun 2026 17:59:54 +0200 Subject: [PATCH] fix: Major bug cleanup pass 1 --- agents-docs/LESSONS.md | 21 ++ agents-docs/features/authentication.md | 17 +- e2e/helpers/app-menu.ts | 20 ++ e2e/helpers/auth-api.ts | 31 +++ e2e/helpers/dashboard.ts | 11 + e2e/helpers/multi-device-session.ts | 24 +- e2e/helpers/plugin-store.ts | 19 ++ e2e/helpers/webrtc-helpers.ts | 15 +- e2e/tests/auth/multi-device-session.spec.ts | 15 +- .../auth/multi-signal-server-auth.spec.ts | 111 ++++++++++ e2e/tests/chat/notifications.spec.ts | 16 +- e2e/tests/chat/profile-avatar-sync.spec.ts | 3 +- e2e/tests/chat/server-icon-sync.spec.ts | 3 +- .../plugins/plugin-api-two-users.spec.ts | 29 ++- e2e/tests/plugins/plugin-manager-ui.spec.ts | 14 +- .../settings/connectivity-warning.spec.ts | 7 +- .../settings/ice-server-settings.spec.ts | 8 +- e2e/tests/settings/stun-turn-fallback.spec.ts | 5 +- electron/api/provision-secret-store.ts | 60 +++++ electron/ipc/system.ts | 15 +- electron/path-jail.spec.ts | 3 + electron/preload.ts | 8 +- server/src/app.ts | 2 +- .../routes/user-registration.rules.spec.ts | 30 +++ server/src/routes/user-registration.rules.ts | 11 + server/src/routes/users.ts | 12 +- server/src/websocket/broadcast.spec.ts | 6 +- .../websocket/handler-multi-client.spec.ts | 4 + toju-app/public/i18n/catalog/auth.json | 9 + toju-app/public/i18n/catalog/servers.json | 1 + toju-app/public/i18n/catalog/settings.json | 6 + toju-app/public/i18n/en.json | 16 ++ .../platform/electron/electron-api.models.ts | 2 + .../services/auth-token-store.service.ts | 8 +- .../services/authentication.service.ts | 8 +- .../services/message-signing.service.ts | 10 +- .../provision-secret-store.service.spec.ts | 65 ++++++ .../provision-secret-store.service.ts | 52 +++++ .../services/signal-server-auth.service.ts | 206 ++++++++++++++++++ .../signal-server-authorize.service.ts | 68 ++++++ ...al-server-credential-store.service.spec.ts | 84 +++++++ .../signal-server-credential-store.service.ts | 88 ++++++++ .../signal-server-provision-notice.service.ts | 20 ++ .../signal-server-provisioner.service.spec.ts | 171 +++++++++++++++ .../signal-server-provisioner.service.ts | 143 ++++++++++++ .../domain/logic/auth-navigation.rules.ts | 23 +- .../domain/logic/auth-session.rules.spec.ts | 10 +- .../self-presence-identity.rules.spec.ts | 53 +++++ .../logic/self-presence-identity.rules.ts | 31 +++ ...server-credential-resolution.rules.spec.ts | 55 +++++ ...gnal-server-credential-resolution.rules.ts | 53 +++++ .../signal-server-provision.rules.spec.ts | 36 +++ .../logic/signal-server-provision.rules.ts | 34 +++ .../models/signal-server-credential.model.ts | 9 + .../feature/login/login.component.html | 12 +- .../feature/login/login.component.ts | 48 +++- .../feature/register/register.component.html | 12 +- .../feature/register/register.component.ts | 49 ++++- .../src/app/domains/authentication/index.ts | 9 + .../domain/rules/message-integrity.rules.ts | 4 +- .../message-revision-signing.rules.spec.ts | 6 +- .../domain/rules/message-sync.rules.spec.ts | 6 +- .../typing-indicator.component.ts | 2 +- .../custom-emoji-picker.component.spec.ts | 1 + .../custom-emoji-picker.component.ts | 5 +- .../services/direct-call.service.spec.ts | 7 +- .../services/direct-call.service.ts | 5 +- .../find-people/find-people.component.ts | 5 +- .../friend-button/friend-button.component.ts | 6 +- .../experimental-vlc-player.component.ts | 6 +- .../application/game-activity.service.spec.ts | 1 + .../notifications-settings.component.ts | 6 +- .../plugin-client-api.service.spec.ts | 2 + .../services/plugin-host.service.ts | 12 +- .../services/plugin-store.service.spec.ts | 18 +- .../services/plugin-store.service.ts | 10 +- .../rules/plugin-local-file.rules.spec.ts | 20 +- .../domain/rules/plugin-local-file.rules.ts | 7 +- .../rules/plugin-source-url.rules.spec.ts | 16 ++ .../domain/rules/plugin-source-url.rules.ts | 14 ++ .../profile-avatar-editor.component.ts | 6 +- .../services/server-directory.service.ts | 39 +++- .../create-server-dialog.component.ts | 6 +- .../find-servers.component.spec.ts | 1 + .../feature/invite/invite.component.ts | 17 +- .../server-browser.component.spec.ts | 1 + .../server-browser.component.ts | 10 + .../services/theme.service.spec.ts | 1 + .../application/services/theme.service.ts | 7 +- .../theme-picker-overlay.component.ts | 5 +- .../logic/client-voice-session.rules.spec.ts | 6 +- .../direct-call/private-call.component.ts | 5 +- .../rooms-side-panel.component.ts | 34 ++- .../servers-rail/servers-rail.component.ts | 1 + .../bans-settings/bans-settings.component.ts | 6 +- .../data-settings/data-settings.component.ts | 6 +- .../debugging-settings.component.ts | 6 +- .../general-settings.component.ts | 5 +- .../ice-server-settings.component.html | 2 +- .../ice-server-settings.component.ts | 1 + .../local-api-settings.component.ts | 6 +- .../network-settings.component.html | 32 +++ .../network-settings.component.ts | 28 ++- .../permissions-settings.component.ts | 5 +- .../server-settings.component.ts | 5 +- .../features/settings/settings.component.ts | 5 +- .../capacitor-mobile-notifications.adapter.ts | 12 +- .../logic/mobile-sqlite-schema.rules.ts | 6 +- .../persistence/capacitor-database.service.ts | 6 +- .../src/app/infrastructure/realtime/README.md | 2 + .../realtime/realtime-session.service.ts | 47 ++-- .../signaling/signaling-transport-handler.ts | 159 +++++++++++--- .../realtime/signaling/signaling.manager.ts | 32 ++- .../bottom-sheet/bottom-sheet.component.ts | 6 +- .../context-menu/context-menu.component.ts | 6 +- .../modal-backdrop.component.spec.ts | 6 +- .../messages-incoming.handlers.spec.ts | 1 - .../app/store/messages/messages.helpers.ts | 10 +- .../store/rooms/room-members-sync.effects.ts | 9 +- .../rooms/room-signaling-connection.spec.ts | 45 +++- .../store/rooms/room-signaling-connection.ts | 18 +- .../store/rooms/room-state-sync.effects.ts | 18 +- toju-app/src/app/store/rooms/rooms.effects.ts | 44 ++-- .../rooms/server-registration.rules.spec.ts | 79 +++++++ .../store/rooms/server-registration.rules.ts | 59 +++++ toju-app/src/app/store/users/users.actions.ts | 10 +- toju-app/src/app/store/users/users.effects.ts | 162 +++++++++++++- 127 files changed, 2731 insertions(+), 322 deletions(-) create mode 100644 e2e/helpers/app-menu.ts create mode 100644 e2e/helpers/dashboard.ts create mode 100644 e2e/helpers/plugin-store.ts create mode 100644 e2e/tests/auth/multi-signal-server-auth.spec.ts create mode 100644 electron/api/provision-secret-store.ts create mode 100644 server/src/routes/user-registration.rules.spec.ts create mode 100644 server/src/routes/user-registration.rules.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.spec.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.spec.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-provision-notice.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.spec.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.spec.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts create mode 100644 toju-app/src/app/domains/authentication/domain/models/signal-server-credential.model.ts create mode 100644 toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.ts create mode 100644 toju-app/src/app/store/rooms/server-registration.rules.spec.ts create mode 100644 toju-app/src/app/store/rooms/server-registration.rules.ts diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 64270a8..fd06f0c 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,27 @@ Durable rules for AI agents working on this project. Read this file at session s ## Lessons +### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms] + +- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless. +- **Rule:** resolve owner identity as `oderId || id` everywhere it's required (the server rejects an empty `ownerPublicKey`), and give `registerServer().subscribe()` an `error` handler so a failed registration is never silent. +- **Why:** verified against the live server — authed POST with a truthy `ownerPublicKey` → 201; authed POST with an empty one → 400; the swallowed 400 is exactly what produces a "ghost" room the creator can enter but no one can find. +- **Example:** `buildServerRegistrationPayload(room, currentUser, normalizedPassword)` in `toju-app/src/app/store/rooms/server-registration.rules.ts`, used by `RoomsEffects.createRoom$`. + +### Identify must fall back to the legacy session token, not only the new credential store [realtime] [authentication] + +- **Trigger:** the multi-signal-server auth refactor changed `resolveCredentialForSignalUrl` to read *only* `SignalServerCredentialStoreService`; sessions restored from disk (and logins where `user.homeSignalServerUrl` is unset) have an empty credential store, so `identify` was skipped on every signal server ("Skipping identify because no session token is available") and users appeared alone — no presence, no peers, sent messages visible only to themselves. E2E never caught it because every e2e flow does a *fresh* register/login that writes the credential store directly. +- **Rule:** when resolving the identify credential for a signal URL, prefer the per-signal credential but fall back to the legacy `AuthTokenStoreService` token reconstructed with the current home user's `id`/`displayName`; never gate `identify` solely on the new credential store. +- **Why:** `persistSessionToken` always writes the legacy `metoyou.authTokens` store on login, but the per-signal credential store is only populated on fresh login (with a `loginResponse`) or successful migration/provisioning — so on reload it can be empty while a valid session still exists. +- **Example:** `resolveSignalIdentity(credential, legacyTokenEntry, homeUser)` in `signal-server-credential-resolution.rules.ts`, wired through `SignalServerAuthService.resolveCredentialForSignalUrl` (which now passes `this.authTokenStore.getTokenEntry(httpUrl)` and a `homeUser` carrying `id`). Test cross-user behavior via a *session-restore* path, not just fresh login. + +### Keep the per-signal-URL identify credential resolvable from the store [realtime] [authentication] + +- **Trigger:** after the multi-signal-server auth refactor, `SignalingManager.getLastIdentify` was switched to `getIdentifyCredentialsForSignalUrl`, which only read an in-memory cache populated *after* `identify()` ran; a freshly (re)connected socket then emitted `join_server` before any identify and users silently never appeared in the presence roster (almost all multi-user e2e tests timed out waiting for the peer's `room-user-card`). +- **Rule:** `getIdentifyCredentialsForSignalUrl` must fall back to resolving the credential from the credential store so a new socket's `onopen` re-identifies before it re-joins; never restrict it to only the in-memory identify cache. +- **Why:** the server drops `join_server`/`view_server` on any unauthenticated connection, so an identify-less join is lost with no error and recovery only happens on a later reconnect (often beyond the 20s test timeout). +- **Example:** server log showed `join_server authed=false ... display=User` dropped, then `User identified: Alice` on a different connection but no `Alice joined server`; fixed in `signaling-transport-handler.ts` by resolving via `dependencies.resolveCredential(signalUrl)` when the cache is empty. + ### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device] - **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync. diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md index 9ed4237..b7c6952 100644 --- a/agents-docs/features/authentication.md +++ b/agents-docs/features/authentication.md @@ -66,7 +66,22 @@ Require `Authorization: Bearer`: The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server. -Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home/active signaling server (or any stored token as a fallback). Missing or rejected tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. WebSocket `auth_required` / `auth_error` responses trigger the same path. +Per-server credentials (`metoyou.signalServerCredentials`) map each normalized signal-server URL to the authenticated user id, username, display name, session token, expiry, and whether the account was auto-provisioned. The home user profile in SQLite/NgRx remains the device-local identity (`homeSignalServerUrl`); foreign-server credentials are a side map used for REST and WebSocket identify on that URL. + +A per-install **provision secret** enables silent account creation on newly added or encountered signal servers. It is generated on home register/login, stored in Electron `safeStorage` when available (sessionStorage fallback on web), and never persisted as the user's visible login password. + +### Multi-signal-server auth flows + +| Flow | Action | Effect | +|---|---|---| +| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret | +| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged | +| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-`) | +| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth | + +Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges. + +Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision. ## Security considerations diff --git a/e2e/helpers/app-menu.ts b/e2e/helpers/app-menu.ts new file mode 100644 index 0000000..480a884 --- /dev/null +++ b/e2e/helpers/app-menu.ts @@ -0,0 +1,20 @@ +import { expect, type Page } from '@playwright/test'; + +export async function openTitleBarMenu(page: Page): Promise { + const menuButton = page.getByRole('button', { name: 'Menu' }); + + await expect(menuButton).toBeVisible({ timeout: 15_000 }); + await menuButton.click(); + await expect(page.locator('app-title-bar .absolute.right-0.top-full').first()).toBeVisible({ timeout: 10_000 }); +} + +export async function openPluginStore(page: Page): Promise { + await openTitleBarMenu(page); + await page.getByRole('button', { name: 'Plugin Store' }).click(); + await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 }); +} + +export async function openSettingsFromMenu(page: Page): Promise { + await openTitleBarMenu(page); + await page.getByRole('button', { name: 'Settings' }).click(); +} diff --git a/e2e/helpers/auth-api.ts b/e2e/helpers/auth-api.ts index 75d85d2..9ffc5cd 100644 --- a/e2e/helpers/auth-api.ts +++ b/e2e/helpers/auth-api.ts @@ -1,6 +1,7 @@ import { type APIRequestContext, type Page } from '@playwright/test'; export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens'; +export const SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY = 'metoyou.signalServerCredentials'; export interface AuthSession { id: string; @@ -56,6 +57,36 @@ export async function loginTestUser( return await response.json() as AuthSession; } +export async function readSignalServerCredentialFromPage( + page: Page, + serverUrl: string +): Promise<{ userId: string; token: string; username: string } | null> { + return await page.evaluate(({ storageKey, url }) => { + try { + const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record; + const normalizedUrl = url.trim().replace(/\/+$/, ''); + const entry = store[normalizedUrl]; + + if (!entry || entry.expiresAt <= Date.now()) { + return null; + } + + return { + userId: entry.userId, + token: entry.token, + username: entry.username + }; + } catch { + return null; + } + }, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: serverUrl }); +} + export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise { return await page.evaluate(({ storageKey, url }) => { try { diff --git a/e2e/helpers/dashboard.ts b/e2e/helpers/dashboard.ts new file mode 100644 index 0000000..212e9cf --- /dev/null +++ b/e2e/helpers/dashboard.ts @@ -0,0 +1,11 @@ +import { expect, type Page } from '@playwright/test'; + +/** Dashboard omnibox (desktop placeholder copy changed with i18n refresh). */ +export function dashboardSearchInput(page: Page) { + return page.getByRole('textbox', { name: 'Search people, servers, and invites' }); +} + +export async function expectDashboardReady(page: Page, timeout = 30_000): Promise { + await expect(page).toHaveURL(/\/dashboard/, { timeout }); + await expect(dashboardSearchInput(page)).toBeVisible({ timeout }); +} diff --git a/e2e/helpers/multi-device-session.ts b/e2e/helpers/multi-device-session.ts index b3aa89b..82eb9a2 100644 --- a/e2e/helpers/multi-device-session.ts +++ b/e2e/helpers/multi-device-session.ts @@ -41,7 +41,6 @@ export async function createMultiDeviceScenario( password: MULTI_DEVICE_PASSWORD }; const serverName = `Multi Device Server ${suffix}`; - const clientA = await createClient(); const clientB = await createClient(); @@ -59,6 +58,7 @@ export async function createMultiDeviceScenario( await searchA.createServer(serverName, { description: options.serverDescription ?? 'Multi-device session coverage' }); + await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await waitForCurrentRoomName(clientA.page, serverName); @@ -115,7 +115,8 @@ export async function expectCrossDeviceMessage( await sender.sendMessage(message); await expect.poll(async () => { - return await receiver.getMessageItemByText(message).isVisible().catch(() => false); + return await receiver.getMessageItemByText(message).isVisible() + .catch(() => false); }, { timeout }).toBe(true); } @@ -150,7 +151,15 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20 } export async function readClientInstanceId(page: Page): Promise { - return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId')); + return page.evaluate(() => { + const sessionId = sessionStorage.getItem('metoyou.clientInstanceId')?.trim(); + + if (sessionId) { + return sessionId; + } + + return localStorage.getItem('metoyou.clientInstanceId')?.trim() ?? null; + }); } export async function logoutFromMenu(page: Page): Promise { @@ -191,9 +200,14 @@ export async function expectPassiveVoiceOnDevice( .getByText('In voice on another device', { exact: false }) .isVisible() .catch(() => false); - const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false); + const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible() + .catch(() => false); const grayedVoiceUser = displayName - ? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false) + ? await channelsSidePanel(page).locator('.opacity-50') + .filter({ hasText: displayName }) + .first() + .isVisible() + .catch(() => false) : false; return membersLabel || joinBadge || grayedVoiceUser; diff --git a/e2e/helpers/plugin-store.ts b/e2e/helpers/plugin-store.ts new file mode 100644 index 0000000..37213f8 --- /dev/null +++ b/e2e/helpers/plugin-store.ts @@ -0,0 +1,19 @@ +import { expect, type Page } from '@playwright/test'; + +export const E2E_PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json'; +export const E2E_PLUGIN_TITLE = 'E2E All API Plugin'; + +export async function addPluginSource(page: Page, sourceUrl = E2E_PLUGIN_SOURCE_URL): Promise { + const sourceInput = page.getByLabel('Plugin source manifest URL'); + + await expect(sourceInput).toBeVisible({ timeout: 15_000 }); + await sourceInput.click(); + await sourceInput.fill(sourceUrl); + await expect(sourceInput).toHaveValue(sourceUrl, { timeout: 5_000 }); + + const addSourceButton = page.getByRole('button', { name: 'Add Source' }); + + await expect(addSourceButton).toBeEnabled({ timeout: 10_000 }); + await addSourceButton.click(); + await expect(page.getByRole('heading', { name: E2E_PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); +} diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 75dc5be..145fcba 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -1,15 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { type Page } from '@playwright/test'; +import { type BrowserContext, type Page } from '@playwright/test'; /** * Install RTCPeerConnection monkey-patch on a page BEFORE navigating. * Tracks all created peer connections and their remote tracks so tests * can inspect WebRTC state via `page.evaluate()`. * - * Call immediately after page creation, before any `goto()`. + * Call on the browser context (preferred) or page before any `goto()`. */ -export async function installWebRTCTracking(page: Page): Promise { - await page.addInitScript(() => { +export async function installWebRTCTracking(target: BrowserContext | Page): Promise { + const addInitScript = 'addInitScript' in target && typeof target.addInitScript === 'function' + ? target.addInitScript.bind(target) + : (target as Page).addInitScript.bind(target); + + await addInitScript(() => { const connections: RTCPeerConnection[] = []; const dataChannels: RTCDataChannel[] = []; const syntheticMediaResources: { @@ -197,6 +201,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis () => (window as any).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false, + undefined, { timeout } ); } @@ -611,6 +616,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr return false; }, + undefined, { timeout } ); } @@ -818,6 +824,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr return false; }, + undefined, { timeout } ); } diff --git a/e2e/tests/auth/multi-device-session.spec.ts b/e2e/tests/auth/multi-device-session.spec.ts index d23947e..76f2905 100644 --- a/e2e/tests/auth/multi-device-session.spec.ts +++ b/e2e/tests/auth/multi-device-session.spec.ts @@ -1,7 +1,4 @@ -import { - test, - expect -} from '../../fixtures/multi-client'; +import { test, expect } from '../../fixtures/multi-client'; import { MULTI_DEVICE_VOICE_CHANNEL, channelsSidePanel, @@ -57,13 +54,17 @@ test.describe('Multi-device session', () => { await expectPassiveVoiceOnDevice(scenario.clientB.page, { displayName: scenario.credentials.displayName }); + await expect( membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false }) ).toBeVisible({ timeout: 20_000 }); + await expect( - channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({ - hasText: scenario.credentials.displayName - }).first() + channelsSidePanel(scenario.clientB.page).locator('.opacity-50') + .filter({ + hasText: scenario.credentials.displayName + }) + .first() ).toBeVisible({ timeout: 20_000 }); }); diff --git a/e2e/tests/auth/multi-signal-server-auth.spec.ts b/e2e/tests/auth/multi-signal-server-auth.spec.ts new file mode 100644 index 0000000..c29509a --- /dev/null +++ b/e2e/tests/auth/multi-signal-server-auth.spec.ts @@ -0,0 +1,111 @@ +import { expect } from '@playwright/test'; +import { test } from '../../fixtures/multi-client'; +import { openSettingsFromMenu } from '../../helpers/app-menu'; +import { expectDashboardReady } from '../../helpers/dashboard'; +import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint'; +import { startTestServer } from '../../helpers/test-server'; +import { + readAuthTokenFromPage, + readSignalServerCredentialFromPage, + registerTestUser +} from '../../helpers/auth-api'; +import { RegisterPage } from '../../pages/register.page'; + +const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary'; +const USER_PASSWORD = 'TestPass123!'; + +test.describe('Multi-signal-server authentication', () => { + test.describe.configure({ timeout: 180_000 }); + + test('auto-provisions a foreign signal server when a new endpoint is added', async ({ createClient, request }) => { + const primaryServer = await startTestServer(); + const secondaryServer = await startTestServer(); + + try { + const client = await createClient(); + const suffix = `multi_auth_${Date.now()}`; + const username = `user_${suffix}`; + + await installTestServerEndpoints(client.context, [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' + } + ]); + + await test.step('Register on the home signal server', async () => { + const register = new RegisterPage(client.page); + + await register.goto(); + await register.register(username, 'Multi Auth User', USER_PASSWORD); + await expectDashboardReady(client.page); + }); + + await test.step('Add a second signal server in network settings', async () => { + await openSettingsFromMenu(client.page); + await client.page.getByRole('button', { name: 'Network' }).click(); + + await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal'); + await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url); + await client.page.getByTestId('add-signal-server-button').click(); + + await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 }); + }); + + await test.step('Wait for auto-provisioned credentials on the secondary server', async () => { + await expect.poll(async () => + await readSignalServerCredentialFromPage(client.page, secondaryServer.url), + { timeout: 30_000 } + ).not.toBeNull(); + + const homeToken = await readAuthTokenFromPage(client.page, primaryServer.url); + const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url); + + expect(homeToken).toBeTruthy(); + expect(secondaryCredential?.username).toBe(username); + expect(secondaryCredential?.token).toBeTruthy(); + }); + + await test.step('Secondary credential can call authenticated APIs', async () => { + const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url); + + if (!secondaryCredential) { + throw new Error('Expected secondary signal-server credential to be provisioned'); + } + + const response = await request.post(`${secondaryServer.url}/api/servers`, { + headers: { + Authorization: `Bearer ${secondaryCredential.token}`, + 'Content-Type': 'application/json' + }, + data: { + name: `Secondary Provisioned Server ${suffix}`, + description: 'Created with auto-provisioned credentials', + ownerId: secondaryCredential.userId, + ownerPublicKey: 'e2e-secondary-owner-key' + } + }); + + expect(response.ok(), `POST /api/servers failed: ${response.status()} ${await response.text()}`).toBe(true); + }); + + await test.step('Home registration still works independently on the secondary server', async () => { + const otherUser = await registerTestUser( + request, + secondaryServer.url, + `other_${suffix}`, + USER_PASSWORD, + 'Other User' + ); + + expect(otherUser.username).toBe(`other_${suffix}`); + }); + } finally { + await primaryServer.stop(); + await secondaryServer.stop(); + } + }); +}); diff --git a/e2e/tests/chat/notifications.spec.ts b/e2e/tests/chat/notifications.spec.ts index d318912..20a9a17 100644 --- a/e2e/tests/chat/notifications.spec.ts +++ b/e2e/tests/chat/notifications.spec.ts @@ -1,5 +1,6 @@ import { expect, + type BrowserContext, type Locator, type Page } from '@playwright/test'; @@ -35,6 +36,7 @@ test.describe('Chat notifications', () => { await clearDesktopNotifications(scenario.alice.page); await scenario.bobRoom.joinTextChannel(scenario.channelName); await scenario.bobMessages.sendMessage(message); + await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName); }); await test.step('Alice receives a desktop notification with the channel preview', async () => { @@ -67,8 +69,7 @@ test.describe('Chat notifications', () => { }); await test.step('Alice still sees unread badges for the room and channel', async () => { - await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); - await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 }); + await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName); }); await test.step('Alice does not get a muted desktop popup', async () => { @@ -96,7 +97,7 @@ async function createNotificationScenario(createClient: () => Promise): const alice = await createClient(); const bob = await createClient(); - await installDesktopNotificationSpy(alice.page); + await installDesktopNotificationSpy(alice.context); await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password); await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password); @@ -143,8 +144,8 @@ async function registerUser(page: Page, username: string, displayName: string, p await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); } -async function installDesktopNotificationSpy(page: Page): Promise { - await page.addInitScript(() => { +async function installDesktopNotificationSpy(context: BrowserContext): Promise { + await context.addInitScript(() => { const notifications: DesktopNotificationRecord[] = []; class MockNotification { @@ -250,6 +251,11 @@ function getUnreadBadge(container: Locator): Locator { return container.locator('span.rounded-full').first(); } +async function expectUnreadCounts(page: Page, serverName: string, channelName: string): Promise { + await expect(getUnreadBadge(getSavedRoomButton(page, serverName))).toHaveText('1', { timeout: 45_000 }); + await expect(getUnreadBadge(getTextChannelButton(page, channelName))).toHaveText('1', { timeout: 45_000 }); +} + function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; diff --git a/e2e/tests/chat/profile-avatar-sync.spec.ts b/e2e/tests/chat/profile-avatar-sync.spec.ts index 59c4198..3b3a161 100644 --- a/e2e/tests/chat/profile-avatar-sync.spec.ts +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -367,11 +367,10 @@ async function launchPersistentSession( }); await installTestServerEndpoint(context, testServerPort); + await installWebRTCTracking(context); const page = context.pages()[0] ?? await context.newPage(); - await installWebRTCTracking(page); - return { context, page }; } diff --git a/e2e/tests/chat/server-icon-sync.spec.ts b/e2e/tests/chat/server-icon-sync.spec.ts index 5fc84de..fce91f0 100644 --- a/e2e/tests/chat/server-icon-sync.spec.ts +++ b/e2e/tests/chat/server-icon-sync.spec.ts @@ -196,11 +196,10 @@ async function launchPersistentSession(userDataDir: string, testServerPort: numb }); await installTestServerEndpoint(context, testServerPort); + await installWebRTCTracking(context); const page = context.pages()[0] ?? (await context.newPage()); - await installWebRTCTracking(page); - return { context, page }; } diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index d82bf54..e6fc200 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -4,13 +4,20 @@ import { test, type Client } from '../../fixtures/multi-client'; +import { openPluginStore } from '../../helpers/app-menu'; +import { + addPluginSource, + E2E_PLUGIN_SOURCE_URL, + E2E_PLUGIN_TITLE +} from '../../helpers/plugin-store'; +import { installWebRTCTracking } from '../../helpers/webrtc-helpers'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; -const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json'; -const PLUGIN_TITLE = 'E2E All API Plugin'; +const PLUGIN_SOURCE_URL = E2E_PLUGIN_SOURCE_URL; +const PLUGIN_TITLE = E2E_PLUGIN_TITLE; const EDITED_MESSAGE = 'Plugin API edited message'; const ORIGINAL_MESSAGE = 'Plugin API original message'; const DELETED_MESSAGE = 'Plugin API deleted message'; @@ -87,6 +94,9 @@ async function createPluginApiScenario(createClient: () => Promise): Pro const alice = await createClient(); const bob = await createClient(); + await installWebRTCTracking(alice.page); + await installWebRTCTracking(bob.page); + await registerUser(alice.page, `alice_${suffix}`, 'Alice'); await registerUser(bob.page, `bob_${suffix}`, 'Bob'); @@ -98,13 +108,10 @@ async function createPluginApiScenario(createClient: () => Promise): Pro const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL); - await installGrantAndActivatePlugin(alice.page, true); - await closeSettingsModal(alice.page); - await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 }); const bobSearch = new ServerSearchPage(bob.page); - await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true }); + await bobSearch.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 }); const bobRoom = new ChatRoomPage(bob.page); @@ -113,6 +120,9 @@ async function createPluginApiScenario(createClient: () => Promise): Pro await bobRoom.joinVoiceChannel(VOICE_CHANNEL); await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 }); await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 }); + await installGrantAndActivatePlugin(alice.page, true); + await closeSettingsModal(alice.page); + await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 }); const aliceMessages = new ChatMessagesPage(alice.page); const bobMessages = new ChatMessagesPage(bob.page); @@ -141,14 +151,11 @@ async function registerUser(page: Page, username: string, displayName: string): } async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise { - await page.getByRole('button', { name: 'Plugin Store' }).click(); - await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 }); + await openPluginStore(page); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 }); if (installFromStore) { - await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); - await page.getByRole('button', { name: 'Add Source' }).click(); - await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); + await addPluginSource(page, PLUGIN_SOURCE_URL); await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }) .click(); diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts index a2433ec..ecc1322 100644 --- a/e2e/tests/plugins/plugin-manager-ui.spec.ts +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -1,4 +1,7 @@ import { expect, test } from '../../fixtures/multi-client'; +import { openPluginStore } from '../../helpers/app-menu'; +import { expectDashboardReady } from '../../helpers/dashboard'; +import { addPluginSource } from '../../helpers/plugin-store'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; @@ -15,7 +18,7 @@ test.describe('Plugin manager UI', () => { await test.step('Register user and create server context', async () => { await register.goto(); await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!'); - await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(page); await search.createServer(`Plugin API Server ${suffix}`, { description: 'Plugin manager UI E2E coverage' }); @@ -23,16 +26,13 @@ test.describe('Plugin manager UI', () => { await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); }); - await test.step('Open visible Plugin Store button', async () => { - await page.getByRole('button', { name: 'Plugin Store' }).click(); - await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 }); + await test.step('Open Plugin Store from the title-bar menu', async () => { + await openPluginStore(page); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 }); }); await test.step('Install fixture plugin from source manifest', async () => { - await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); - await page.getByRole('button', { name: 'Add Source' }).click(); - await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 }); + await addPluginSource(page); const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' }); await pluginCard.getByRole('button', { name: 'Readme' }).click(); diff --git a/e2e/tests/settings/connectivity-warning.spec.ts b/e2e/tests/settings/connectivity-warning.spec.ts index e61643a..82636ac 100644 --- a/e2e/tests/settings/connectivity-warning.spec.ts +++ b/e2e/tests/settings/connectivity-warning.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '../../fixtures/multi-client'; +import { expectDashboardReady } from '../../helpers/dashboard'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; @@ -88,7 +89,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); - await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(alice.page); }); await test.step('Register Bob', async () => { @@ -96,7 +97,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); - await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(bob.page); }); await test.step('Register Charlie', async () => { @@ -104,7 +105,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); - await expect(charlie.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(charlie.page); }); // ── Create server and have everyone join ── diff --git a/e2e/tests/settings/ice-server-settings.spec.ts b/e2e/tests/settings/ice-server-settings.spec.ts index 32da9b2..5949771 100644 --- a/e2e/tests/settings/ice-server-settings.spec.ts +++ b/e2e/tests/settings/ice-server-settings.spec.ts @@ -1,4 +1,6 @@ import { test, expect } from '../../fixtures/multi-client'; +import { openSettingsFromMenu } from '../../helpers/app-menu'; +import { expectDashboardReady } from '../../helpers/dashboard'; import { RegisterPage } from '../../pages/register.page'; test.describe('ICE server settings', () => { @@ -9,8 +11,8 @@ test.describe('ICE server settings', () => { await register.goto(); await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); - await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); - await page.getByTitle('Settings').click(); + await expectDashboardReady(page); + await openSettingsFromMenu(page); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'Network' }).click(); await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 }); @@ -101,7 +103,7 @@ test.describe('ICE server settings', () => { await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 }); await page.reload({ waitUntil: 'domcontentloaded' }); - await page.getByTitle('Settings').click(); + await openSettingsFromMenu(page); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'Network' }).click(); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/tests/settings/stun-turn-fallback.spec.ts b/e2e/tests/settings/stun-turn-fallback.spec.ts index 85e6be2..f561d01 100644 --- a/e2e/tests/settings/stun-turn-fallback.spec.ts +++ b/e2e/tests/settings/stun-turn-fallback.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '../../fixtures/multi-client'; +import { expectDashboardReady } from '../../helpers/dashboard'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; @@ -89,7 +90,7 @@ test.describe('STUN/TURN fallback behaviour', () => { await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); - await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(alice.page); }); await test.step('Register Bob', async () => { @@ -97,7 +98,7 @@ test.describe('STUN/TURN fallback behaviour', () => { await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); - await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); + await expectDashboardReady(bob.page); }); await test.step('Alice creates a server', async () => { diff --git a/electron/api/provision-secret-store.ts b/electron/api/provision-secret-store.ts new file mode 100644 index 0000000..34a4b60 --- /dev/null +++ b/electron/api/provision-secret-store.ts @@ -0,0 +1,60 @@ +import { safeStorage } from 'electron'; +import { + mkdir, + readFile, + writeFile +} from 'fs/promises'; +import path from 'path'; +import { app } from 'electron'; + +const STORAGE_DIR_NAME = 'provision-secrets'; + +function getStorageDir(): string { + return path.join(app.getPath('userData'), STORAGE_DIR_NAME); +} + +function getSecretFilePath(homeUserId: string): string { + return path.join(getStorageDir(), `${homeUserId}.bin`); +} + +async function ensureStorageDir(): Promise { + await mkdir(getStorageDir(), { recursive: true }); +} + +export async function storeProvisionSecret(homeUserId: string, secret: string): Promise { + if (!homeUserId.trim() || !secret) { + return false; + } + + await ensureStorageDir(); + + if (!safeStorage.isEncryptionAvailable()) { + await writeFile(getSecretFilePath(homeUserId), secret, 'utf8'); + return true; + } + + const encrypted = safeStorage.encryptString(secret); + + await writeFile(getSecretFilePath(homeUserId), encrypted); + + return true; +} + +export async function getProvisionSecret(homeUserId: string): Promise { + if (!homeUserId.trim()) { + return null; + } + + try { + const filePath = getSecretFilePath(homeUserId); + const payload = await readFile(filePath); + + if (!safeStorage.isEncryptionAvailable()) { + return payload.toString('utf8'); + } + + return safeStorage.decryptString(payload); + } catch { + return null; + } +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 1c07db0..327502c 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -20,6 +20,7 @@ import { type DesktopSettings } from '../desktop-settings'; import { applyLocalApiSettings, getLocalApiSnapshot } from '../api'; +import { getProvisionSecret, storeProvisionSecret } from '../api/provision-secret-store'; import { activateLinuxScreenShareAudioRouting, deactivateLinuxScreenShareAudioRouting, @@ -62,7 +63,11 @@ import { listRunningProcessNames } from '../process-list'; import { detectActiveGame } from '../game-detection'; import { collectAppMetricsSnapshot } from '../app-metrics'; import { clearAllTokens } from '../api/auth-store'; -import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail'; +import { + assertPathUnderUserData, + grantPluginReadRoot, + resolveReadablePath +} from '../path-jail'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; @@ -380,6 +385,14 @@ export function setupSystemHandlers(): void { ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot()); + ipcMain.handle('store-provision-secret', async (_event, homeUserId: string, secret: string) => + await storeProvisionSecret(homeUserId, secret) + ); + + ipcMain.handle('get-provision-secret', async (_event, homeUserId: string) => + await getProvisionSecret(homeUserId) + ); + ipcMain.handle('get-app-data-path', () => app.getPath('userData')); ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder()); ipcMain.handle('export-user-data', async () => await exportUserData()); diff --git a/electron/path-jail.spec.ts b/electron/path-jail.spec.ts index 68934fc..1ed6a29 100644 --- a/electron/path-jail.spec.ts +++ b/electron/path-jail.spec.ts @@ -37,8 +37,10 @@ describe('path-jail', () => { it('accepts cached plugin bundle paths under plugin-bundles', async () => { const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0'); + fs.mkdirSync(bundleDir, { recursive: true }); const bundlePath = path.join(bundleDir, 'main.js'); + fs.writeFileSync(bundlePath, 'export default {}'); await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath); @@ -59,6 +61,7 @@ describe('path-jail', () => { it('allows user-granted plugin source roots outside app data', async () => { const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-')); const manifestPath = path.join(externalRoot, 'plugin-source.json'); + fs.writeFileSync(manifestPath, '{}'); grantPluginReadRoot(externalRoot); diff --git a/electron/preload.ts b/electron/preload.ts index 72dd110..4adfbaf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -346,6 +346,9 @@ export interface ElectronAPI { command: (command: Command) => Promise; query: (query: Query) => Promise; + + storeProvisionSecret: (homeUserId: string, secret: string) => Promise; + getProvisionSecret: (homeUserId: string) => Promise; } const electronAPI: ElectronAPI = { @@ -502,7 +505,10 @@ const electronAPI: ElectronAPI = { }, command: (command) => ipcRenderer.invoke('cqrs:command', command), - query: (query) => ipcRenderer.invoke('cqrs:query', query) + query: (query) => ipcRenderer.invoke('cqrs:query', query), + + storeProvisionSecret: (homeUserId, secret) => ipcRenderer.invoke('store-provision-secret', homeUserId, secret), + getProvisionSecret: (homeUserId) => ipcRenderer.invoke('get-provision-secret', homeUserId) }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/server/src/app.ts b/server/src/app.ts index d435fd8..84c785c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,7 +41,7 @@ function buildCorsOptions() { export function createApp(): express.Express { const app = express(); - // Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY. + // Trust loopback proxies only - avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY. app.set('trust proxy', 'loopback'); app.use(cors(buildCorsOptions())); app.use(express.json()); diff --git a/server/src/routes/user-registration.rules.spec.ts b/server/src/routes/user-registration.rules.spec.ts new file mode 100644 index 0000000..f389139 --- /dev/null +++ b/server/src/routes/user-registration.rules.spec.ts @@ -0,0 +1,30 @@ +import { + describe, + it, + expect +} from 'vitest'; +import { isDuplicateUsernameError } from './user-registration.rules'; + +describe('user-registration.rules', () => { + it('detects sqlite unique constraint failures on username', () => { + expect(isDuplicateUsernameError({ + message: 'UNIQUE constraint failed: users.username' + })).toBe(true); + }); + + it('detects typeorm query failed errors with username constraint text', () => { + expect(isDuplicateUsernameError({ + name: 'QueryFailedError', + message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: users.username' + })).toBe(true); + }); + + it('ignores unrelated database errors', () => { + expect(isDuplicateUsernameError({ + message: 'UNIQUE constraint failed: servers.id' + })).toBe(false); + + expect(isDuplicateUsernameError(new Error('connection lost'))).toBe(false); + expect(isDuplicateUsernameError(null)).toBe(false); + }); +}); diff --git a/server/src/routes/user-registration.rules.ts b/server/src/routes/user-registration.rules.ts new file mode 100644 index 0000000..9da6787 --- /dev/null +++ b/server/src/routes/user-registration.rules.ts @@ -0,0 +1,11 @@ +export function isDuplicateUsernameError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const message = 'message' in error && typeof error.message === 'string' + ? error.message + : ''; + + return message.includes('UNIQUE constraint failed: users.username'); +} diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 1305623..90152e7 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -10,6 +10,7 @@ import { import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service'; import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service'; import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth'; +import { isDuplicateUsernameError } from './user-registration.rules'; const router = Router(); @@ -46,7 +47,16 @@ router.post('/register', async (req, res) => { createdAt: Date.now() }; - await registerUser(user); + try { + await registerUser(user); + } catch (error) { + if (isDuplicateUsernameError(error)) { + return res.status(409).json({ error: 'Username taken' }); + } + + throw error; + } + const session = await issueSessionToken(user.id); res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt)); diff --git a/server/src/websocket/broadcast.spec.ts b/server/src/websocket/broadcast.spec.ts index 0d1beca..fa9ae1c 100644 --- a/server/src/websocket/broadcast.spec.ts +++ b/server/src/websocket/broadcast.spec.ts @@ -7,7 +7,11 @@ import { import { WebSocket } from 'ws'; import { connectedUsers } from './state'; import { ConnectedUser } from './types'; -import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast'; +import { + broadcastToServer, + findUserByOderId, + findVoiceActiveConnection +} from './broadcast'; function createMockWs(): WebSocket & { sentMessages: string[] } { const sent: string[] = []; diff --git a/server/src/websocket/handler-multi-client.spec.ts b/server/src/websocket/handler-multi-client.spec.ts index 4572aa9..a89d9c1 100644 --- a/server/src/websocket/handler-multi-client.spec.ts +++ b/server/src/websocket/handler-multi-client.spec.ts @@ -134,12 +134,14 @@ describe('server websocket handler - multi-client sessions', () => { oderId: 'user-2', serverIds: new Set(['server-1']) }); + createConnectedUser('conn-passive', { authenticated: true, oderId: 'user-1', serverIds: new Set(['server-1']), clientInstanceId: 'device-passive' }); + const active = createConnectedUser('conn-active', { authenticated: true, oderId: 'user-1', @@ -169,6 +171,7 @@ describe('server websocket handler - multi-client sessions', () => { serverIds: new Set(['server-1']), clientInstanceId: 'device-b' }); + const active = createConnectedUser('conn-active', { authenticated: true, oderId: 'user-1', @@ -197,6 +200,7 @@ describe('server websocket handler - multi-client sessions', () => { connectionScope: 'ws://localhost:3001', clientInstanceId: 'device-a' }); + createConnectedUser('conn-new', { authenticated: false, connectionScope: 'ws://localhost:3001', diff --git a/toju-app/public/i18n/catalog/auth.json b/toju-app/public/i18n/catalog/auth.json index a0c63b3..0337d60 100644 --- a/toju-app/public/i18n/catalog/auth.json +++ b/toju-app/public/i18n/catalog/auth.json @@ -29,6 +29,15 @@ "prepareStateFailed": "Failed to prepare local user state.", "noCurrentUser": "No current user", "sessionExpired": "Your session expired. Please sign in again." + }, + "authorize": { + "title": "Sign in to {{serverName}}", + "registerTitle": "Create account on {{serverName}}", + "description": "Your home account stays signed in. This only authorizes the selected signal server.", + "defaultServerName": "Signal Server" + }, + "provision": { + "usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead." } } } diff --git a/toju-app/public/i18n/catalog/servers.json b/toju-app/public/i18n/catalog/servers.json index 6c1f921..6f52f5f 100644 --- a/toju-app/public/i18n/catalog/servers.json +++ b/toju-app/public/i18n/catalog/servers.json @@ -124,6 +124,7 @@ "loading": "Loading invite...", "joining": "Joining {{name}}...", "redirectingLogin": "Redirecting to login...", + "redirectingAuthorize": "Authorizing signal server...", "missingInfo": "This invite link is missing required server information.", "acceptFailed": "Unable to accept this invite.", "banned": "You are banned from this server and cannot accept this invite.", diff --git a/toju-app/public/i18n/catalog/settings.json b/toju-app/public/i18n/catalog/settings.json index 8fbe887..3f36167 100644 --- a/toju-app/public/i18n/catalog/settings.json +++ b/toju-app/public/i18n/catalog/settings.json @@ -93,6 +93,11 @@ "errors": { "invalidUrl": "Please enter a valid URL", "duplicateUrl": "This server URL already exists" + }, + "auth": { + "authorized": "Authorized", + "needsSignIn": "Needs sign-in", + "signIn": "Sign in" } }, "connection": { @@ -116,6 +121,7 @@ "turnUser": "User: {{username}}", "moveUp": "Move up (higher priority)", "moveDown": "Move down (lower priority)", + "remove": "Remove", "empty": "No ICE servers configured. P2P connections may fail across networks.", "addTitle": "Add ICE Server", "stunPlaceholder": "stun:stun.example.com:19302", diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index a2d6c35..6853e03 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -59,6 +59,15 @@ "prepareStateFailed": "Failed to prepare local user state.", "noCurrentUser": "No current user", "sessionExpired": "Your session expired. Please sign in again." + }, + "authorize": { + "title": "Sign in to {{serverName}}", + "registerTitle": "Create account on {{serverName}}", + "description": "Your home account stays signed in. This only authorizes the selected signal server.", + "defaultServerName": "Signal Server" + }, + "provision": { + "usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead." } }, "call": { @@ -1013,6 +1022,7 @@ "loading": "Loading invite...", "joining": "Joining {{name}}...", "redirectingLogin": "Redirecting to login...", + "redirectingAuthorize": "Authorizing signal server...", "missingInfo": "This invite link is missing required server information.", "acceptFailed": "Unable to accept this invite.", "banned": "You are banned from this server and cannot accept this invite.", @@ -1138,6 +1148,11 @@ "errors": { "invalidUrl": "Please enter a valid URL", "duplicateUrl": "This server URL already exists" + }, + "auth": { + "authorized": "Authorized", + "needsSignIn": "Needs sign-in", + "signIn": "Sign in" } }, "connection": { @@ -1161,6 +1176,7 @@ "turnUser": "User: {{username}}", "moveUp": "Move up (higher priority)", "moveDown": "Move down (lower priority)", + "remove": "Remove", "empty": "No ICE servers configured. P2P connections may fail across networks.", "addTitle": "Add ICE Server", "stunPlaceholder": "stun:stun.example.com:19302", diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 3fa2e22..f7eeaf1 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -323,6 +323,8 @@ export interface ElectronApi { copyImageToClipboard: (srcURL: string) => Promise; command: (command: ElectronCommand) => Promise; query: (query: ElectronQuery) => Promise; + storeProvisionSecret?: (homeUserId: string, secret: string) => Promise; + getProvisionSecret?: (homeUserId: string) => Promise; } export type ElectronWindow = Window & { diff --git a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts index 072bc7a..c8ead5d 100644 --- a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts @@ -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 { diff --git a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts index de6d860..4d4bf51 100644 --- a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts @@ -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 { diff --git a/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts index 4c24ad4..2e37037 100644 --- a/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts @@ -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 }); diff --git a/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.spec.ts new file mode 100644 index 0000000..67e7251 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.spec.ts @@ -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(); + + 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'); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts new file mode 100644 index 0000000..c179c24 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts @@ -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 { + 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 { + const api = this.electronBridge.getApi(); + + if (api?.getProvisionSecret) { + return api.getProvisionSecret(homeUserId); + } + + return sessionStorage.getItem(this.sessionKey(homeUserId)); + } + + async hasSecret(homeUserId: string): Promise { + 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(''); +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts new file mode 100644 index 0000000..67bd57c --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts @@ -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>(); + + 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): 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, existingSecret?: string | null): Promise { + 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 { + 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 | null | undefined, + roomSourceUrl: string | undefined + ): ReadonlySet { + const homeOderId = currentUser?.oderId || currentUser?.id; + + return resolveSelfPresenceUserIds({ + homeUserId: currentUser?.id, + homeOderId, + actorUserId: homeOderId + ? this.resolveActorUserIdForServer(roomSourceUrl, homeOderId) + : undefined + }); + } + + resolveCredentialForSignalUrl( + signalUrl: string, + homeUser?: Pick | 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 { + 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; + } + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts new file mode 100644 index 0000000..b49691c --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.spec.ts new file mode 100644 index 0000000..ac4b195 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.spec.ts @@ -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(); + + 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'); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.ts new file mode 100644 index 0000000..6babd45 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-credential-store.service.ts @@ -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; + + 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 { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + if (!raw) { + return {}; + } + + const parsed = JSON.parse(raw) as Record; + + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + + private writeStore(store: Record): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } + + private normalizeServerUrl(serverUrl: string): string { + return serverUrl.trim().replace(/\/+$/, ''); + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-provision-notice.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-provision-notice.service.ts new file mode 100644 index 0000000..fb6d618 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-provision-notice.service.ts @@ -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(null); + + publish(notice: SignalServerProvisionNotice): void { + this.notice.set(notice); + } + + clear(): void { + this.notice.set(null); + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts new file mode 100644 index 0000000..e6dad6d --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts @@ -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; + 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(); + + 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(); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts new file mode 100644 index 0000000..7d26ab4 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts @@ -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; + provisionSecret: string; + }): Promise { + 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 { + return firstValueFrom( + this.http.post(`${serverUrl}/api/users/register`, { + username, + password, + displayName + }) + ); + } + + private async login( + serverUrl: string, + username: string, + password: string + ): Promise { + return firstValueFrom( + this.http.post(`${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(/\/+$/, ''); + } +} diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts index d05dc18..873be22 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts @@ -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 = {} +): Record { const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback); + const queryParams: Record = {}; - 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( diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts index 2ec2ab0..3d28dc4 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts @@ -16,13 +16,9 @@ describe('auth-session.rules', () => { } as Pick; 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', () => { diff --git a/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.spec.ts new file mode 100644 index 0000000..f132c43 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.ts new file mode 100644 index 0000000..a95505d --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/self-presence-identity.rules.ts @@ -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 { + const ids = new Set(); + + 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 +): boolean { + return !!userId?.trim() && selfIds.has(userId.trim()); +} diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.spec.ts new file mode 100644 index 0000000..1cceff6 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.spec.ts @@ -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(); + }); +}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.ts new file mode 100644 index 0000000..1f81a29 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-credential-resolution.rules.ts @@ -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; +} diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts new file mode 100644 index 0000000..a1c4149 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts @@ -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']); + }); +}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts new file mode 100644 index 0000000..3242ec2 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts @@ -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)]; +} diff --git a/toju-app/src/app/domains/authentication/domain/models/signal-server-credential.model.ts b/toju-app/src/app/domains/authentication/domain/models/signal-server-credential.model.ts new file mode 100644 index 0000000..e12e950 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/models/signal-server-credential.model.ts @@ -0,0 +1,9 @@ +export interface SignalServerCredential { + serverUrl: string; + userId: string; + username: string; + displayName: string; + token: string; + expiresAt: number; + provisioned: boolean; +} diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.html b/toju-app/src/app/domains/authentication/feature/login/login.component.html index 6088072..8afc675 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.html +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.html @@ -5,9 +5,19 @@ name="lucideLogIn" class="w-5 h-5 text-primary" /> -

{{ 'auth.login.title' | translate }}

+

+ @if (isAuthorizeMode()) { + {{ 'auth.authorize.title' | translate: { serverName: authorizeServerName() } }} + } @else { + {{ 'auth.login.title' | translate }} + } +

+ @if (isAuthorizeMode()) { +

{{ 'auth.authorize.description' | translate }}

+ } +
(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 + }) }); } } diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.html b/toju-app/src/app/domains/authentication/feature/register/register.component.html index 826c2ba..cafc534 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.html +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.html @@ -5,9 +5,19 @@ name="lucideUserPlus" class="w-5 h-5 text-primary" /> -

{{ 'auth.register.title' | translate }}

+

+ @if (isAuthorizeMode()) { + {{ 'auth.authorize.registerTitle' | translate: { serverName: authorizeServerName() } }} + } @else { + {{ 'auth.register.title' | translate }} + } +

+ @if (isAuthorizeMode()) { +

{{ 'auth.authorize.description' | translate }}

+ } + (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 + }) }); } } diff --git a/toju-app/src/app/domains/authentication/index.ts b/toju-app/src/app/domains/authentication/index.ts index e9b5a92..cc262d1 100644 --- a/toju-app/src/app/domains/authentication/index.ts +++ b/toju-app/src/app/domains/authentication/index.ts @@ -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'; diff --git a/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts index d4ac540..45d353e 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts @@ -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; diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts index 5021922..d124cb6 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts @@ -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); }); }); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts index 69c3e2e..e0a656f 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts @@ -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' }, diff --git a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index 1c789f8..7d301b6 100644 --- a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts +++ b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */ +/* eslint-disable @typescript-eslint/member-ordering, */ import { Component, computed, diff --git a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.spec.ts b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.spec.ts index 2eeb96a..583859e 100644 --- a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.spec.ts +++ b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.spec.ts @@ -94,6 +94,7 @@ describe('CustomEmojiPickerComponent', () => { } ] }); + initializeAppI18nForTests(injector); return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent)); diff --git a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts index 29cfce8..5168031 100644 --- a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts +++ b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts @@ -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, diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index 0cccdf0..e7b0037 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -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 { diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index 96d0008..ba05d78 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -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'; diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts index 50a7e21..2237369 100644 --- a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts @@ -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'; diff --git a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts index 508eda6..9ad6191 100644 --- a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts @@ -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' }) diff --git a/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts b/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts index 54a17f4..f47994d 100644 --- a/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts +++ b/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts @@ -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, diff --git a/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts index 075cefb..ec1a750 100644 --- a/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts +++ b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts @@ -232,6 +232,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { } ] }); + initializeAppI18nForTests(injector); const service = runInInjectionContext(injector, () => new GameActivityService()); const context = { diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts index 508d271..b77a583 100644 --- a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts +++ b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts @@ -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, diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts index dba3e80..93a2ac3 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts @@ -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(); }); diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts index ebf87b8..400489a 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts @@ -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 { - if (!entrypointUrl.startsWith('https://')) { + if (!isSecurePluginRemoteUrl(entrypointUrl)) { throw new Error('Remote plugin entrypoints must use HTTPS'); } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts index 5105e23..618b1de 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts @@ -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, responses: Record): void { @@ -312,8 +328,8 @@ function createService( } ] }); - const service = injector.get(PluginStoreService); + injector.get(AppI18nService).initialize(); return service; diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index f510ded..6e6b211 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -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'); } diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts index 9708e74..b6d03ad 100644 --- a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts @@ -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']); }); }); diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts index 6445b95..bf5e5cc 100644 --- a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts @@ -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[] { +export function collectPluginReadRoots(...fileUrls: (string | undefined)[]): string[] { const roots = new Set(); for (const fileUrl of fileUrls) { @@ -34,7 +35,7 @@ export function collectPluginReadRoots(...fileUrls: Array): export async function grantPluginReadRoots( api: Pick | null | undefined, - ...fileUrls: Array + ...fileUrls: (string | undefined)[] ): Promise { if (!api?.grantPluginReadRoot) { return; diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.spec.ts new file mode 100644 index 0000000..411e4e3 --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.ts new file mode 100644 index 0000000..516712b --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-source-url.rules.ts @@ -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); +} diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts index 40c9b03..2ca2e05 100644 --- a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts @@ -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 { diff --git a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts index 79fa92f..fb65796 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts @@ -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; 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 { - 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 { - return this.api.notifyLeave(serverId, userId, selector); + return this.api.notifyLeave(serverId, this.resolveActorUserId(userId, selector), selector); } updateUserCount(serverId: string, count: number): Observable { @@ -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; + } } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts index b991b6c..d37e5f1 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts @@ -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; } diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts index 684a65b..4465fe9 100644 --- a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts @@ -52,6 +52,7 @@ function createHarness(options: HarnessOptions = {}) { ...provideAppI18nForTests() ] }); + initializeAppI18nForTests(injector); const component = runInInjectionContext(injector, () => injector.get(FindServersComponent)); diff --git a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts index f0c4f1b..e87eb28 100644 --- a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts @@ -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(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 { + 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, diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts index fc12c85..6647b9a 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts @@ -114,6 +114,7 @@ function createHarness(options: HarnessOptions = {}) { ...provideAppI18nForTests() ] }); + initializeAppI18nForTests(injector); const component = runInInjectionContext(injector, () => injector.get(ServerBrowserComponent)); diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts index 9d00f42..2116196 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts @@ -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(); 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( { diff --git a/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts b/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts index 836205d..caa67f2 100644 --- a/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts +++ b/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts @@ -28,6 +28,7 @@ describe('ThemeService theme application', () => { useValue: createDocumentStub(styleElements) } ]); + initializeAppI18nForTests(injector); service = injector.get(ThemeService); diff --git a/toju-app/src/app/domains/theme/application/services/theme.service.ts b/toju-app/src/app/domains/theme/application/services/theme.service.ts index ac5dcb7..ea8d999 100644 --- a/toju-app/src/app/domains/theme/application/services/theme.service.ts +++ b/toju-app/src/app/domains/theme/application/services/theme.service.ts @@ -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; } diff --git a/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts b/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts index 0edf3b3..7912909 100644 --- a/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts +++ b/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts @@ -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 { diff --git a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts index 02d8f21..5bd1ca1 100644 --- a/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts +++ b/toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; import type { VoiceState } from '../../../../shared-kernel'; import { isLocalVoiceOwner, diff --git a/toju-app/src/app/features/direct-call/private-call.component.ts b/toju-app/src/app/features/direct-call/private-call.component.ts index 1ba96f7..8db019a 100644 --- a/toju-app/src/app/features/direct-call/private-call.component.ts +++ b/toju-app/src/app/features/direct-call/private-call.component.ts @@ -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'; diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 900501d..2521162 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -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): 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 { diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index b04d909..bc5b2ff 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -280,6 +280,7 @@ export class ServersRailComponent { this.router.navigate(['/login'], { queryParams: buildLoginReturnQueryParams(this.router.url) }); + return; } diff --git a/toju-app/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts index d033eed..aec12a2 100644 --- a/toju-app/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts @@ -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 diff --git a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts index dd45775..910dc50 100644 --- a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts @@ -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, diff --git a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts index 69b840d..976ed60 100644 --- a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts @@ -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, diff --git a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts index 97f07ed..46db928 100644 --- a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts @@ -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', diff --git a/toju-app/src/app/features/settings/settings-modal/ice-server-settings/ice-server-settings.component.html b/toju-app/src/app/features/settings/settings-modal/ice-server-settings/ice-server-settings.component.html index 924c99e..ecf19d5 100644 --- a/toju-app/src/app/features/settings/settings-modal/ice-server-settings/ice-server-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/ice-server-settings/ice-server-settings.component.html @@ -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" > + @if (provisionNotice(); as notice) { +
+ {{ + 'auth.provision.usernameCollision' + | translate + : { + serverName: notice.serverName, + preferredUsername: notice.preferredUsername, + provisionedUsername: notice.provisionedUsername + } + }} + +
+ } +
@for (server of servers(); track server.id) { @@ -70,11 +91,21 @@ @if (server.latency !== undefined && server.status === 'online') {

{{ server.latency }}ms

} +

{{ authStatusKey(server.url) | translate }}

@if (server.status === 'incompatible') {

{{ 'settings.network.serverEndpoints.incompatible' | translate }}

}
+ @if (!signalServerAuth.hasValidCredential(server.url)) { + + } @if (!server.isActive && server.status !== 'incompatible') {