diff --git a/deploy/relay-v1/README.md b/deploy/relay-v1/README.md index 9f085aaf05..324b60e5bc 100644 --- a/deploy/relay-v1/README.md +++ b/deploy/relay-v1/README.md @@ -263,3 +263,44 @@ WantedBy=timers.target is cheap: hourly traffic moves past the window in seconds, versus 455 seconds for the first pass over 2.17 M rows. Pruning and retiring are separate steps — pruning only buys time until the version reaches the retirement switch above. + +## Version-by-query endpoint and the Page origin + +Releases used to be separated by a proxy location (`/v//`). A client +now targets `https://remote.openbitfun.com/relay?v=`: the +version travels as a query parameter, one proxy location serves every build, +and the bare origin is the account controller. `/v/1.0.2/` stays live on the +same process and database for builds that predate the parameter, so both +aliases share credentials and device registration. + +```nginx +location = /relay { return 308 /relay/; } +location ^~ /relay/ { + proxy_pass http://127.0.0.1:19702/; # strip the prefix + proxy_http_version 1.1; + proxy_set_header Host $host; # bare host: the Relay compares it verbatim + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + client_max_body_size 65m; +} +location = / { proxy_pass http://127.0.0.1:19702/; } # the controller SPA +location = /index.html { proxy_pass http://127.0.0.1:19702/index.html; } +location ^~ /assets/ { proxy_pass http://127.0.0.1:19702; } +location ^~ /brand/ { proxy_pass http://127.0.0.1:19702; } +location / { return 410; } # keep unversioned paths closed +``` + +Published Pages must not share a browser origin with the controller: Page +documents execute user-authored JavaScript, and the controller keeps its device +key and Relay credential in that origin's IndexedDB. Serve `/p/` and +`/api/page-auth/` from a separate host (`RELAY_PAGE_PUBLIC_BASE_URL`) and keep +`RELAY_PAGE_AUTH_BASE_URL` on a third origin. Redirect the old path instead of +serving content there: + +```nginx +location ^~ /v/1.0.2/p/ { rewrite ^/v/1\.0\.2(/p/.*)$ https://pages.example.com$1 redirect; } +location ^~ /relay/p/ { rewrite ^/relay(/p/.*)$ https://pages.example.com$1 redirect; } +``` diff --git a/src/apps/cli/src/account_guidance.rs b/src/apps/cli/src/account_guidance.rs index 82beb41b8b..bf44ddcfa6 100644 --- a/src/apps/cli/src/account_guidance.rs +++ b/src/apps/cli/src/account_guidance.rs @@ -147,8 +147,10 @@ mod tests { #[test] fn the_endpoint_variant_prefers_a_retired_official_endpoint() { - let older = DEFAULT_RELAY_URL.replace("/v/1.0.2", "/v/1.0.1"); - let retired = account_failure_guidance_for_endpoint("some generic error", &older); + // `/v/1.0.2` and `/relay` are live aliases of one deployment; any other + // `/v/` path belongs to a retired deployment. + let older = "https://remote.openbitfun.com/v/1.0.1"; + let retired = account_failure_guidance_for_endpoint("some generic error", older); assert_eq!(retired.0, UPDATE_SENTENCE); assert_eq!(retired.1, Some(UPDATE_HINT)); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index 2f73c252f6..8ea37e4ef8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -1,5 +1,5 @@ import { GitHubAccountProfile, GitHubProfileLoader } from '../../services/GitHubAccountProfile'; -import { normalizeAccountRelayUrl, OFFICIAL_RELAY_URL } from '../../services/AccountDeviceLink'; +import { normalizeAccountRelayUrl, OFFICIAL_RELAY_URL, sameRelayDeployment } from '../../services/AccountDeviceLink'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { stampRemoteSessionsDevice } from '../../services/RemoteSessionIdentity'; import { @@ -173,7 +173,7 @@ export class SettingsController { selectAccountRelay(relayUrl: string): void { const endpoint = normalizeAccountRelayUrl(relayUrl); if (!endpoint) throw new Error('Invalid Relay URL'); - if (this.cloudRelayUrl !== endpoint) { + if (!sameRelayDeployment(this.cloudRelayUrl, endpoint)) { this.teardownCloudAccountProjection(this.requireCloud()); this.selectedRelayUrl = endpoint; } @@ -784,7 +784,8 @@ export class SettingsController { if (!persisted) { return; } - if (/^https:\/\/remote\.openbitfun\.com\/v\/[^/]+\/?$/.test(persisted.relayUrl) && persisted.relayUrl.replace(/\/+$/, '') !== OFFICIAL_RELAY_URL) { + if (/^https:\/\/remote\.openbitfun\.com\/v\/[^/]+\/?$/.test(persisted.relayUrl) + && !sameRelayDeployment(persisted.relayUrl, OFFICIAL_RELAY_URL)) { // Relay credentials are database-scoped; never replay an older credential at a new endpoint. this.selectedRelayUrl = OFFICIAL_RELAY_URL; this.cloudRelayUrl = OFFICIAL_RELAY_URL; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets index 2cc9349613..6fc0eece45 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets @@ -1,10 +1,57 @@ -export const OFFICIAL_RELAY_URL: string = 'https://remote.openbitfun.com/v/1.0.2'; +/** Hosted Relay service version announced by the official endpoint. */ +export const RELAY_SERVICE_VERSION: string = '1.0.9'; + +const OFFICIAL_RELAY_ORIGIN: string = 'https://remote.openbitfun.com'; + +/** Paths served by the same deployed Relay and the same account database. + * `?v=` replaced the path segment as the client-facing version, but `/v/1.0.2` + * stays live for builds that predate the change: a token, master key, or device + * registration obtained through either path is interchangeable, because both + * reach one process and one database. Any other `/v/` path is a + * different deployment. */ +const LIVE_RELAY_ENDPOINT_PATHS: string[] = ['/relay', '/v/1.0.2']; + +const VERSION_PATTERN: RegExp = /^[A-Za-z0-9.]{1,32}$/; + +/** Identity of the official Relay: no version query, no fragment, no trailing + * slash. Every path join and every deployment comparison uses this value. */ +export const OFFICIAL_RELAY_URL: string = `${OFFICIAL_RELAY_ORIGIN}/relay`; + +/** The same deployment as a user sees and shares it: the service version + * travels as a GET parameter instead of a path segment, so a release no longer + * needs a proxy location of its own. */ +export const OFFICIAL_RELAY_ENDPOINT: string = `${OFFICIAL_RELAY_URL}?v=${RELAY_SERVICE_VERSION}`; + export interface AccountDeviceLink { relayUrl: string; deviceId: string; } +/** Strip the version parameter and any trailing slash, or explain why not. + * Exactly one `v=` parameter is accepted; every other query parameter + * is rejected so the query can never carry per-request data, and a fragment + * means the value is a page rather than an endpoint. */ +function endpointBase(value: string): string | undefined { + const text = value.trim(); + if (text === '' || text.indexOf('#') >= 0) return undefined; + const mark = text.indexOf('?'); + const base = (mark >= 0 ? text.substring(0, mark) : text).replace(/\/+$/, ''); + if (mark >= 0) { + const pairs = text.substring(mark + 1).split('&'); + if (pairs.length !== 1 || pairs[0].substring(0, 2) !== 'v=' || + !VERSION_PATTERN.test(pairs[0].substring(2))) return undefined; + } + return base; +} + +function officialPath(value: string): string { + return value.startsWith(OFFICIAL_RELAY_ORIGIN) ? value.substring(OFFICIAL_RELAY_ORIGIN.length) : ''; +} + export function normalizeAccountRelayUrl(value: string): string { - const normalized = value.trim().replace(/\/+$/, ''); - if (normalized === OFFICIAL_RELAY_URL) return normalized; - const match = /^(https?:\/\/)(localhost|\[[0-9a-fA-F:]+\]|[0-9.]+)(?::([0-9]{1,5}))?$/.exec(normalized); + const base = endpointBase(value); + if (!base) return ''; + // Both live aliases of the official host answer as one deployment, so an + // endpoint stored by an older build still resolves to today's identity. + if (LIVE_RELAY_ENDPOINT_PATHS.indexOf(officialPath(base)) >= 0) return OFFICIAL_RELAY_URL; + const match = /^(https?:\/\/)(localhost|\[[0-9a-fA-F:]+\]|[0-9.]+)(?::([0-9]{1,5}))?$/.exec(base); if (!match) return ''; if (match[3] && (Number(match[3]) < 1 || Number(match[3]) > 65535)) return ''; const host = match[2].toLowerCase(); @@ -14,7 +61,33 @@ export function normalizeAccountRelayUrl(value: string): string { (octets.length === 4 && octets.every((part: number): boolean => Number.isInteger(part) && part >= 0 && part <= 255) && (octets[0] === 10 || octets[0] === 127 || (octets[0] === 192 && octets[1] === 168) || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 169 && octets[1] === 254))); - return local ? normalized : ''; + return local ? base : ''; +} + +interface RelayEndpointIdentity { origin: string; path: string; } + +function endpointIdentity(value: string): RelayEndpointIdentity | undefined { + const base = endpointBase(value); + if (!base) return undefined; + const match = /^(https?):\/\/(\[[0-9a-fA-F:]+\]|[^/?#:@]+)(?::([0-9]{1,5}))?([^?#]*)$/.exec(base); + if (!match) return undefined; + const scheme = match[1].toLowerCase(); + const port = match[3] ? Number(match[3]) : (scheme === 'https' ? 443 : 80); + if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined; + return { origin: `${scheme}://${match[2].toLowerCase()}:${port}`, path: match[4] }; +} + +/** Whether two endpoints resolve to the same deployed Relay. + * This is the comparison behind reusing a stored token or master key: both live + * aliases of one host count as one deployment, so a build that moves from + * `/v/1.0.2` to `/relay?v=` keeps its credential, while any other versioned + * path is a different or retired deployment. */ +export function sameRelayDeployment(left: string, right: string): boolean { + const a = endpointIdentity(left); + const b = endpointIdentity(right); + if (!a || !b || a.origin !== b.origin) return false; + return a.path === b.path || + (LIVE_RELAY_ENDPOINT_PATHS.indexOf(a.path) >= 0 && LIVE_RELAY_ENDPOINT_PATHS.indexOf(b.path) >= 0); } /** Endpoint and target are hints; the account directory is the authority. */ diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 7a972dd6bf..a8aa718d2d 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1,6 +1,13 @@ import { ReadFileChunkResult } from '../main/ets/model/RemoteModels'; import { GitHubAccountProfile, GitHubProfileLoader, normalizeGitHubProfile, readCachedGitHubProfile } from '../main/ets/services/GitHubAccountProfile'; -import { accountDeviceLink } from '../main/ets/services/AccountDeviceLink'; +import { + accountDeviceLink, + normalizeAccountRelayUrl, + OFFICIAL_RELAY_ENDPOINT, + OFFICIAL_RELAY_URL, + RELAY_SERVICE_VERSION, + sameRelayDeployment +} from '../main/ets/services/AccountDeviceLink'; import { describe, it, expect } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; @@ -606,18 +613,46 @@ export default function transportAndGeneralChatUnitTest() { }); it('uses the same device selector for official and LAN endpoints', 0, () => { - for (const endpoint of ['https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) { + for (const endpoint of [OFFICIAL_RELAY_URL, 'http://192.168.1.10:9700']) { const parsed = accountDeviceLink(`${endpoint}/#/pair?did=desktop-1`); expect(parsed?.deviceId).assertEqual('desktop-1'); expect(parsed?.relayUrl).assertEqual(endpoint); } }); + it('resolves a versioned link to the endpoint base', 0, () => { + // A link the user opens carries the service version as a GET parameter + // before the fragment; the endpoint it resolves to is the base, so no join + // can ever produce `...?v=1.0.9/api/...`. + expect(OFFICIAL_RELAY_ENDPOINT).assertEqual(`${OFFICIAL_RELAY_URL}?v=${RELAY_SERVICE_VERSION}`); + const versioned = accountDeviceLink(`${OFFICIAL_RELAY_ENDPOINT}/#/pair?did=desktop-1`); + expect(versioned?.deviceId).assertEqual('desktop-1'); + expect(versioned?.relayUrl).assertEqual(OFFICIAL_RELAY_URL); + expect(normalizeAccountRelayUrl(OFFICIAL_RELAY_ENDPOINT)).assertEqual(OFFICIAL_RELAY_URL); + // Exactly one `v` may ride along: every other parameter is not an endpoint. + expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?v=1.0.9&relay=evil`)).assertEqual(''); + expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?version=1.0.9`)).assertEqual(''); + expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?v=1.0.9#/pair?did=desktop-1`)).assertEqual(''); + }); + it('keeps the older official path interchangeable with the current one', 0, () => { + const legacy = accountDeviceLink('https://remote.openbitfun.com/v/1.0.2/#/pair?did=desktop-1'); + expect(legacy?.deviceId).assertEqual('desktop-1'); + expect(legacy?.relayUrl).assertEqual(OFFICIAL_RELAY_URL); + // Both paths reach one process and one account database, so a stored token + // or master key from either stays usable. + expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.2', OFFICIAL_RELAY_URL)).assertTrue(); + expect(sameRelayDeployment(OFFICIAL_RELAY_ENDPOINT, OFFICIAL_RELAY_URL)).assertTrue(); + expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.0', OFFICIAL_RELAY_URL)).assertFalse(); + expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.0', 'https://remote.openbitfun.com/v/1.0.2')).assertFalse(); + expect(sameRelayDeployment('http://remote.openbitfun.com/relay', OFFICIAL_RELAY_URL)).assertFalse(); + expect(sameRelayDeployment('http://192.168.1.10:9700/', 'http://192.168.1.10:9700')).assertTrue(); + }); it('rejects credentials, legacy rooms, duplicate targets and public lookalikes', 0, () => { - const prefix = 'https://remote.openbitfun.com/v/1.0.2/#/pair?'; + const prefix = `${OFFICIAL_RELAY_ENDPOINT}/#/pair?`; for (const suffix of ['did=desktop-1&pk=untrusted', 'did=desktop-1&relay=evil', 'did=desktop-1&did=desktop-2', 'did=%00', 'did=%ZZ', 'room=old&pk=old']) { expect(accountDeviceLink(prefix + suffix)).assertUndefined(); } + expect(accountDeviceLink('https://evil.test/relay/#/pair?did=desktop-1')).assertUndefined(); expect(accountDeviceLink('https://evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined(); expect(accountDeviceLink('https://remote.openbitfun.com@evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined(); }); @@ -656,7 +691,9 @@ export default function transportAndGeneralChatUnitTest() { describe('RemoteSessionManager account connection', () => { it('loads account device sessions through either relay endpoint', 0, async () => { const manager = new RemoteSessionManager(); - for (const endpoint of ['https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) { + // The versioned official endpoint and the older live path both address the + // one deployment, next to a LAN relay that never carries a version. + for (const endpoint of [OFFICIAL_RELAY_ENDPOINT, 'https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) { const result = await manager.connectAccountDevice(new RecordingAccountDeviceClient(), endpoint, { token: 'token', userId: 'account-user', masterKey: new Uint8Array(32) }, 'desktop-device'); expect(result.workspace.path).assertEqual('/account-workspace'); diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt index 00020a2314..982a5585bb 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt @@ -1,6 +1,7 @@ package com.openbitfun.mobile.core.feature.account import com.openbitfun.mobile.core.transport.accountDeviceLink +import com.openbitfun.mobile.core.transport.sameRelayDeployment public enum class AccountDeviceLinkStatus { INVALID, SIGN_IN_REQUIRED, UNAVAILABLE, READY } @@ -13,7 +14,7 @@ public fun resolveAccountDeviceLink(url: String, state: AccountUiState): Account val link = accountDeviceLink(url) ?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.INVALID, null) val id = link.deviceId - val ready = (state as? AccountUiState.Ready)?.takeIf { it.relayUrl == link.relayUrl } + val ready = (state as? AccountUiState.Ready)?.takeIf { sameRelayDeployment(it.relayUrl, link.relayUrl) } ?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, id, link.relayUrl) // A scanned target must never resolve to a device the Relay confirmed is // incompatible; the link reads as unavailable, the same as offline. diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt index 590dcc438e..7c126343fe 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt @@ -254,20 +254,32 @@ public class AccountStore internal constructor( ) return@launch } - if (restored.relayUrl.trimEnd('/').startsWith("https://remote.openbitfun.com/v/") && - restored.relayUrl.trimEnd('/') != AccountDefaults.CLOUD_RELAY_URL) { - // Relay tokens belong to their issuing database. Keep the old encrypted - // record/cache, but require a fresh login before using the new endpoint. - selectedRelayUrl = AccountDefaults.CLOUD_RELAY_URL + val storedRelay = restored.relayUrl.trimEnd('/') + val currentRelay = AccountDefaults.CLOUD_RELAY_URL + // Relay tokens belong to their issuing database. `/relay` and the older + // `/v/1.0.2` are live aliases of one deployment and one database, so a + // credential saved against either keeps working; only some other + // versioned path means a relay this build must sign in against again. + if (storedRelay.startsWith("https://remote.openbitfun.com/v/") && + !com.openbitfun.mobile.core.transport.sameRelayDeployment(storedRelay, currentRelay)) { + // Keep the old encrypted record/cache, but require a fresh login + // before using the new endpoint. + selectedRelayUrl = currentRelay expireSession(AccountFailureReason.AUTHENTICATION, AccountFailureStage.AUTHENTICATION) return@launch } - session = restored - selectedRelayUrl = restored.relayUrl + // Store today's endpoint identity: a session saved against a live alias + // or against the versioned official endpoint is joined and compared on + // the bare base, so the query can never reach a path. + val current = if (com.openbitfun.mobile.core.transport.sameRelayDeployment(storedRelay, currentRelay)) { + restored.copy(relayUrl = currentRelay) + } else restored + session = current + selectedRelayUrl = current.relayUrl // Local credential restoration and remote directory availability are // separate facts. Publish identity before any network wait, but grant // no target authority until an authenticated membership snapshot arrives. - publishReady(restored, emptyList(), selectionConfirmed = false) + publishReady(current, emptyList(), selectionConfirmed = false) refreshDevices() } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt index 658fdcbc8e..3026412272 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt @@ -8,13 +8,14 @@ class AccountDeviceLinkTest { @Test fun explicitLegacyConstructorsPreserveTheExistingDefaults() { assertNull(AccountDeviceLinkResult(AccountDeviceLinkStatus.INVALID, null).relayUrl) val legacy = AccountUiState.Ready("user", "name", emptyList(), null, null) - assertEquals("https://remote.openbitfun.com/v/1.0.2", legacy.relayUrl) + assertEquals("https://remote.openbitfun.com/relay", legacy.relayUrl) assertEquals(false, legacy.refreshing) assertNull(legacy.refreshFailure) } - private val link = "https://remote.openbitfun.com/v/1.0.2/#/pair?did=desktop-1" - private val ready = AccountUiState.Ready(userId = "user", username = "name", relayUrl = "https://remote.openbitfun.com/v/1.0.2", devices = listOf(AccountDeviceUi("desktop-1", "Desktop", true, null)), selectedDeviceId = null, selectedDeviceName = null) + private val endpoint = "https://remote.openbitfun.com/relay" + private val link = "$endpoint/#/pair?did=desktop-1" + private val ready = AccountUiState.Ready(userId = "user", username = "name", relayUrl = endpoint, devices = listOf(AccountDeviceUi("desktop-1", "Desktop", true, null)), selectedDeviceId = null, selectedDeviceName = null) @Test fun onlyAuthenticatedOnlineMembershipCanResolveTheTarget() { assertEquals(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, resolveAccountDeviceLink(link, AccountUiState.SignedOut).status) @@ -34,11 +35,16 @@ class AccountDeviceLinkTest { resolveAccountDeviceLink(localLink.replace("desktop-1", "foreign"), ready.copy(relayUrl = endpoint)).status) } - @Test fun rejectsLegacyAndLookalikeLinksWithoutUsingTheirRoutingFields() { + @Test fun rejectsLookalikeEndpointsAndUnexpectedRoutingFields() { for (invalid in listOf( link.replace("https:", "http:"), link.replace("remote.openbitfun.com", "evil.example"), link.replace("remote.openbitfun.com", "user@remote.openbitfun.com"), - link.replace("/v/1.0.2/", "/relay/"), link + "&did=foreign", link + "&pk=untrusted", link + "&relay=evil", link.replace("did=desktop-1", "room=old"), + // Another host's `/relay` is not the official deployment. + link.replace("https://remote.openbitfun.com", "https://remote.openbitfun.com.evil.example"), + // A query on the endpoint may only ever carry the service version. + link.replace("/#/pair?did=", "/?v=1.0.9&relay=evil#/pair?did="), + link.replace("/#/pair?did=", "/?version=1.0.9#/pair?did="), + link + "&did=foreign", link + "&pk=untrusted", link + "&relay=evil", link.replace("did=desktop-1", "room=old"), link.replace("did=desktop-1", "did=../bad"), )) { val result = resolveAccountDeviceLink(invalid, ready) diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt index 0fba19fda1..8e4585935c 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -229,6 +229,37 @@ class AccountStoreTest { assertIs(restored.state.value) } + /** + * `/relay` and the older `/v/1.0.2` are one deployment and one account + * database, so a record saved before the version moved into the query keeps + * its token and master key — and is stored against today's identity, which + * carries no version of its own. + */ + @Test fun liveRelayAliasesKeepTheStoredTokenAndMasterKey() = runTest { + val secure = MemorySecureStore(); val backend = FakeAccountBackend() + val first = AccountStore.create(this, backend, secure, "phone-1", "Android") + first.dispatch(AccountIntent.Login); advanceUntilIdle() + val pristine = secure.read("github_device_session_v1")!!.decodeToString() + for (alias in listOf( + "https://remote.openbitfun.com/v/1.0.2", + AccountDefaults.CLOUD_RELAY_URL, + "https://remote.openbitfun.com/relay?v=1.0.9", + )) { + val stored = pristine.replace(AccountDefaults.CLOUD_RELAY_URL, alias) + secure.write("github_device_session_v1", stored.encodeToByteArray()) + val requests = backend.listRequests + val restored = AccountStore.create(this, backend, secure, "phone-1", "Android") + restored.dispatch(AccountIntent.Restore); advanceUntilIdle() + val state = assertIs(restored.state.value) + assertEquals(AccountDefaults.CLOUD_RELAY_URL, state.relayUrl, alias) + // The directory was read with the retained credential, not after + // another sign-in, and the stored bytes are left alone. + assertEquals(requests + 1, backend.listRequests, alias) + assertEquals(AccountDefaults.CLOUD_RELAY_URL, backend.lastLoginRelay, alias) + assertEquals(stored, secure.read("github_device_session_v1")!!.decodeToString(), alias) + } + } + @Test fun enrichesExistingAccountAndRetainsIdentityAndCacheOffline() = runTest { val secure = MemorySecureStore() @@ -824,8 +855,10 @@ private class FakeAccountBackend : AccountBackend { lastLoginRelay = relayUrl loginThrowable?.let { throw it } failure?.let { throw CloudAccountException(it) } + // Mirrors the live backend: the session keeps the endpoint it signed in + // against, which the store only ever hands over as a base URL. return AccountSessionData( - "https://remote.openbitfun.com/v/1.0.2", + relayUrl, "user", "token", userId, diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatCloudConfigPolicyTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatCloudConfigPolicyTest.kt index 22309db781..8b8ea804e7 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatCloudConfigPolicyTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatCloudConfigPolicyTest.kt @@ -64,7 +64,7 @@ class GeneralChatCloudConfigPolicyTest { payload( model("a", "anthropic", "sonnet", "https://api.anthropic.com/", "sk"), model("b", "anthropic", "sonnet", "https://api.anthropic.com/v1/messages", "sk"), - model("c", "anthropic", "sonnet", "https://remote.openbitfun.com/relay", "sk"), + model("c", "anthropic", "sonnet", "https://remote.openbitfun.com/relay?v=1.0.9", "sk"), model("d", "openai", "gpt", "https://api.openai.com/", "sk"), ), ) @@ -73,7 +73,9 @@ class GeneralChatCloudConfigPolicyTest { listOf( "https://api.anthropic.com/v1/messages", "https://api.anthropic.com/v1/messages", - "https://remote.openbitfun.com/relay", + // The version travels as a GET parameter, so no path may ever be + // appended after it: the relay answers its own route. + "https://remote.openbitfun.com/relay?v=1.0.9", "https://api.openai.com", ), models.map { it.apiUrl }, diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt index 93119adb4c..ed1d3c1be0 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt @@ -7,9 +7,21 @@ public data class AccountDeviceLink(public val relayUrl: String, public val devi public fun normalizeAccountRelayUrl(value: String): String? = runCatching { val url = Url(value.trim()) if (url.protocol.name !in setOf("http", "https") || url.user != null || url.password != null || - url.parameters.names().isNotEmpty() || url.fragment.isNotEmpty()) return null - val normalized = url.toString().trimEnd('/') + url.fragment.isNotEmpty()) return null + // The endpoint may announce its service version as a single `v` parameter. + // Any other query content is rejected: the query must never become a + // channel for per-request data. + val names = url.parameters.names() + if (names.isNotEmpty()) { + val versions = url.parameters.getAll("v").orEmpty() + if (names != setOf("v") || versions.size != 1 || + !Regex("[A-Za-z0-9.-]{1,32}").matches(versions.single())) return null + } + val normalized = url.toString().substringBefore('?').trimEnd('/') if (normalized == DEFAULT_CLOUD_RELAY_URL) return normalized + // `/v/1.0.2` and `/relay` are one deployment, so a stored credential or + // invitation from either alias keeps working and resolves to the base. + if (sameRelayDeployment(normalized, DEFAULT_CLOUD_RELAY_URL)) return DEFAULT_CLOUD_RELAY_URL val host = url.host.removeSurrounding("[", "]").lowercase() val octets = host.split('.').mapNotNull(String::toIntOrNull) val local = host == "localhost" || host == "::1" || @@ -24,7 +36,7 @@ public fun normalizeAccountRelayUrl(value: String): String? = runCatching { public fun accountDeviceLink(value: String): AccountDeviceLink? = runCatching { if (value.length > 8192) return null val url = Url(value.trim()) - if (url.parameters.names().isNotEmpty() || !url.fragment.startsWith("/pair?")) return null + if (!url.fragment.startsWith("/pair?")) return null val endpoint = normalizeAccountRelayUrl(value.trim().substringBefore('#')) ?: return null val query = Url("https://localhost/?" + url.fragment.removePrefix("/pair?")) if (query.parameters.names() != setOf("did")) return null diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt index 678e8d4222..54535db6e9 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt @@ -54,7 +54,40 @@ import kotlin.uuid.Uuid import kotlin.uuid.ExperimentalUuidApi import kotlin.time.TimeSource -public const val DEFAULT_CLOUD_RELAY_URL: String = "https://remote.openbitfun.com/v/1.0.2" +/** Hosted Relay service version announced by the official endpoint. */ +public const val RELAY_SERVICE_VERSION: String = "1.0.9" + +/** + * Identity of the official Relay: no version query, no fragment, no trailing + * slash. + * + * Every API and socket path is joined onto this, and a stored token or master + * key is compared against it. The version parameter belongs to + * [OFFICIAL_CLOUD_RELAY_URL] only, so it can never reach a path. + */ +public const val DEFAULT_CLOUD_RELAY_URL: String = "https://remote.openbitfun.com/relay" + +/** + * The same deployment as a user sees and shares it: the service version travels + * as a GET parameter instead of a path segment, so a release no longer needs a + * proxy location of its own. + */ +public const val OFFICIAL_CLOUD_RELAY_URL: String = "$DEFAULT_CLOUD_RELAY_URL?v=$RELAY_SERVICE_VERSION" + +/** + * Paths served by the same deployed Relay and the same account database. + * + * `?v=` replaced the path segment as the client-facing version, but `/v/1.0.2` + * stays live for builds that predate the change: a token, master key, or device + * registration obtained through either path is interchangeable, because both + * reach one process and one database. Any other `/v/` path is a + * different deployment. + */ +private val LIVE_RELAY_ENDPOINT_PATHS: Set = setOf("/relay", "/v/1.0.2") + +/** The only query parameter a Relay endpoint may carry, and its value shape. */ +private const val RELAY_VERSION_PARAMETER = "v" +private val RELAY_VERSION_VALUE = Regex("[A-Za-z0-9.]{1,32}") /** A relayed stream page slower than this is worth a breadcrumb; faster ones are not. */ internal const val SLOW_STREAM_PAGE_MS: Long = 300L @@ -697,6 +730,41 @@ private fun statusFailure(status: Int): CloudAccountException = when (status) { else -> CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE, status) } +/** The server an endpoint names, and the path it names on that server. */ +private class RelayEndpointIdentity(val origin: String, val path: String) + +/** + * Whether two Relay URLs resolve to the same deployed Relay. + * + * This is the comparison behind reusing a stored token or master key: both live + * aliases of one host count as one deployment, so a build that moves from + * `/v/1.0.2` to `/relay?v=` keeps its credential, while any other versioned + * path is a different or retired deployment. Exactly one `v=` + * parameter is allowed and it is never part of the identity; a fragment makes + * the value not an endpoint at all. + */ +public fun sameRelayDeployment(left: String?, right: String?): Boolean { + val a = relayEndpointIdentity(left) ?: return false + val b = relayEndpointIdentity(right) ?: return false + if (a.origin != b.origin) return false + return a.path == b.path || (a.path in LIVE_RELAY_ENDPOINT_PATHS && b.path in LIVE_RELAY_ENDPOINT_PATHS) +} + +private fun relayEndpointIdentity(value: String?): RelayEndpointIdentity? { + val text = value?.trim().orEmpty() + if (text.isEmpty()) return null + val url = runCatching { io.ktor.http.Url(text) }.getOrNull() ?: return null + if (url.protocol.name !in setOf("http", "https") || url.user != null || url.password != null || + url.fragment.isNotEmpty()) return null + if (url.parameters.names().isNotEmpty()) { + val versions = url.parameters.getAll(RELAY_VERSION_PARAMETER).orEmpty() + if (url.parameters.names() != setOf(RELAY_VERSION_PARAMETER) || versions.size != 1 || + !RELAY_VERSION_VALUE.matches(versions.single())) return null + } + val port = url.port.takeIf { it != url.protocol.defaultPort }?.let { ":$it" }.orEmpty() + return RelayEndpointIdentity(url.protocol.name + "://" + url.host.lowercase() + port, url.encodedPath.trimEnd('/')) +} + private fun encodePathSegment(value: String): String = value.encodeToByteArray().joinToString("") { byte -> val unsigned = byte.toInt() and 0xff val character = unsigned.toChar() diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt index 7555c9ab95..5d15699098 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt @@ -81,7 +81,7 @@ class CloudAccountClientTest { fun githubLoginRegistersOnlyThePublicDeviceKey() = runTest { val bodies = mutableListOf() val engine = MockEngine { request -> - assertEquals("https://remote.openbitfun.com/v/1.0.2/api/auth/login", request.url.toString()) + assertEquals("https://remote.openbitfun.com/relay/api/auth/login", request.url.toString()) bodies += RelayJson.parseToJsonElement(request.text()).jsonObject json("""{"token":"token-1","user_id":"123"}""") } @@ -99,6 +99,35 @@ class CloudAccountClientTest { assertEquals(CLIENT_PROTOCOL_VERSION, bodies[0]["clientProtocol"]?.jsonPrimitive?.int) } + /** + * The service version rides along as a GET parameter, while the identity a + * credential is stored, compared, and joined against stays the bare base. + */ + @Test + fun theOfficialEndpointCarriesTheVersionWithoutTakingItIntoAnIdentity() { + assertEquals("1.0.9", RELAY_SERVICE_VERSION) + assertEquals("https://remote.openbitfun.com/relay", DEFAULT_CLOUD_RELAY_URL) + assertEquals("https://remote.openbitfun.com/relay?v=1.0.9", OFFICIAL_CLOUD_RELAY_URL) + // One deployment answers on both live paths, so a token or master key + // saved by either build keeps working across the change. + assertTrue(sameRelayDeployment(DEFAULT_CLOUD_RELAY_URL, OFFICIAL_CLOUD_RELAY_URL)) + assertTrue(sameRelayDeployment("https://remote.openbitfun.com/v/1.0.2/", DEFAULT_CLOUD_RELAY_URL)) + assertTrue(sameRelayDeployment("https://remote.openbitfun.com/v/1.0.2", OFFICIAL_CLOUD_RELAY_URL)) + // Any other versioned path is another deployment, and a value that is not + // a plain endpoint is never the same deployment as anything. + assertFalse(sameRelayDeployment("https://remote.openbitfun.com/v/1.0.0", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment("https://remote.openbitfun.com/v/1.0.0", "https://remote.openbitfun.com/v/1.0.2")) + assertFalse(sameRelayDeployment("http://remote.openbitfun.com/relay", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment("https://remote.openbitfun.com/relay?x=1", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment("https://remote.openbitfun.com/relay?v=1.0.9&x=1", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment("https://remote.openbitfun.com/relay#/pair?did=d", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment("https://user@remote.openbitfun.com/relay", DEFAULT_CLOUD_RELAY_URL)) + assertFalse(sameRelayDeployment(null, DEFAULT_CLOUD_RELAY_URL)) + // A LAN relay is compared exactly, as it always was. + assertTrue(sameRelayDeployment("http://192.168.1.10:9700/", "http://192.168.1.10:9700")) + assertFalse(sameRelayDeployment("http://192.168.1.10:9700", "http://192.168.1.11:9700")) + } + @Test fun deviceDirectoryCarriesTheRelayCompatibilityVerdict() = runTest { val engine = MockEngine { _ -> diff --git a/src/apps/relay-server/FAQ.md b/src/apps/relay-server/FAQ.md index 5e3856b807..d82d049368 100644 --- a/src/apps/relay-server/FAQ.md +++ b/src/apps/relay-server/FAQ.md @@ -7,7 +7,10 @@ No. Released clients use the official Relay for same-account device control. Sign in on the host and controller with the same OpenBitFun account and follow the Remote Connect invitation or device-selection flow. Keep the host running -and reachable. The official endpoint is `https://remote.openbitfun.com/v/1.0.2`; +and reachable. The official endpoint is `https://remote.openbitfun.com/relay?v=1.0.9`; +the version travels as a query parameter, and the older +`https://remote.openbitfun.com/v/1.0.2/` path keeps working against the same +account database; the path is a service-protocol prefix, not the current desktop release number. | Connection | What you need | diff --git a/src/apps/relay-server/FAQ.zh-CN.md b/src/apps/relay-server/FAQ.zh-CN.md index be63b55e93..b28951788a 100644 --- a/src/apps/relay-server/FAQ.zh-CN.md +++ b/src/apps/relay-server/FAQ.zh-CN.md @@ -6,7 +6,9 @@ 不需要。发布版客户端默认通过官方 Relay 实现同账号设备互控。在宿主与控制端 登录同一个 OpenBitFun 账号,按 Remote Connect 的邀请或设备选择流程连接即可。 -宿主需要保持运行且可连接。官方端点为 `https://remote.openbitfun.com/v/1.0.2`, +宿主需要保持运行且可连接。官方端点为 `https://remote.openbitfun.com/relay?v=1.0.9`, +版本号通过查询参数传递;旧的 `https://remote.openbitfun.com/v/1.0.2/` 路径仍然可用, +两者共用同一个账号数据库; 路径中的版本是服务协议前缀,不代表当前桌面客户端版本。 | 连接方式 | 使用条件 | diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 3afc520574..9a38851c9f 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -10,7 +10,12 @@ deploy a server. Older clients can remain on the GitHub-only authorization flow. For connection choices, internet requirements, privacy, service limits, and self-hosting, see the user FAQ: [English](FAQ.md) / [中文](FAQ.zh-CN.md). -The official endpoint is `https://remote.openbitfun.com/v/1.0.2`. This release is deployed with +The official endpoint is `https://remote.openbitfun.com/relay?v=1.0.9`: the +service version travels as a `v` query parameter so a release no longer needs +its own proxy location, and the bare origin is the account controller. The +older `https://remote.openbitfun.com/v/1.0.2/` path stays live on the same +process and database for builds that predate the parameter, so both aliases +share credentials and device registration. This release is deployed with its own process, database, assets, and reverse-proxy location. An existing `/relay` deployment remains on its existing binary and data directory. @@ -55,7 +60,7 @@ Relay URL. A private Relay therefore needs a matching client build. and `src/mobile-web/src/services/pairingLink.ts`. Native clients have matching constants in KMP `core-transport/AccountDeviceLink.kt` and HarmonyOS `services/AccountDeviceLink.ets`; update the HarmonyOS account-link parser too. - Search for `https://remote.openbitfun.com/v/1.0.2` to verify every runtime + Search for `https://remote.openbitfun.com/relay?v=` to verify every runtime reference and corresponding test before building your distribution. 3. Decide who owns identity. You can retain the official OpenBitFun identity authority, or run the [shared identity service](../../../deploy/miniapp-market/README.md) diff --git a/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs b/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs index 2f78f7bab4..fb086356ab 100644 --- a/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs +++ b/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs @@ -255,7 +255,10 @@ async fn official_and_lan_invitations_use_the_same_authenticated_device_protocol let result = service.start(method.clone()).await.unwrap(); assert_eq!( result.qr_url, - Some(format!("{url}/#/pair?did=authenticated-host-1")) + Some(openbitfun_product_domains::account::relay_page_url( + &url, + "#/pair?did=authenticated-host-1" + )) ); assert!(result .qr_data diff --git a/src/crates/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index f8eaad7e3b..b92e3574b3 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -2,8 +2,106 @@ use serde::{Deserialize, Serialize}; -/// Versioned hosted Relay deployment for the GitHub account/device-key protocol. -pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/v/1.0.2"; +/// Hosted Relay service version announced by the official endpoint. +/// +/// This is the *service* release the client targets, not the product build +/// version. Keep it in sync with `relay-service/Cargo.toml`. +pub const RELAY_SERVICE_VERSION: &str = "1.0.9"; + +/// Endpoint paths served by the same deployed Relay and the same account +/// database. `?v=` replaced the path segment as the client-facing version, but +/// `/v/1.0.2` must keep working for builds that predate the change: a token, +/// master key, or device registration from either path is interchangeable +/// because both reach one process and one SQLite file. +pub const LIVE_RELAY_ENDPOINT_PATHS: &[&str] = &["/relay", "/v/1.0.2"]; + +/// Hosted Relay deployment for the GitHub account/device-key protocol. +/// +/// The version travels as a GET parameter so a release no longer needs a new +/// proxy location, while `remote.openbitfun.com` keeps one root page. Callers +/// must join API paths onto [`relay_endpoint_base`], never onto this string. +pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/relay?v=1.0.9"; + +/// Strip the version query and any fragment so the endpoint can be joined with +/// an API path. A trailing slash is removed so `{base}/api/...` stays correct. +pub fn relay_endpoint_base(endpoint: &str) -> &str { + let endpoint = endpoint.trim(); + let end = endpoint + .find(['?', '#']) + .unwrap_or(endpoint.len()); + endpoint[..end].trim_end_matches('/') +} + +/// Service version announced by the endpoint's `?v=` parameter, if any. +/// +/// Only the first `v` parameter of the query is considered. Callers still +/// validate the value; this helper only extracts it. +pub fn relay_endpoint_version(endpoint: &str) -> Option<&str> { + let query = endpoint.split_once('?')?.1; + let query = query.split('#').next().unwrap_or(query); + query + .split('&') + .find_map(|pair| pair.strip_prefix("v=")) + .filter(|value| !value.is_empty()) +} + +fn endpoint_authority(endpoint: &str) -> Option<(&str, &str)> { + let (scheme, rest) = endpoint.split_once("://")?; + if !matches!(scheme, "http" | "https") { + return None; + } + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if authority.is_empty() || authority.contains('@') { + return None; + } + Some((scheme, authority)) +} + +/// Path portion of an endpoint, without query, fragment, or trailing slash. +fn endpoint_path(endpoint: &str) -> &str { + let Some((_, rest)) = endpoint.trim().split_once("://") else { + return ""; + }; + let rest = match rest.find(['?', '#']) { + Some(end) => &rest[..end], + None => rest, + }; + match rest.find('/') { + Some(index) => rest[index..].trim_end_matches('/'), + None => "", + } +} + +/// Whether two endpoints resolve to the same deployed Relay. +/// +/// Both live aliases of the same host count as one deployment, so a build that +/// switches from `/v/1.0.2` to `/relay?v=` keeps its stored credential and +/// master key. A different host, or a different `/v/` path, does not. +pub fn same_relay_deployment(left: &str, right: &str) -> bool { + let (Some((left_scheme, left_authority)), Some((right_scheme, right_authority))) = + (endpoint_authority(left), endpoint_authority(right)) + else { + return false; + }; + if left_scheme != right_scheme || !left_authority.eq_ignore_ascii_case(right_authority) { + return false; + } + let (left_path, right_path) = (endpoint_path(left), endpoint_path(right)); + left_path == right_path + || (LIVE_RELAY_ENDPOINT_PATHS.contains(&left_path) + && LIVE_RELAY_ENDPOINT_PATHS.contains(&right_path)) +} + +/// User-facing page URL for an endpoint, keeping `?v=` before the fragment so +/// the version survives in the address bar and in shared links. +pub fn relay_page_url(endpoint: &str, fragment: &str) -> String { + let base = relay_endpoint_base(endpoint); + match relay_endpoint_version(endpoint) { + Some(version) => format!("{base}/?v={version}{fragment}"), + None => format!("{base}/{fragment}"), + } +} /// Control-contract protocol number reported to the Relay alongside the build /// string when a device registers or opens its realtime connection. @@ -254,3 +352,80 @@ mod identity_tests { assert!(invalid.identity_id().is_none()); } } + +#[cfg(test)] +mod relay_endpoint_tests { + use super::*; + + #[test] + fn base_strips_version_query_and_fragment() { + assert_eq!( + relay_endpoint_base("https://remote.openbitfun.com/relay?v=1.0.9"), + "https://remote.openbitfun.com/relay" + ); + assert_eq!( + relay_endpoint_base("https://remote.openbitfun.com/relay/?v=1.0.9"), + "https://remote.openbitfun.com/relay" + ); + assert_eq!( + relay_endpoint_base("https://remote.openbitfun.com/v/1.0.2/"), + "https://remote.openbitfun.com/v/1.0.2" + ); + assert_eq!( + relay_endpoint_base("https://relay.example.com/prefix#/pair"), + "https://relay.example.com/prefix" + ); + } + + #[test] + fn version_is_read_from_the_query_only() { + assert_eq!( + relay_endpoint_version("https://remote.openbitfun.com/relay?v=1.0.9"), + Some("1.0.9") + ); + assert_eq!( + relay_endpoint_version("https://remote.openbitfun.com/relay?v=1.0.9#/pair?did=x"), + Some("1.0.9") + ); + assert_eq!( + relay_endpoint_version("https://remote.openbitfun.com/v/1.0.2"), + None + ); + } + + #[test] + fn live_aliases_share_one_deployment_but_other_releases_do_not() { + assert!(same_relay_deployment( + "https://remote.openbitfun.com/relay?v=1.0.9", + "https://remote.openbitfun.com/v/1.0.2", + )); + assert!(same_relay_deployment( + "https://remote.openbitfun.com/relay", + "https://remote.openbitfun.com/relay/", + )); + assert!(!same_relay_deployment( + "https://remote.openbitfun.com/v/1.0.1", + "https://remote.openbitfun.com/relay", + )); + assert!(!same_relay_deployment( + "https://other.example.com/relay", + "https://remote.openbitfun.com/relay", + )); + assert!(!same_relay_deployment( + "http://remote.openbitfun.com/relay", + "https://remote.openbitfun.com/relay", + )); + } + + #[test] + fn page_url_keeps_version_before_the_fragment() { + assert_eq!( + relay_page_url("https://remote.openbitfun.com/relay?v=1.0.9", "#/pair?did=abc"), + "https://remote.openbitfun.com/relay/?v=1.0.9#/pair?did=abc" + ); + assert_eq!( + relay_page_url("https://remote.openbitfun.com/v/1.0.2", "#/pair?did=abc"), + "https://remote.openbitfun.com/v/1.0.2/#/pair?did=abc" + ); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index 90234118a6..1928c2c027 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -28,15 +28,42 @@ pub fn is_retired_official_relay(value: &str) -> bool { && url.username().is_empty() && url.password().is_none() && url.port().is_none() - && url.query().is_none() && url.fragment().is_none() + && version_only_query(&url) && is_retired_official_version_path(url.path(), current.path()) }) } +/// Official endpoints may announce their service version as a single `v` query +/// parameter. Any other query content disqualifies the URL from being treated +/// as an official endpoint at all, so a crafted query can never inherit the +/// official retirement handling. +fn version_only_query(url: &reqwest::Url) -> bool { + let Some(query) = url.query() else { + return true; + }; + let mut parameters = query.split('&'); + let version = parameters + .next() + .and_then(|parameter| parameter.strip_prefix("v=")); + parameters.next().is_none() + && version.is_some_and(|version| { + !version.is_empty() + && version.len() <= 32 + && version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + }) +} + fn is_retired_official_version_path(path: &str, current: &str) -> bool { let path = path.trim_end_matches('/'); let current = current.trim_end_matches('/'); + // `/v/1.0.2` and `/relay` are two live aliases of one deployment: both reach + // the same process and account database, so neither retires a stored token. + if openbitfun_product_domains::account::LIVE_RELAY_ENDPOINT_PATHS.contains(&path) { + return false; + } path != current && path .strip_prefix("/v/") @@ -411,21 +438,55 @@ pub fn error_indicates_expired_token(message: &str) -> bool { /// Parse a user/config supplied relay base URL at the shared transport /// boundary. Paths are allowed for reverse-proxy prefixes; credentials, -/// query strings, fragments, and non-HTTP schemes are not. +/// fragments, and non-HTTP schemes are not. +/// +/// The endpoint may announce its service version as a single `v` query +/// parameter (`?v=1.0.9`). That parameter is validated and removed, so every +/// path join downstream still receives a bare base URL. Any other query +/// parameter is rejected: the query must never become a channel for +/// per-request data. pub fn validate_relay_base_url(relay_url: &str) -> Result { - let url = reqwest::Url::parse(relay_url.trim()) + let mut url = reqwest::Url::parse(relay_url.trim()) .map_err(|error| anyhow!("invalid relay URL: {error}"))?; if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() - || url.query().is_some() || url.fragment().is_some() { return Err(anyhow!( "relay URL must be an http(s) server address without credentials, query, or fragment" )); } + let has_version_parameter = match url.query() { + Some(query) => { + let mut parameters = query.split('&'); + let version = parameters + .next() + .and_then(|parameter| parameter.strip_prefix("v=")); + let extra = parameters.next(); + match (version, extra) { + (Some(version), None) + if !version.is_empty() + && version.len() <= 32 + && version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) => + { + true + } + _ => { + return Err(anyhow!( + "relay URL may only carry a single v= query parameter" + )) + } + } + } + None => false, + }; + if has_version_parameter { + url.set_query(None); + } Ok(url) } @@ -1534,7 +1595,7 @@ mod tests { #[test] fn retired_official_endpoint_does_not_capture_custom_relays() { let current = openbitfun_product_domains::account::DEFAULT_RELAY_URL; - assert_eq!(current, "https://remote.openbitfun.com/v/1.0.2"); + assert_eq!(current, "https://remote.openbitfun.com/relay?v=1.0.9"); for old in [ ["https://remote.openbitfun.com", "/v/1.0.0"].concat(), ["https://remote.openbitfun.com", "/v/1.0.1"].concat(), diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs index a839cdd605..a68b54fa80 100644 --- a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -337,6 +337,9 @@ async fn finish_page_publication( } /// Join account relay base URL with a page path into an absolute URL. +/// +/// The endpoint may announce its service version as `?v=`; that parameter is +/// dropped before joining so the path is never appended to a query string. pub fn join_relay_url(relay_url: &str, path: &str) -> String { let path = path.trim(); if path.is_empty() { @@ -345,7 +348,7 @@ pub fn join_relay_url(relay_url: &str, path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { return path.to_string(); } - let base = relay_url.trim().trim_end_matches('/'); + let base = openbitfun_product_domains::account::relay_endpoint_base(relay_url); if base.is_empty() { return path.to_string(); } diff --git a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs index 56170e9d97..095766c0f7 100644 --- a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs +++ b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs @@ -55,8 +55,14 @@ impl QrGenerator { { return Err(anyhow!("Invalid account device id")); } + // Keep the announced service version in the user-facing page URL, but + // place it before the fragment: `.../relay/?v=1.0.9#/pair?did=...`. + let version = openbitfun_product_domains::account::relay_endpoint_version(web_app_url); let mut url = super::account::validate_relay_base_url(web_app_url)?; url.set_path(&format!("{}/", url.path().trim_end_matches('/'))); + if let Some(version) = version { + url.set_query(Some(&format!("v={version}"))); + } url.set_fragment(Some(&format!("/pair?did={device_id}"))); Ok(url.to_string()) } diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_failure.rs b/src/crates/services/services-integrations/src/remote_connect/relay_failure.rs index a6bd9b6909..12c843f59c 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_failure.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_failure.rs @@ -244,9 +244,14 @@ mod tests { #[test] fn the_configured_endpoint_of_another_official_release_counts_as_retired() { assert_eq!(classify_relay_endpoint(DEFAULT_RELAY_URL), None); - let older = DEFAULT_RELAY_URL.replace("/v/1.0.2", "/v/1.0.1"); + // `/v/1.0.2` and `/relay` are live aliases of one deployment; any other + // `/v/` path belongs to a retired deployment. + for live in ["https://remote.openbitfun.com/relay", "https://remote.openbitfun.com/v/1.0.2"] { + assert_eq!(classify_relay_endpoint(live), None, "{live}"); + } + let older = "https://remote.openbitfun.com/v/1.0.1"; assert_eq!( - classify_relay_endpoint(&older), + classify_relay_endpoint(older), Some(RelayFailureKind::RelayVersionRetired) ); assert_eq!(classify_relay_endpoint("https://example.com/relay"), None); diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs index 91cbac75e5..89493b51ce 100644 --- a/src/crates/services/services-integrations/src/remote_persistence.rs +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -112,6 +112,23 @@ struct AccountSessionPayload { device_id: Option, } +/// Whether a stored account session belongs to the same deployed Relay. +/// +/// `/relay` and `/v/1.0.2` are two live aliases of one deployment, so a stored +/// master key must survive the endpoint move. Only builds that link the Relay +/// transport can consult the shared endpoint rules; the persistence-only +/// configuration keeps its original exact comparison. +fn same_relay_session_endpoint(stored: &str, current: &str) -> bool { + #[cfg(feature = "remote-connect")] + { + openbitfun_product_domains::account::same_relay_deployment(stored, current) + } + #[cfg(not(feature = "remote-connect"))] + { + stored.trim_end_matches('/') == current.trim_end_matches('/') + } +} + pub fn read_device_identity(path: &Path) -> Result> { let value: Option = read_optional_json(path)?; if let Some(value) = &value { @@ -945,8 +962,7 @@ pub fn load_or_create_device_secret( let secret = previous .filter(|session| { session.user_id == user_id - && session.relay_url.trim_end_matches('/') - == relay_url.trim_end_matches('/') + && same_relay_session_endpoint(&session.relay_url, relay_url) && session.device_id.as_deref() == Some(device_id) }) .map(|session| session.master_key) diff --git a/src/mobile-web/src/services/pairingLink.ts b/src/mobile-web/src/services/pairingLink.ts index 72b448ce35..05c28716c1 100644 --- a/src/mobile-web/src/services/pairingLink.ts +++ b/src/mobile-web/src/services/pairingLink.ts @@ -1,14 +1,31 @@ -export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.2'; +/** Service version announced by the official endpoint's `v` parameter. */ +export const RELAY_SERVICE_VERSION = '1.0.9'; +/** Paths served by the same deployed Relay and account database. */ +const LIVE_RELAY_ENDPOINT_PATHS = ['/relay', '/v/1.0.2']; -export function normalizeRelayUrl(value: string): string | null { +export const OFFICIAL_RELAY_URL = `https://remote.openbitfun.com/relay?v=${RELAY_SERVICE_VERSION}`; + +/** Base URL of an endpoint: validates it, accepts a single `v` parameter, and + * removes it so the result can be joined with API paths. */ +export function relayEndpointBase(value: string): string | null { try { - const url = new URL(value); + const url = new URL(value.trim()); if (!['http:', 'https:'].includes(url.protocol) || !url.hostname - || url.username || url.password || url.search || url.hash) return null; + || url.username || url.password || url.hash) return null; + const keys = Array.from(url.searchParams.keys()); + if (keys.length > 0) { + if (keys.length !== 1 || keys[0] !== 'v') return null; + if (!/^[A-Za-z0-9.-]{1,32}$/.test(url.searchParams.get('v') ?? '')) return null; + } + url.search = ''; return url.href.replace(/\/+$/, ''); } catch { return null; } } +export function normalizeRelayUrl(value: string): string | null { + return relayEndpointBase(value); +} + function isLocalAddress(hostname: string): boolean { if (hostname === 'localhost' || hostname === '[::1]') return true; const octets = hostname.split('.').map(Number); @@ -21,19 +38,40 @@ function isLocalAddress(hostname: string): boolean { return /^\[(?:f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/i.test(hostname); } +/** True when the normalized URL is one of the official deployment's live + * paths (`/relay` or the older `/v/1.0.2`), so a page opened from either keeps + * working while both reach the same Relay. */ +function isOfficialRelayUrl(normalized: string): boolean { + const official = relayEndpointBase(OFFICIAL_RELAY_URL); + if (!official) return false; + try { + const url = new URL(normalized); + const base = new URL(official); + return url.origin === base.origin + && LIVE_RELAY_ENDPOINT_PATHS.includes(url.pathname.replace(/\/+$/, '')); + } catch { return false; } +} + /** Invitations may select the official endpoint or a locally hosted Relay. */ export function pairingRelayUrl(value: string): string | null { const normalized = normalizeRelayUrl(value); if (!normalized) return null; - if (normalized === OFFICIAL_RELAY_URL) return normalized; + if (isOfficialRelayUrl(normalized)) return normalized; const url = new URL(normalized); return isLocalAddress(url.hostname) && url.pathname === '/' ? normalized : null; } export function currentRelayUrl(location: Pick = window.location): string { - const endpoint = pairingRelayUrl(`${location.origin}${location.pathname.replace(/index\.html$/, '')}`); - if (!endpoint) throw new Error('Open this page from the official Relay or a local Relay invitation.'); - return endpoint; + const pathname = location.pathname.replace(/index\.html$/, ''); + const endpoint = pairingRelayUrl(`${location.origin}${pathname}`); + if (endpoint) return endpoint; + // The official root page is the controller itself, so the address bar may + // carry no path at all while the endpoint is still the official `/relay`. + if (pathname === '/' || pathname === '') { + const official = relayEndpointBase(OFFICIAL_RELAY_URL); + if (official) return official; + } + throw new Error('Open this page from the official Relay or a local Relay invitation.'); } /** A target is resolved only through the authenticated device directory. */ diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index f68a0fe947..9c318ad7f2 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -229,10 +229,12 @@ test('official and local invitations share strict device-only targeting', async } } for (const base of ['https://evil.example/', 'https://remote.openbitfun.com.evil.example/v/1.0.0/', - 'https://user@remote.openbitfun.com/v/1.0.2/', 'http://remote.openbitfun.com/v/1.0.2/', - 'https://remote.openbitfun.com/relay/']) { + 'https://user@remote.openbitfun.com/v/1.0.2/', 'http://remote.openbitfun.com/v/1.0.2/']) { assert.equal(pairingRelayUrl(base), null); } + // Both official aliases resolve to the same deployment. + assert.equal(pairingRelayUrl('https://remote.openbitfun.com/relay/'), 'https://remote.openbitfun.com/relay'); + assert.equal(pairingRelayUrl('https://remote.openbitfun.com/relay/?v=1.0.9'), 'https://remote.openbitfun.com/relay'); assert.equal(accountDeviceIdFromHash('#/pair?did=desktop'), 'desktop'); assert.equal(accountDeviceIdFromHash('#/chat?did=desktop'), null); }); diff --git a/src/shared/relay-transport/AccountRealtime.ts b/src/shared/relay-transport/AccountRealtime.ts index b4509fc5d8..614d67b2f7 100644 --- a/src/shared/relay-transport/AccountRealtime.ts +++ b/src/shared/relay-transport/AccountRealtime.ts @@ -3,6 +3,7 @@ import { CLIENT_PROTOCOL_VERSION, CLIENT_VERSION } from './ClientBuild'; /** Shared account connection. Protocol reference: Happy apiSocket/RpcHandlerManager. */ import { io, type Socket } from 'socket.io-client'; import { RpcPayload } from './RpcPayload'; +import { relayEndpointBase } from './RelayEndpoint'; /** Encrypted `DeviceEvent` forwarded by the Relay from another account device. * The Relay sees only ciphertext; the owner decrypts with the pairwise key. */ @@ -34,10 +35,9 @@ export class AccountRealtime { constructor(options: RealtimeOptions) { this.payloads = new RpcPayload(options.url, options.token); - const url = new URL(options.url); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { - throw new Error('Invalid Relay URL'); - } + const base = relayEndpointBase(options.url); + if (!base) throw new Error('Invalid Relay URL'); + const url = new URL(base); const path = `${url.pathname.replace(/\/$/, '')}/v1/updates`; this.socket = io(url.origin, { path, transports: ['websocket'], forceNew: true, autoConnect: false, diff --git a/src/shared/relay-transport/RelayEndpoint.ts b/src/shared/relay-transport/RelayEndpoint.ts new file mode 100644 index 0000000000..f8db995691 --- /dev/null +++ b/src/shared/relay-transport/RelayEndpoint.ts @@ -0,0 +1,77 @@ +/** Hosted Relay endpoint identity. + * + * The service version travels as a `v` query parameter so a release no longer + * needs its own proxy location, while the endpoint a client stores stays a + * plain base URL: every path join, socket path, and deployment comparison uses + * `relayEndpointBase`, and only user-facing links keep the parameter. + */ + +export const RELAY_SERVICE_VERSION = '1.0.9'; + +/** Paths served by the same deployed Relay and the same account database. + * `/v/1.0.2` stays live for builds that predate the version parameter, so a + * credential or master key from either path is interchangeable. */ +export const LIVE_RELAY_ENDPOINT_PATHS = ['/relay', '/v/1.0.2']; + +export const OFFICIAL_RELAY_URL = `https://remote.openbitfun.com/relay?v=${RELAY_SERVICE_VERSION}`; + +const VERSION_PATTERN = /^[A-Za-z0-9.-]{1,32}$/; + +/** Validate an endpoint and return its base URL (no query, fragment, or + * trailing slash). Exactly one `v=` parameter is allowed; every other + * query parameter is rejected so the query can never carry per-request data. */ +export function relayEndpointBase(value: string | null | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value.trim()); + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname + || url.username || url.password || url.hash) return null; + const keys = Array.from(url.searchParams.keys()); + if (keys.length > 0) { + if (keys.length !== 1 || keys[0] !== 'v') return null; + if (!VERSION_PATTERN.test(url.searchParams.get('v') ?? '')) return null; + } + url.search = ''; + return url.href.replace(/\/+$/, ''); + } catch { return null; } +} + +/** Service version announced by the endpoint, when it carries one. */ +export function relayEndpointVersion(value: string | null | undefined): string | null { + if (!value) return null; + try { + const version = new URL(value.trim()).searchParams.get('v'); + return version && VERSION_PATTERN.test(version) ? version : null; + } catch { return null; } +} + +function endpointPath(base: string): string { + try { + return new URL(base).pathname.replace(/\/+$/, ''); + } catch { return ''; } +} + +/** Whether two endpoints resolve to the same deployed Relay. Both live aliases + * of one host count as one deployment so a build that switches from + * `/v/1.0.2` to `/relay?v=` keeps its stored credential and master key. */ +export function sameRelayDeployment(left: string | null | undefined, right: string | null | undefined): boolean { + const a = relayEndpointBase(left); + const b = relayEndpointBase(right); + if (!a || !b) return false; + const urlA = new URL(a); + const urlB = new URL(b); + if (urlA.origin !== urlB.origin) return false; + const pathA = endpointPath(a); + const pathB = endpointPath(b); + return pathA === pathB + || (LIVE_RELAY_ENDPOINT_PATHS.includes(pathA) && LIVE_RELAY_ENDPOINT_PATHS.includes(pathB)); +} + +/** User-facing page URL: keeps `?v=` before the fragment so the version + * survives in the address bar and in shared links. */ +export function relayPageUrl(endpoint: string, fragment: string): string { + const base = relayEndpointBase(endpoint); + if (!base) throw new Error('Invalid Relay URL'); + const version = relayEndpointVersion(endpoint); + return version ? `${base}/?v=${version}${fragment}` : `${base}/${fragment}`; +} diff --git a/src/shared/relay-transport/RpcPayload.ts b/src/shared/relay-transport/RpcPayload.ts index bff12f88f3..f672272bc3 100644 --- a/src/shared/relay-transport/RpcPayload.ts +++ b/src/shared/relay-transport/RpcPayload.ts @@ -1,3 +1,5 @@ +import { relayEndpointBase } from './RelayEndpoint'; + /** Short-lived opaque bulk transfers. References stay on the configured Relay. */ const INLINE_BYTES = 128 * 1024; const MAX_BYTES = 64 * 1024 * 1024; @@ -6,11 +8,9 @@ export class RpcPayload { private readonly endpoint: string; private readonly lifetime = new AbortController(); constructor(url: string, private readonly token: string) { - const base = new URL(url); - if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) { - throw new Error('Invalid Relay URL'); - } - this.endpoint = `${base.href.replace(/\/$/, '')}/v1/rpc/payloads`; + const base = relayEndpointBase(url); + if (!base) throw new Error('Invalid Relay URL'); + this.endpoint = `${base}/v1/rpc/payloads`; } close(): void { this.lifetime.abort(); } async uploadIfLarge(value: unknown): Promise { diff --git a/src/web-ui/src/infrastructure/remote-connect/relayEndpoint.test.ts b/src/web-ui/src/infrastructure/remote-connect/relayEndpoint.test.ts new file mode 100644 index 0000000000..88f97db121 --- /dev/null +++ b/src/web-ui/src/infrastructure/remote-connect/relayEndpoint.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + OFFICIAL_RELAY_URL, + relayEndpointBase, + relayEndpointVersion, + relayPageUrl, + sameRelayDeployment, +} from '../../../../shared/relay-transport/RelayEndpoint'; + +describe('relay endpoint identity', () => { + it('carries the service version as a query parameter', () => { + expect(OFFICIAL_RELAY_URL).toBe('https://remote.openbitfun.com/relay?v=1.0.9'); + expect(relayEndpointVersion(OFFICIAL_RELAY_URL)).toBe('1.0.9'); + }); + + it('strips the version so paths are never appended to a query', () => { + expect(relayEndpointBase(OFFICIAL_RELAY_URL)).toBe('https://remote.openbitfun.com/relay'); + expect(relayEndpointBase('https://remote.openbitfun.com/relay/?v=1.0.9')).toBe( + 'https://remote.openbitfun.com/relay', + ); + expect(relayEndpointBase('https://remote.openbitfun.com/v/1.0.2/')).toBe( + 'https://remote.openbitfun.com/v/1.0.2', + ); + }); + + it('rejects credentials, fragments, and unknown query content', () => { + for (const invalid of [ + 'https://user@remote.openbitfun.com/relay', + 'https://remote.openbitfun.com/relay#/pair', + 'https://remote.openbitfun.com/relay?v=1.0.9&x=1', + 'https://remote.openbitfun.com/relay?next=https://evil.example', + 'ftp://remote.openbitfun.com/relay', + '', + ]) { + expect(relayEndpointBase(invalid)).toBeNull(); + } + }); + + it('treats both live aliases as one deployment and other releases as different', () => { + expect(sameRelayDeployment(OFFICIAL_RELAY_URL, 'https://remote.openbitfun.com/v/1.0.2')).toBe(true); + expect(sameRelayDeployment(OFFICIAL_RELAY_URL, 'https://other.example.com/relay')).toBe(false); + expect(sameRelayDeployment(OFFICIAL_RELAY_URL, 'https://remote.openbitfun.com/v/1.0.1')).toBe(false); + expect(sameRelayDeployment('http://remote.openbitfun.com/relay', OFFICIAL_RELAY_URL)).toBe(false); + }); + + it('keeps the version before the fragment in user-facing links', () => { + expect(relayPageUrl(OFFICIAL_RELAY_URL, '#/pair?did=abc')).toBe( + 'https://remote.openbitfun.com/relay/?v=1.0.9#/pair?did=abc', + ); + expect(relayPageUrl('https://remote.openbitfun.com/v/1.0.2', '#/pair?did=abc')).toBe( + 'https://remote.openbitfun.com/v/1.0.2/#/pair?did=abc', + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts index d20afe9b48..025c301152 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts @@ -1,20 +1,22 @@ import type { ConnectionResult, RemoteConnectionMethod, RemoteConnectStatus } from '../api/service-api/RemoteConnectAPI'; +import { + OFFICIAL_RELAY_URL, + relayEndpointBase, + sameRelayDeployment, +} from '../../../../shared/relay-transport/RelayEndpoint'; export type RemoteNetworkMethod = 'lan' | 'openbitfun_server'; -export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.2'; +export { OFFICIAL_RELAY_URL }; export function remoteNetworkMethod(method: RemoteConnectionMethod | null | undefined): RemoteNetworkMethod | null { if (typeof method === 'object' && method !== null && 'lan' in method) return 'lan'; return method === 'openbitfun_server' ? 'openbitfun_server' : null; } +/** Endpoint identity used for joins, comparisons, and status: the version + * parameter is accepted once and removed. */ export function normalizeRelayUrl(value: string | null | undefined): string | null { - if (!value) return null; - try { - const url = new URL(value); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; - return url.href.replace(/\/+$/, ''); - } catch { return null; } + return relayEndpointBase(value); } export function invitationRelayUrl(invitation: ConnectionResult | null | undefined): string | null { @@ -41,7 +43,7 @@ export function selectRemoteNetworkConnection( ) { const legacyUrl = normalizeRelayUrl(status?.relay_url); const legacyMethod = remoteNetworkMethod(status?.active_method) - ?? (legacyUrl ? legacyUrl === OFFICIAL_RELAY_URL ? 'openbitfun_server' : 'lan' : null); + ?? (legacyUrl ? sameRelayDeployment(legacyUrl, OFFICIAL_RELAY_URL) ? 'openbitfun_server' : 'lan' : null); const method = requestedMethod ?? remoteNetworkMethod(invitation?.method) ?? status?.connections?.find(route => route.relay_connected)?.method ?? legacyMethod; const route = status?.connections?.find(route => route.method === method);