From c476c43e5b8996fc9018bd8631d67a461e5fe361 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:30:40 +0200 Subject: [PATCH 1/6] feat(connect): deregister unreachable hosts --- apps/web/src/cloud/linkEnvironment.test.ts | 29 ++++- apps/web/src/cloud/linkEnvironment.ts | 29 +++++ apps/web/src/cloud/linkEnvironmentAtoms.ts | 11 ++ .../cloud/CloudEnvironmentConnectList.tsx | 114 ++++++++++++++++-- .../settings/ConnectionsSettings.tsx | 114 ++++++++++++++++++ 5 files changed, 288 insertions(+), 9 deletions(-) diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beabb..286add4435c 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -22,11 +22,13 @@ import { import { type RpcSession } from "@t3tools/client-runtime/rpc"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { CloudSession } from "@t3tools/client-runtime/platform"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { collectCloudLinkTargets, + deregisterRelayEnvironment, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, normalizeRelayBaseUrl, @@ -122,14 +124,18 @@ function registryLayer(options?: { } function services(options?: Parameters[0]) { - return Layer.mergeAll(relayLayer(), registryLayer(options)); + return Layer.mergeAll( + relayLayer(), + registryLayer(options), + Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-token") })), + ); } function withServices( effect: Effect.Effect< A, E, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry | CloudSession >, options?: Parameters[0], ) { @@ -436,4 +442,23 @@ describe("web cloud link environment client", () => { ); }), ); + + it.effect("deregisters an inaccessible environment directly through the relay", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + yield* withServices( + deregisterRelayEnvironment({ + environmentId: EnvironmentId.make(TARGET.environmentId), + }), + ); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + `/v1/client/environment-links/${TARGET.environmentId}`, + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE"); + expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + }), + ); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 22ff986afbd..da84f3b14b1 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -26,6 +26,7 @@ import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { CloudSession } from "@t3tools/client-runtime/platform"; import { readPrimaryEnvironmentDescriptor, @@ -385,6 +386,34 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } +export function deregisterRelayEnvironment(input: { + readonly environmentId: EnvironmentId; +}): Effect.Effect { + return Effect.gen(function* () { + const session = yield* CloudSession; + const clerkToken = yield* session.clerkToken.pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not obtain the T3 Connect session token.", + cause, + }), + ), + ); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient + .unlinkEnvironment({ + clerkToken, + environmentId: input.environmentId, + }) + .pipe( + Effect.mapError( + decodedRelayClientError("Could not remove the environment from T3 Connect."), + ), + ); + }); +} + // "publish_only" links the environment to the relay for agent-activity // publishing alone: no managed tunnel is provisioned, so it can be toggled // independently of T3 Connect while clients reach the environment out of band. diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index 1094e860e46..4816f3aaede 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -5,6 +5,7 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; import { + deregisterRelayEnvironment, linkPrimaryEnvironmentToCloud, type CloudLinkMode, type CloudLinkTarget, @@ -44,3 +45,13 @@ export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connecti execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => updatePrimaryCloudPreferences(input), }); + +export const deregisterEnvironment = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:deregister-environment", + scheduler: cloudLinkScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly environmentId: string }) => input.environmentId, + }, + execute: deregisterRelayEnvironment, +}); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a..39353bb0c6f 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -14,12 +14,23 @@ import * as Option from "effect/Option"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; +import { deregisterEnvironment as deregisterEnvironmentAtom } from "~/cloud/linkEnvironmentAtoms"; import { cn } from "~/lib/utils"; import { relayEnvironmentDiscovery } from "~/state/relay"; import { useRelayEnvironmentDiscovery } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; @@ -66,6 +77,9 @@ export function CloudEnvironmentConnectRows({ const registerEnvironment = useAtomCommand(environmentCatalog.register, { reportFailure: false, }); + const deregisterEnvironment = useAtomCommand(deregisterEnvironmentAtom, { + reportFailure: false, + }); const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); @@ -84,6 +98,8 @@ export function CloudEnvironmentConnectRows({ const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); const savedById = new Map( savedEnvironments.map((environment) => [environment.environmentId, environment]), ); @@ -127,6 +143,45 @@ export function CloudEnvironmentConnectRows({ }); }; + const deregisterRelayEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ environmentId: environment.environmentId }); + setDeregisteringEnvironmentId(null); + if (result._tag === "Success") { + await refreshRelayEnvironments(); + toastManager.add({ + type: "success", + title: "Server deregistered", + description: "Its T3 Connect access was revoked and one host space is now available.", + }); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + const message = + cause instanceof Error ? cause.message : "Could not deregister the T3 Connect server."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not deregister environment", { + environmentId: environment.environmentId, + message, + traceId, + cause, + }); + toastManager.add({ + type: "error", + title: "Could not deregister server", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && @@ -245,13 +300,58 @@ export function CloudEnvironmentConnectRows({ {savedConnection.buttonLabel} ) : ( - +
+ + + {deregisteringEnvironmentId === environment.environmentId + ? "Deregistering…" + : "Deregister"} + + } + /> + + + + Deregister “{environment.label}” from T3 Connect? + + + This revokes this server’s T3 Connect access, removes its managed tunnel, and + frees one of your three host spaces. You can use this even if you no longer + have access to the server. + + + + Cancel} /> + void deregisterRelayEnvironment(environment)} + > + Deregister server + + } + /> + + + + +
)} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..2a6c3cb177d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -70,6 +70,7 @@ import { AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, + AlertDialogTrigger, } from "../ui/alert-dialog"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; @@ -111,6 +112,7 @@ import { resolveServerSelfUpdateCapability, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { deregisterEnvironment as deregisterEnvironmentAtom } from "~/cloud/linkEnvironmentAtoms"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -119,6 +121,7 @@ import { connectSshEnvironment as connectSshEnvironmentAtom, } from "~/connection/onboarding"; import { useEnvironmentQuery } from "~/state/query"; +import { relayEnvironmentDiscovery } from "~/state/relay"; import { desktopNetworkAccessStateAtom, refreshDesktopNetworkAccessState, @@ -1338,15 +1341,19 @@ function NetworkAccessDescription({ type SavedBackendListRowProps = { environment: EnvironmentPresentation; + deregisteringEnvironmentId: EnvironmentId | null; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; + onDeregister: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ environment, + deregisteringEnvironmentId, removingEnvironmentId, onConnect, + onDeregister, onRemove, }: SavedBackendListRowProps) { const environmentId = environment.environmentId; @@ -1471,6 +1478,51 @@ function SavedBackendListRow({ ) : ( <> + {environment.relayManaged ? ( + + + {deregisteringEnvironmentId === environmentId + ? "Deregistering…" + : "Deregister"} + + } + /> + + + + Deregister “{environment.label}” from T3 Connect? + + + This revokes this server’s T3 Connect access, removes its managed tunnel, + and frees one of your three host spaces. Use this if you no longer have + access to the server or do not want it registered to your account. + + + + Cancel} /> + void onDeregister(environmentId)} + > + Deregister server + + } + /> + + + + ) : null} {!isConnected ? (