From 6b3973ac0d87a8ea94ac6df1b716b08f9db81d9c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 18:11:10 -0700 Subject: [PATCH 1/4] fix(web): let clients without a managed backend manage pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Access management was bound to the primary environment, so it was reachable only from a client that owns a local backend. A hosted-app client has no primary at all — `platformConnectionSourceLayer` emits no registrations for `isHostedStaticApp()` — so the Connections page hid the whole "Authorized clients" section. A user who had paired the hosted app to their own server could not mint a pairing link, revoke a client, or see a pairing QR code for that server. Route the access-management endpoints through the environment supervisor's prepared connection instead of the primary, and administer the active environment when there is no primary. A desktop that manages its own backend keeps administering that backend, so managed mode is unchanged. Pairing also silently lost administrative scope: onboarding always requested the standard client scopes, and the server rejects a wider request rather than clamping it, so a client bootstrapped from a server's own startup token was downgraded to a standard session and could not manage the access it had just been handed. Request no scopes and take exactly what the credential carries. Only a directly addressable HTTP origin yields a shareable pairing URL; relay and SSH environments are reached through a broker or a local tunnel whose address means nothing on another device, so those fall back to showing the bare pairing code. --- .../ConnectionsSettings.logic.test.ts | 137 ++++++++- .../settings/ConnectionsSettings.logic.ts | 46 ++- .../settings/ConnectionsSettings.tsx | 264 ++++++++++++++---- .../src/connection/onboarding.test.ts | 6 +- .../src/connection/onboarding.ts | 7 +- packages/client-runtime/src/state/auth.ts | 47 +++- packages/client-runtime/src/state/authHttp.ts | 152 ++++++++++ packages/client-runtime/src/state/runtime.ts | 2 +- 8 files changed, 600 insertions(+), 61 deletions(-) create mode 100644 packages/client-runtime/src/state/authHttp.ts diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..9e8a367d04a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,20 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import { + BearerConnectionProfile, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionProfile, + SshConnectionTarget, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId, type DesktopWslState } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + environmentPairingBaseUrl, + resolveAccessEnvironment, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +87,122 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +const primaryId = EnvironmentId.make("primary-env"); +const savedId = EnvironmentId.make("saved-env"); + +describe("resolveAccessEnvironment", () => { + it("administers the managed backend when one exists, even while viewing a saved environment", () => { + expect( + resolveAccessEnvironment({ + primaryEnvironmentId: primaryId, + activeEnvironmentId: savedId, + }), + ).toEqual({ environmentId: primaryId, isPrimary: true }); + }); + + it("administers the selected environment when there is no managed backend", () => { + expect( + resolveAccessEnvironment({ + primaryEnvironmentId: null, + activeEnvironmentId: savedId, + }), + ).toEqual({ environmentId: savedId, isPrimary: false }); + }); + + it("has nothing to administer when no environment is selected either", () => { + expect( + resolveAccessEnvironment({ primaryEnvironmentId: null, activeEnvironmentId: null }), + ).toEqual({ environmentId: null, isPrimary: false }); + }); +}); + +describe("environmentPairingBaseUrl", () => { + const entry = ( + target: ConnectionCatalogEntry["target"], + profile?: unknown, + ): ConnectionCatalogEntry => ({ + target, + profile: (profile === undefined + ? Option.none() + : Option.some(profile)) as ConnectionCatalogEntry["profile"], + }); + + it("uses a bearer environment's own base URL", () => { + expect( + environmentPairingBaseUrl( + entry( + new BearerConnectionTarget({ + environmentId: savedId, + label: "headless", + connectionId: "bearer:saved-env", + }), + new BearerConnectionProfile({ + connectionId: "bearer:saved-env", + environmentId: savedId, + label: "headless", + httpBaseUrl: "https://box.tail.ts.net/", + wsBaseUrl: "wss://box.tail.ts.net/", + }), + ), + ), + ).toBe("https://box.tail.ts.net/"); + }); + + it("uses the primary's base URL", () => { + expect( + environmentPairingBaseUrl( + entry( + new PrimaryConnectionTarget({ + environmentId: primaryId, + label: "local", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }), + ), + ), + ).toBe("http://127.0.0.1:3773/"); + }); + + it("has no shareable URL for a bearer environment whose profile is missing", () => { + expect( + environmentPairingBaseUrl( + entry( + new BearerConnectionTarget({ + environmentId: savedId, + label: "headless", + connectionId: "bearer:saved-env", + }), + ), + ), + ).toBeNull(); + }); + + it("has no shareable URL for an SSH tunnel, whose local address is meaningless elsewhere", () => { + expect( + environmentPairingBaseUrl( + entry( + new SshConnectionTarget({ + environmentId: savedId, + label: "box", + connectionId: "ssh:box", + }), + new SshConnectionProfile({ + connectionId: "ssh:box", + environmentId: savedId, + label: "box", + target: { alias: "box", hostname: "box", username: "ivan", port: 22 }, + }), + ), + ), + ).toBeNull(); + }); + + it("has no shareable URL for a relay environment", () => { + expect( + environmentPairingBaseUrl( + entry(new RelayConnectionTarget({ environmentId: savedId, label: "cloud" })), + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..2d94bbdc77a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,4 +1,48 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; +import type { DesktopBridge, DesktopWslState, EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +/** + * Pick the server whose pairing links and client sessions the Connections page + * administers. + * + * A desktop that manages its own backend keeps administering that backend, so + * managed mode is unchanged. A client-only desktop — or a browser pointed at a + * saved server — has no primary, and administers the selected environment + * instead. Access management was previously reachable only through the primary, + * which left those clients with no way to mint a pairing link at all. + */ +export function resolveAccessEnvironment(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; +}): { readonly environmentId: EnvironmentId | null; readonly isPrimary: boolean } { + const environmentId = input.primaryEnvironmentId ?? input.activeEnvironmentId; + return { + environmentId, + isPrimary: environmentId !== null && environmentId === input.primaryEnvironmentId, + }; +} + +/** + * The address to build a shareable pairing URL from for an environment the + * desktop does not manage. Only a directly addressable HTTP origin is usable: + * relay and SSH environments are reached through a broker or a local tunnel + * whose address means nothing on another device, so those fall back to showing + * the bare pairing code instead of an unusable link. + */ +export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "RelayConnectionTarget": + case "SshConnectionTarget": + return null; + } +} type WslEnableBridge = Pick; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..c2bef8feeeb 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -41,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + environmentPairingBaseUrl, + resolveAccessEnvironment, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -113,6 +117,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; +import { useActiveEnvironmentId } from "~/state/entities"; import { environmentCatalog } from "~/connection/catalog"; import { connectPairing as connectPairingAtom, @@ -972,12 +977,19 @@ type AuthorizedClientsHeaderActionProps = { clientSessions: ReadonlyArray; isRevokingOtherClients: boolean; onRevokeOtherClients: () => void; + // Supplied by the parent so the link is minted on whichever environment the + // page is administering, rather than always on the primary. + onCreatePairingLink: (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => Promise; }; const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ clientSessions, isRevokingOtherClients, onRevokeOtherClients, + onCreatePairingLink, }: AuthorizedClientsHeaderActionProps) { const [dialogOpen, setDialogOpen] = useState(false); const [pairingLabel, setPairingLabel] = useState(""); @@ -989,7 +1001,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio const handleCreatePairingLink = useCallback(async () => { setIsCreatingPairingLink(true); try { - await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes }); + await onCreatePairingLink({ label: pairingLabel, scopes: pairingScopes }); setPairingLabel(""); setPairingScopes([...AuthStandardClientScopes]); setDialogOpen(false); @@ -1005,7 +1017,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio } finally { setIsCreatingPairingLink(false); } - }, [pairingLabel, pairingScopes]); + }, [onCreatePairingLink, pairingLabel, pairingScopes]); const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { setPairingScopes((current) => @@ -1713,12 +1725,26 @@ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const activeEnvironmentId = useActiveEnvironmentId(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const createEnvironmentPairingLink = useAtomCommand(authEnvironment.createPairingCredential, { + reportFailure: false, + }); + const revokeEnvironmentPairingLink = useAtomCommand(authEnvironment.revokePairingLink, { + reportFailure: false, + }); + const revokeEnvironmentClientSession = useAtomCommand(authEnvironment.revokeClientSession, { + reportFailure: false, + }); + const revokeOtherEnvironmentClientSessions = useAtomCommand( + authEnvironment.revokeOtherClientSessions, + { reportFailure: false }, + ); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge @@ -1843,10 +1869,41 @@ export function ConnectionsSettings() { ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + + const { environmentId: accessEnvironmentId, isPrimary: isAccessEnvironmentPrimary } = + resolveAccessEnvironment({ primaryEnvironmentId, activeEnvironmentId }); + const accessEnvironment = + accessEnvironmentId === null + ? null + : (environments.find( + (environment) => environment.entry.target.environmentId === accessEnvironmentId, + ) ?? null); + // Scopes come from the session on the server being administered. For a + // non-primary environment that is the credential this client paired with, + // which is administrative when it was bootstrapped from the server's own + // startup token and standard-only when it came from an ordinary pairing link. + const accessEnvironmentSession = useEnvironmentQuery( + !isAccessEnvironmentPrimary && accessEnvironmentId !== null + ? authEnvironment.sessionState({ environmentId: accessEnvironmentId, input: null }) + : null, + ); + const accessSessionScopes = isAccessEnvironmentPrimary + ? currentSessionScopes + : accessEnvironmentSession.data?.authenticated + ? (accessEnvironmentSession.data.scopes ?? null) + : null; + const canManageEnvironmentAccess = accessSessionScopes?.includes(AuthAccessWriteScope) ?? false; + // An environment the desktop does not manage has no advertised-endpoint list. + // Its own base URL is the address this client reaches it on, and it needs no + // reachability gate: being connected through it is the proof. + const accessEnvironmentPairingBaseUrl = + isAccessEnvironmentPrimary || accessEnvironment === null + ? null + : environmentPairingBaseUrl(accessEnvironment.entry); const authAccessChanges = useEnvironmentQuery( - canManageLocalBackend && primaryEnvironmentId !== null + canManageEnvironmentAccess && accessEnvironmentId !== null ? authEnvironment.accessChanges({ - environmentId: primaryEnvironmentId, + environmentId: accessEnvironmentId, input: null, }) : null, @@ -2036,32 +2093,78 @@ export function ConnectionsSettings() { setDisableTailscaleServeDialogOpen(true); }, []); - const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { - setRevokingDesktopPairingLinkId(id); - setDesktopAccessManagementMutationError(null); - try { - await revokeServerPairingLink(id); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; - setDesktopAccessManagementMutationError(message); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not revoke pairing link", - description: message, - }), - ); - } finally { - setRevokingDesktopPairingLinkId(null); - } - }, []); + const handleCreateAccessPairingLink = useCallback( + async (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await createServerPairingCredential(input); + return; + } + const result = await createEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + }, + [accessEnvironmentId, createEnvironmentPairingLink, isAccessEnvironmentPrimary], + ); + + const handleRevokeDesktopPairingLink = useCallback( + async (id: string) => { + setRevokingDesktopPairingLinkId(id); + setDesktopAccessManagementMutationError(null); + try { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerPairingLink(id); + } else { + const result = await revokeEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input: { id }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; + setDesktopAccessManagementMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke pairing link", + description: message, + }), + ); + } finally { + setRevokingDesktopPairingLinkId(null); + } + }, + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentPairingLink], + ); const handleRevokeDesktopClientSession = useCallback( async (sessionId: ServerClientSessionRecord["sessionId"]) => { setRevokingDesktopClientSessionId(sessionId); setDesktopAccessManagementMutationError(null); try { - await revokeServerClientSession(sessionId); + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerClientSession(sessionId); + } else { + const result = await revokeEnvironmentClientSession({ + environmentId: accessEnvironmentId, + input: { sessionId }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke client access."; setDesktopAccessManagementMutationError(message); @@ -2076,14 +2179,27 @@ export function ConnectionsSettings() { setRevokingDesktopClientSessionId(null); } }, - [], + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentClientSession], ); const handleRevokeOtherDesktopClients = useCallback(async () => { setIsRevokingOtherDesktopClients(true); setDesktopAccessManagementMutationError(null); try { - const revokedCount = await revokeOtherServerClientSessions(); + let revokedCount: number; + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + revokedCount = await revokeOtherServerClientSessions(); + } else { + const result = await revokeOtherEnvironmentClientSessions({ + environmentId: accessEnvironmentId, + input: null, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + revokedCount = result.value.revokedCount; + } toastManager.add({ type: "success", title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, @@ -2102,7 +2218,7 @@ export function ConnectionsSettings() { } finally { setIsRevokingOtherDesktopClients(false); } - }, []); + }, [accessEnvironmentId, isAccessEnvironmentPrimary, revokeOtherEnvironmentClientSessions]); const handleAddSavedBackend = useCallback(async () => { if (savedBackendMode === "ssh") { @@ -2891,7 +3007,14 @@ export function ConnectionsSettings() { } /> ); - const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( + const renderAuthorizedClients = ( + presentation: AccessSectionPresentation, + endpoints: { + readonly endpointUrl: string | null | undefined; + readonly endpoints: ReadonlyArray; + readonly defaultEndpointKey: string | null; + }, + ) => ( <> {desktopAccessManagementError ? (
@@ -2899,9 +3022,9 @@ export function ConnectionsSettings() {
) : null} ); + const renderAuthorizedClientsSection = (endpoints: { + readonly endpointUrl: string | null | undefined; + readonly endpoints: ReadonlyArray; + readonly defaultEndpointKey: string | null; + }) => ( + + } + > + + {renderAuthorizedClients("current", endpoints)} + + + ); const renderNetworkAccessRow = () => ( + {/* + * Pairing links and client sessions belong to whichever server this page + * is administering. When that server is not a desktop-managed backend — + * a browser or hosted app pointed at a saved server — it has no "This + * environment" block to live under, so it gets its own section. + */} + {!isAccessEnvironmentPrimary && canManageEnvironmentAccess + ? renderAuthorizedClientsSection({ + endpointUrl: accessEnvironmentPairingBaseUrl, + endpoints: EMPTY_ADVERTISED_ENDPOINTS, + defaultEndpointKey: null, + }) + : null} + {canManageLocalBackend ? ( <> @@ -3019,26 +3181,13 @@ export function ConnectionsSettings() { )} - {isLocalBackendRemotelyReachable ? ( - - } - > - - {renderAuthorizedClients("current")} - - - ) : null} + {isLocalBackendRemotelyReachable + ? renderAuthorizedClientsSection({ + endpointUrl: desktopServerExposureState?.endpointUrl, + endpoints: visibleDesktopAdvertisedEndpoints, + defaultEndpointKey: defaultDesktopAdvertisedEndpointKey, + }) + : null} { @@ -3305,7 +3454,14 @@ export function ConnectionsSettings() { - ) : ( + ) : /* + * Only explain the missing scope when there is a backend to be missing + * it on. A client with no primary at all (hosted app, or a client-only + * desktop) administers a saved environment in its own section above, so + * this row would contradict it — and an empty "This environment" + * heading is worse than none. + */ + primaryEnvironmentId !== null ? ( + ) : ( + )} { : String(tokenRequest?.init.body); const tokenParams = new URLSearchParams(tokenBody); expect(tokenParams.get("subject_token")).toBe("pairing-token"); - expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + // No requested scope: the server grants exactly what the pairing + // credential carries. Naming a set is rejected rather than clamped when + // it exceeds the grant, and naming the standard set would silently drop + // the administrative scopes on a server's own startup token. + expect(tokenParams.get("scope")).toBeNull(); expect(tokenParams.get("client_label")).toBe("T3 Code Test"); }), ); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2c..2448a322ae0 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -91,10 +91,15 @@ export const preparePairingRegistration = Effect.fn( const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + // Deliberately request no scopes: the server then grants exactly what the + // pairing credential carries. Naming a wider set is rejected outright rather + // than clamped, so asking for administrative scopes would break pairing with + // an ordinary link — while always asking for the standard set silently + // discarded the administrative scopes on a server's own startup token, + // leaving that client unable to manage the access it was handed. const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, - scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const connectionId = `bearer:${descriptor.environmentId}`; diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts index 074b89627af..d0fed97a12f 100644 --- a/packages/client-runtime/src/state/auth.ts +++ b/packages/client-runtime/src/state/auth.ts @@ -2,14 +2,30 @@ import type { AuthAccessSnapshot, AuthAccessStreamEvent, AuthAccessStreamSnapshotEvent, + AuthEnvironmentScope, + AuthSessionId, } from "@t3tools/contracts"; import { WS_METHODS } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; +import type { HttpClient } from "effect/unstable/http"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { subscribe } from "../rpc/client.ts"; -import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +export { EnvironmentNotConnectedError } from "./authHttp.ts"; + +import { + createEnvironmentPairingCredential, + fetchEnvironmentSessionState, + revokeEnvironmentClientSession, + revokeEnvironmentPairingLink, + revokeOtherEnvironmentClientSessions, +} from "./authHttp.ts"; +import { + createEnvironmentCommand, + createEnvironmentQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { pairingLinks: [], @@ -76,7 +92,7 @@ export function projectAuthAccessSnapshot( } export function createAuthEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { return { accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { @@ -86,5 +102,32 @@ export function createAuthEnvironmentAtoms( Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), ), }), + sessionState: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:auth-session-state", + execute: (_input: null) => fetchEnvironmentSessionState(), + }), + // Access mutations address the environment they run in rather than the + // primary, so a client with no managed backend can still administer a saved + // server it holds an `access:write` credential for. + createPairingCredential: createEnvironmentCommand(runtime, { + label: "environment-command:server:create-pairing-credential", + execute: (input: { + readonly label?: string; + readonly scopes?: ReadonlyArray; + }) => createEnvironmentPairingCredential(input), + }), + revokePairingLink: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-pairing-link", + execute: (input: { readonly id: string }) => revokeEnvironmentPairingLink(input), + }), + revokeClientSession: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-client-session", + execute: (input: { readonly sessionId: AuthSessionId }) => + revokeEnvironmentClientSession(input), + }), + revokeOtherClientSessions: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-other-client-sessions", + execute: (_input: null) => revokeOtherEnvironmentClientSessions(), + }), }; } diff --git a/packages/client-runtime/src/state/authHttp.ts b/packages/client-runtime/src/state/authHttp.ts new file mode 100644 index 00000000000..4c0bffab39d --- /dev/null +++ b/packages/client-runtime/src/state/authHttp.ts @@ -0,0 +1,152 @@ +import type { AuthEnvironmentScope, AuthSessionId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpMethod } from "effect/unstable/http"; + +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Access management is interactive: the user is waiting on a dialog, so fail +// fast rather than leaving a spinner up while a wedged endpoint stalls. +const AUTH_MUTATION_TIMEOUT_MS = 10_000; + +/** + * Raised when an access-management call is attempted against an environment + * that has no live prepared connection. The catalog can hold an environment + * that is reconnecting or unreachable, and those have no endpoint or credential + * to address, so the mutation cannot be attempted at all. + */ +export class EnvironmentNotConnectedError extends Data.TaggedError( + "@t3tools/client-runtime/state/authHttp/EnvironmentNotConnectedError", +)<{ readonly message: string }> {} + +/** + * Resolve the endpoint, credential and authorization headers for an + * authenticated request against the environment this effect is running in. + * + * The access-management endpoints have always existed per-environment; only the + * client bound them to the primary. Routing them through the supervisor's + * prepared connection lets a client with no managed backend of its own manage a + * saved server's pairing links and sessions, using whichever credential that + * connection was established with. + */ +const prepareAuthRequest = Effect.fn("clientRuntime.state.authHttp.prepareAuthRequest")(function* ( + method: HttpMethod.HttpMethod, + pathname: string, +) { + const supervisor = yield* EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new EnvironmentNotConnectedError({ + message: "This environment is not connected, so its access settings cannot be changed.", + }); + } + const { httpAuthorization, httpBaseUrl } = prepared.value; + const requestUrl = environmentEndpointUrl(httpBaseUrl, pathname); + const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + // Optional: only relay/DPoP connections need a signer, so bearer and + // same-origin session connections must still work without one. + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const headers = yield* buildEnvironmentAuthHeaders(httpAuthorization, method, requestUrl, signer); + return { client, headers, httpAuthorization, requestUrl }; +}); + +/** + * Read the calling client's own session state for this environment, including + * the scopes it was granted. Access-management UI keys off this rather than the + * primary environment's session, so it reflects what this client may actually do + * on the server it is looking at. + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.authHttp.fetchEnvironmentSessionState", +)(function* () { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "GET", + "/api/auth/session", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials(httpAuthorization, client.auth.session({ headers })), + ); +}); + +export const createEnvironmentPairingCredential = Effect.fn( + "clientRuntime.state.authHttp.createEnvironmentPairingCredential", +)(function* (input: { + readonly label?: string; + readonly scopes?: ReadonlyArray; +}) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/pairing-token", + ); + const trimmedLabel = input.label?.trim(); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.pairingCredential({ + headers, + payload: { + ...(trimmedLabel ? { label: trimmedLabel } : {}), + ...(input.scopes ? { scopes: input.scopes } : {}), + }, + }), + ), + ); +}); + +export const revokeEnvironmentPairingLink = Effect.fn( + "clientRuntime.state.authHttp.revokeEnvironmentPairingLink", +)(function* (input: { readonly id: string }) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/pairing-links/revoke", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.revokePairingLink({ headers, payload: { id: input.id } }), + ), + ); +}); + +export const revokeEnvironmentClientSession = Effect.fn( + "clientRuntime.state.authHttp.revokeEnvironmentClientSession", +)(function* (input: { readonly sessionId: AuthSessionId }) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/clients/revoke", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.revokeClient({ headers, payload: { sessionId: input.sessionId } }), + ), + ); +}); + +export const revokeOtherEnvironmentClientSessions = Effect.fn( + "clientRuntime.state.authHttp.revokeOtherEnvironmentClientSessions", +)(function* () { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/clients/revoke-others", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials(httpAuthorization, client.auth.revokeOtherClients({ headers })), + ); +}); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 7bfeb81f5db..dea82f1fe08 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -444,7 +444,7 @@ export function followStreamInEnvironment( ); } -function createEnvironmentQueryAtomFamily( +export function createEnvironmentQueryAtomFamily( runtime: Atom.AtomRuntime, options: EnvironmentQueryAtomOptions, ): (target: { From 73b0ef5c0cd6873aedc753022d9e0e0fa75d07c2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 18:11:11 -0700 Subject: [PATCH 2/4] feat(server): print a scannable QR code for `t3 pair` `t3 serve` already prints a QR code next to its startup pairing URL, but a link minted later with `t3 pair` printed only text, so pairing a phone meant transcribing a URL by hand. Encode the pair URL when there is one. A bare token is not actionable on the device that scans it, and JSON output stays machine-readable. --- apps/server/src/cliAuthFormat.test.ts | 38 +++++++++++++++++++++++++++ apps/server/src/cliAuthFormat.ts | 5 ++++ 2 files changed, 43 insertions(+) diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts index a428adc7bea..2e48fa3dbbf 100644 --- a/apps/server/src/cliAuthFormat.test.ts +++ b/apps/server/src/cliAuthFormat.test.ts @@ -23,6 +23,44 @@ it("formats issued pairing credentials with the secret and optional pair URL", ( expect(output).toContain("secret-pairing-token"); expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); + // Scannable from another device, matching `t3 serve`'s startup output. + expect(output).toContain("█"); +}); + +it("omits the QR code when there is no pair URL worth scanning", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + scopes: ["orchestration:read"], + subject: "one-time-token", + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), + }, + { json: false }, + ); + + expect(output).toContain("secret-pairing-token"); + expect(output).not.toContain("█"); +}); + +it("keeps JSON output machine-readable rather than embedding a QR code", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + scopes: ["orchestration:read"], + subject: "one-time-token", + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), + }, + { baseUrl: "https://example.com", json: true }, + ); + + expect(output).not.toContain("█"); + expect(JSON.parse(output)).toMatchObject({ + pairUrl: "https://example.com/pair#token=secret-pairing-token", + }); }); it("formats pairing listings without exposing the secret token", () => { diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 2ef5ba10a80..51e1e6154e5 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -2,6 +2,7 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3 import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; +import { renderTerminalQrCode } from "./startupAccess.ts"; const newline = "\n"; @@ -62,6 +63,10 @@ export function formatIssuedPairingCredential( `Token: ${credential.credential}`, ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), `Expires at: ${credential.expiresAt}`, + // Match `t3 serve`, which prints a scannable code alongside its startup + // pairing URL. Only a URL is worth encoding; a bare token is not + // actionable on the device that scans it. + ...(pairUrl ? ["", renderTerminalQrCode(pairUrl)] : []), ].join(newline) + newline ); } From f14509692ccb48790683ecf72b3723d7c69d722c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 19:20:47 -0700 Subject: [PATCH 3/4] fix(web): keep the current-origin pairing fallback to this page's server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Connections page administers an environment it is not served by — a relay or SSH environment, or a bearer environment whose profile is missing — that environment has no addressable base URL, and the pairing row is meant to fall back to the bare pairing code. It instead fell through to the current page's `/pair` URL whenever this page was itself on a non-loopback host, so the QR code and the copy button handed out a link to this client app rather than to the server the link belongs to. The page's own origin only reaches the administered server when that server is the one that served the page, which is the managed-backend case. Make that a stated input of the fallback rather than an accident of the page's hostname, and move the choice into a tested helper. --- .../ConnectionsSettings.logic.test.ts | 68 +++++++++++++++++++ .../settings/ConnectionsSettings.logic.ts | 29 ++++++++ .../settings/ConnectionsSettings.tsx | 35 +++++++--- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 9e8a367d04a..537dc6f4493 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -14,6 +14,7 @@ import { applyWslEnableSelection, environmentPairingBaseUrl, resolveAccessEnvironment, + resolveShareablePairingUrl, } from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { @@ -206,3 +207,70 @@ describe("environmentPairingBaseUrl", () => { ).toBeNull(); }); }); + +describe("resolveShareablePairingUrl", () => { + const currentOriginPairingUrl = "https://client.example/pair?token=secret"; + + it("prefers the selected advertised endpoint", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: "https://box.tail.ts.net/pair?token=secret", + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + isCurrentOriginLoopback: false, + }), + ).toBe("https://box.tail.ts.net/pair?token=secret"); + }); + + it("falls back to the administered server's own base URL", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: false, + isCurrentOriginLoopback: false, + }), + ).toBe("http://192.168.1.5:3773/pair?token=secret"); + }); + + it("uses this page's origin only for the server that serves this page", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: true, + isCurrentOriginLoopback: false, + }), + ).toBe(currentOriginPairingUrl); + }); + + it("shows the bare code for another server with no address, not a link to this client", () => { + // A relay or SSH environment, or a bearer environment whose profile is + // missing: an origin-relative link would pair the scanning device to this + // client app instead of the server the link belongs to. + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: false, + isCurrentOriginLoopback: false, + }), + ).toBeNull(); + }); + + it("shows the bare code when this page's origin is loopback", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl: "http://localhost:3773/pair?token=secret", + servesCurrentOrigin: true, + isCurrentOriginLoopback: true, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 2d94bbdc77a..889b2039920 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -44,6 +44,35 @@ export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string } } +/** + * Pick the pairing URL to show for a link, from the addresses that reach the + * server being administered. + * + * The page's own origin is only one of those addresses when this page is served + * by the administered server — the managed-backend case. For any other + * environment (a saved server reached over the network, a relay, or an SSH + * tunnel) an origin-relative link would pair the scanning device to this client + * app instead of the server the link belongs to, so there is no shareable URL + * and the caller falls back to showing the bare pairing code. A loopback origin + * is likewise unshareable: it resolves to the scanning device, not to us. + */ +export function resolveShareablePairingUrl(input: { + /** Pairing URL for the advertised endpoint the user picked, when there is one. */ + readonly endpointPairingUrl: string | null; + /** Pairing URL built from the administered server's own base URL. */ + readonly basePairingUrl: string | null; + /** Pairing URL on this page's origin. */ + readonly currentOriginPairingUrl: string; + /** Whether the administered server is the one serving this page. */ + readonly servesCurrentOrigin: boolean; + readonly isCurrentOriginLoopback: boolean; +}): string | null { + if (input.endpointPairingUrl !== null) return input.endpointPairingUrl; + if (input.basePairingUrl !== null) return input.basePairingUrl; + if (!input.servesCurrentOrigin || input.isCurrentOriginLoopback) return null; + return input.currentOriginPairingUrl; +} + type WslEnableBridge = Pick; export async function applyWslEnableSelection(input: { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c2bef8feeeb..7acfeb69ea3 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -45,6 +45,7 @@ import { applyWslEnableSelection, environmentPairingBaseUrl, resolveAccessEnvironment, + resolveShareablePairingUrl, } from "./ConnectionsSettings.logic"; import { SettingsPageContainer, @@ -513,6 +514,8 @@ type PairingLinkListRowProps = { endpointUrl: string | null | undefined; endpoints: ReadonlyArray; defaultEndpointKey: string | null; + // Whether the server these links belong to is the one serving this page. + servesCurrentOrigin: boolean; presentation?: AccessSectionPresentation; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; @@ -523,6 +526,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ endpointUrl, endpoints, defaultEndpointKey, + servesCurrentOrigin, presentation = "current", revokingPairingLinkId, onRevoke, @@ -538,10 +542,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ () => resolveCurrentOriginPairingUrl(pairingLink.credential), [pairingLink.credential], ); - const hostedPairingUrl = useMemo( + const basePairingUrl = useMemo( () => endpointUrl != null && endpointUrl !== "" - ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + ? (resolveHostedPairingUrl(endpointUrl, pairingLink.credential) ?? + resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : null, [endpointUrl, pairingLink.credential], ); @@ -570,13 +575,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ } return options; }, [endpoints, pairingLink.credential]); - const shareablePairingUrl = - endpointPairingUrl ?? - (endpointUrl != null && endpointUrl !== "" - ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) - : isLoopbackHostname(window.location.hostname) - ? null - : currentOriginPairingUrl); + const shareablePairingUrl = resolveShareablePairingUrl({ + endpointPairingUrl, + basePairingUrl, + currentOriginPairingUrl, + servesCurrentOrigin, + isCurrentOriginLoopback: isLoopbackHostname(window.location.hostname), + }); const revealValue = shareablePairingUrl ?? pairingLink.credential; const isShareableHostedAppPairingUrl = shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); @@ -1158,6 +1163,7 @@ type PairingClientsListProps = { endpointUrl: string | null | undefined; endpoints: ReadonlyArray; defaultEndpointKey: string | null; + servesCurrentOrigin: boolean; presentation?: AccessSectionPresentation; isLoading: boolean; pairingLinks: ReadonlyArray; @@ -1172,6 +1178,7 @@ const PairingClientsList = memo(function PairingClientsList({ endpointUrl, endpoints, defaultEndpointKey, + servesCurrentOrigin, presentation = "current", isLoading, pairingLinks, @@ -1190,6 +1197,7 @@ const PairingClientsList = memo(function PairingClientsList({ endpointUrl={endpointUrl} endpoints={endpoints} defaultEndpointKey={defaultEndpointKey} + servesCurrentOrigin={servesCurrentOrigin} presentation={presentation} revokingPairingLinkId={revokingPairingLinkId} onRevoke={onRevokePairingLink} @@ -3013,6 +3021,7 @@ export function ConnectionsSettings() { readonly endpointUrl: string | null | undefined; readonly endpoints: ReadonlyArray; readonly defaultEndpointKey: string | null; + readonly servesCurrentOrigin: boolean; }, ) => ( <> @@ -3025,6 +3034,7 @@ export function ConnectionsSettings() { endpointUrl={endpoints.endpointUrl} endpoints={endpoints.endpoints} defaultEndpointKey={endpoints.defaultEndpointKey} + servesCurrentOrigin={endpoints.servesCurrentOrigin} presentation={presentation} isLoading={isLoadingDesktopAccessManagement} pairingLinks={visibleDesktopPairingLinks} @@ -3040,6 +3050,7 @@ export function ConnectionsSettings() { readonly endpointUrl: string | null | undefined; readonly endpoints: ReadonlyArray; readonly defaultEndpointKey: string | null; + readonly servesCurrentOrigin: boolean; }) => ( Date: Fri, 24 Jul 2026 22:20:06 -0700 Subject: [PATCH 4/4] fix(web): keep loopback pairing links local --- .../ConnectionsSettings.logic.test.ts | 53 +++++++++++++++++-- .../settings/ConnectionsSettings.logic.ts | 30 ++++++++--- .../settings/ConnectionsSettings.tsx | 2 - 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 537dc6f4493..b5b2874f276 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -218,7 +218,6 @@ describe("resolveShareablePairingUrl", () => { basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", currentOriginPairingUrl, servesCurrentOrigin: true, - isCurrentOriginLoopback: false, }), ).toBe("https://box.tail.ts.net/pair?token=secret"); }); @@ -230,7 +229,6 @@ describe("resolveShareablePairingUrl", () => { basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", currentOriginPairingUrl, servesCurrentOrigin: false, - isCurrentOriginLoopback: false, }), ).toBe("http://192.168.1.5:3773/pair?token=secret"); }); @@ -242,7 +240,6 @@ describe("resolveShareablePairingUrl", () => { basePairingUrl: null, currentOriginPairingUrl, servesCurrentOrigin: true, - isCurrentOriginLoopback: false, }), ).toBe(currentOriginPairingUrl); }); @@ -257,7 +254,6 @@ describe("resolveShareablePairingUrl", () => { basePairingUrl: null, currentOriginPairingUrl, servesCurrentOrigin: false, - isCurrentOriginLoopback: false, }), ).toBeNull(); }); @@ -269,7 +265,54 @@ describe("resolveShareablePairingUrl", () => { basePairingUrl: null, currentOriginPairingUrl: "http://localhost:3773/pair?token=secret", servesCurrentOrigin: true, - isCurrentOriginLoopback: true, + }), + ).toBeNull(); + }); + + it.each([ + "http://localhost:3773/pair?token=secret", + "http://127.0.0.1:3773/pair?token=secret", + "http://[::1]:3773/pair?token=secret", + ])("skips the loopback advertised endpoint %s", (endpointPairingUrl) => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl, + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe("http://192.168.1.5:3773/pair?token=secret"); + }); + + it("skips a loopback base URL and falls back to a shareable current origin", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://127.0.0.1:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe(currentOriginPairingUrl); + }); + + it("shows the bare code when another server only has a loopback base URL", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://127.0.0.1:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: false, + }), + ).toBeNull(); + }); + + it("skips a hosted app link whose wrapped backend URL is loopback", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: "https://t3.chat/pair?host=https%3A%2F%2Flocalhost%3A3773&token=secret", + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: false, }), ).toBeNull(); }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 889b2039920..738280ba1e0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,5 +1,6 @@ import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; import type { DesktopBridge, DesktopWslState, EnvironmentId } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; /** @@ -44,6 +45,17 @@ export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string } } +function isLoopbackPairingUrl(value: string): boolean { + const pairingUrl = new URL(value); + if (isLoopbackHost(pairingUrl.hostname)) return true; + + // Hosted app links wrap the administered server's address in `host`, so the + // outer URL can be public even when the target still resolves to the scanning + // device itself. + const targetUrl = pairingUrl.searchParams.get("host"); + return targetUrl !== null && isLoopbackHost(new URL(targetUrl).hostname); +} + /** * Pick the pairing URL to show for a link, from the addresses that reach the * server being administered. @@ -53,8 +65,9 @@ export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string * environment (a saved server reached over the network, a relay, or an SSH * tunnel) an origin-relative link would pair the scanning device to this client * app instead of the server the link belongs to, so there is no shareable URL - * and the caller falls back to showing the bare pairing code. A loopback origin - * is likewise unshareable: it resolves to the scanning device, not to us. + * and the caller falls back to showing the bare pairing code. Any loopback + * candidate is likewise unshareable: it resolves to the scanning device, not + * to us. */ export function resolveShareablePairingUrl(input: { /** Pairing URL for the advertised endpoint the user picked, when there is one. */ @@ -65,12 +78,15 @@ export function resolveShareablePairingUrl(input: { readonly currentOriginPairingUrl: string; /** Whether the administered server is the one serving this page. */ readonly servesCurrentOrigin: boolean; - readonly isCurrentOriginLoopback: boolean; }): string | null { - if (input.endpointPairingUrl !== null) return input.endpointPairingUrl; - if (input.basePairingUrl !== null) return input.basePairingUrl; - if (!input.servesCurrentOrigin || input.isCurrentOriginLoopback) return null; - return input.currentOriginPairingUrl; + const candidates = [ + input.endpointPairingUrl, + input.basePairingUrl, + input.servesCurrentOrigin ? input.currentOriginPairingUrl : null, + ]; + return ( + candidates.find((candidate) => candidate !== null && !isLoopbackPairingUrl(candidate)) ?? null + ); } type WslEnableBridge = Pick; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 7acfeb69ea3..60004966be8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -104,7 +104,6 @@ import { revokeOtherServerClientSessions, revokeServerClientSession, revokeServerPairingLink, - isLoopbackHostname, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, @@ -580,7 +579,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ basePairingUrl, currentOriginPairingUrl, servesCurrentOrigin, - isCurrentOriginLoopback: isLoopbackHostname(window.location.hostname), }); const revealValue = shareablePairingUrl ?? pairingLink.credential; const isShareableHostedAppPairingUrl =