From f99b20275359ff7acecdb916a9fa25ac33102b12 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:22:47 -0700 Subject: [PATCH] fix(web): T3 Connect settings can repair a link that drifted to publish-only (#19389) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/useCloudLinkController.test.tsx | 43 +++++++++++++++++- apps/web/src/cloud/useCloudLinkController.ts | 45 ++++++++++++++++--- .../settings/ConnectionsSettings.tsx | 5 ++- .../src/connection/errors.test.ts | 1 + .../src/relay/errorPresentation.ts | 3 ++ 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx index 5ee48b5f72252b063fceb834e12bf5eb70505eaf..b9d7b3a4c7b42fb0b467647328fe7def7654a446 100644 --- a/apps/web/src/cloud/useCloudLinkController.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentId, type EnvironmentCloudLinkStateResult, } from "@t3tools/contracts"; +import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { AsyncResult } from "effect/reactivity"; import { act, useLayoutEffect } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({ scopes: new Set(), snapshot: null as EnvironmentCloudLinkStateResult | null, renderedSnapshot: null as EnvironmentCloudLinkStateResult | null, + relayProviderKind: null as RelayManagedEndpointProviderKind | null, getToken: vi.fn<() => Promise>(), link: vi.fn(), unlink: vi.fn(), @@ -22,12 +24,26 @@ const testState = vi.hoisted(() => ({ toast: vi.fn(), })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ + environments: new Map( + testState.relayProviderKind === null + ? [] + : [ + [ + "primary", + { environment: { endpoint: { providerKind: testState.relayProviderKind } } }, + ], + ], + ), + }), +})); vi.mock("@clerk/react", () => ({ useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }), })); vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); vi.mock("../state/relay", () => ({ - relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, + relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery, stateValueAtom: {} }, })); vi.mock("../state/session", () => ({ readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope), @@ -105,6 +121,7 @@ beforeEach(() => { testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); testState.snapshot = linkedState; testState.renderedSnapshot = linkedState; + testState.relayProviderKind = null; testState.getToken.mockReset().mockResolvedValue("clerk-token"); for (const command of [ testState.link, @@ -191,6 +208,30 @@ describe("useCloudLinkController", () => { }); }); + it("shows a link the relay holds as publish-only as off and relinks it as managed", async () => { + testState.relayProviderKind = "manual"; + await mountController(); + + expect(controller?.managedTunnelActive).toBe(false); + expect(controller?.managedTunnelOutOfSync).toBe(true); + expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); + expect(testState.link).toHaveBeenCalledExactlyOnceWith({ + target, + clerkToken: "clerk-token", + mode: "managed", + }); + }); + + it("keeps a managed link that the relay also holds as managed", async () => { + testState.relayProviderKind = "cloudflare_tunnel"; + await mountController(); + + expect(controller?.managedTunnelActive).toBe(true); + expect(controller?.managedTunnelOutOfSync).toBe(false); + expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); + expect(testState.link).not.toHaveBeenCalled(); + }); + it("stops the preference write if management permission is lost after linking", async () => { await mountController(); testState.link.mockImplementationOnce(async () => { diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index e39feb697762ea856e7adfea2cbc8a024d4573df..f9f9e342b7a836aab89e91bb692d8e40686d79e8 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -1,6 +1,13 @@ +import { useAtomValue } from "@effect/atom-react"; import { useAuth } from "@clerk/react"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts"; +import { + EnvironmentId, + AuthRelayReadScope, + AuthRelayWriteScope, + type EnvironmentCloudLinkStateResult, +} from "@t3tools/contracts"; +import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { isAtomCommandInterrupted, settlePromise, @@ -27,6 +34,24 @@ export interface CloudLinkDesiredState { readonly holdWebhooksWhileOffline?: boolean; } +/** + * Whether the link has a working managed tunnel. The local tunnel config can + * outlive a relay link that drifted to publish-only, and clients then get + * `endpoint_provider_not_managed`. So when relay discovery has this + * environment's record, it must also say managed. Older environment servers + * predate managedTunnelActive; for them a link always implies a tunnel. + */ +function isManagedTunnelActive( + linkState: EnvironmentCloudLinkStateResult, + relayProviderKind: RelayManagedEndpointProviderKind | undefined, +) { + const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; + return ( + localManagedTunnel && + (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") + ); +} + /** * Drives the primary environment's T3 Connect link. T3 Connect (managed * tunnel) and agent-activity publishing are independent capabilities backed by @@ -52,6 +77,11 @@ export function useCloudLinkController() { { reportFailure: false }, ); const primaryCloudLinkState = usePrimaryCloudLinkState(); + const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments; + const primaryEnvironmentId = primaryCloudLinkState.target?.environmentId; + const relayProviderKind = primaryEnvironmentId + ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind + : undefined; const [operationError, setOperationError] = useState(null); const reportUpdateFailure = (cause: unknown) => { @@ -74,10 +104,12 @@ export function useCloudLinkController() { }); }; - // Older environment servers predate the managedTunnelActive field; for them a - // link always implies a managed tunnel, so fall back to `linked`. - const managedTunnelActive = - primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; + const linkData = primaryCloudLinkState.data; + const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; + // Local state says managed but the relay holds a publish-only link. Turning + // T3 Connect on relinks as managed, which repairs it. + const managedTunnelOutOfSync = + linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; const linked = primaryCloudLinkState.data?.linked ?? false; @@ -145,7 +177,7 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); return false; } - const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked; + const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { const linkResult = await linkPrimaryEnvironment({ target, @@ -194,6 +226,7 @@ export function useCloudLinkController() { linkState: primaryCloudLinkState, linked, managedTunnelActive, + managedTunnelOutOfSync, publishAgentActivity, holdWebhooksWhileOffline, operationError, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 443ac6e0288aed027dc26d5a195a52183a52265e..e35e6aeeb324b1b9082c08a5c38e627ad339594f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1829,6 +1829,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b isSignedIn, linkState: primaryCloudLinkState, managedTunnelActive, + managedTunnelOutOfSync, publishAgentActivity, holdWebhooksWhileOffline, operationError, @@ -1919,7 +1920,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b description={ managedTunnelActive ? "This environment is available to your other devices through T3 Connect." - : "Make this environment available to your other devices through T3 Connect." + : managedTunnelOutOfSync + ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it." + : "Make this environment available to your other devices through T3 Connect." } status={operationError ?? primaryCloudLinkState.error} control={ diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index 465ff4cec145466617225f55d9acd3632f7f89b3..4f36c8258018e12648c54d2be5f9f98f56d67340 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -61,6 +61,7 @@ describe("mapManagedRelayError", () => { expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({ _tag: "ConnectionBlockedError", reason: "permission", + detail: expect.stringContaining("turn T3 Connect on"), }); expect(mapConnectRefusal(undefined)).toMatchObject({ _tag: "ConnectionBlockedError", diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index 28ca912adef6fcaac9320350e056a0c5c908eab4..deed043538417e103b391c4b6128a7c61ed77dd1 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -46,6 +46,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { if (error.reason === "managed_endpoint_allocation_not_ready") { return "The environment's T3 Connect tunnel is still being set up."; } + if (error.reason === "endpoint_provider_not_managed") { + return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn T3 Connect on."; + } return error.reason ? `Relay rejected the environment connection request (${error.reason}).` : "Relay rejected the environment connection request."; -- 2.54.0