authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:22:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:22:47-07:00
logf99b20275359ff7acecdb916a9fa25ac33102b12
treedb79fa1ad241c9fb1c5274a8929ec16660f3b8ac
parent3062701c24ac6b6bde3d238fab8689e48ce2396b
signature Signed by PGP key B5690EEEBB952194

fix(web): T3 Connect settings can repair a link that drifted to publish-only (#19389)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 89 insertions(+), 8 deletions(-)

apps/web/src/cloud/useCloudLinkController.test.tsx+42-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 EnvironmentId,4 EnvironmentId,
5 type EnvironmentCloudLinkStateResult,5 type EnvironmentCloudLinkStateResult,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
7import { AsyncResult } from "effect/reactivity";8import { AsyncResult } from "effect/reactivity";
8import { act, useLayoutEffect } from "react";9import { act, useLayoutEffect } from "react";
9import { create, type ReactTestRenderer } from "react-test-renderer";10import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({...@@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({
13 scopes: new Set<string>(),14 scopes: new Set<string>(),
14 snapshot: null as EnvironmentCloudLinkStateResult | null,15 snapshot: null as EnvironmentCloudLinkStateResult | null,
15 renderedSnapshot: null as EnvironmentCloudLinkStateResult | null,16 renderedSnapshot: null as EnvironmentCloudLinkStateResult | null,
17 relayProviderKind: null as RelayManagedEndpointProviderKind | null,
16 getToken: vi.fn<() => Promise<string | null>>(),18 getToken: vi.fn<() => Promise<string | null>>(),
17 link: vi.fn(),19 link: vi.fn(),
18 unlink: vi.fn(),20 unlink: vi.fn(),
...@@ -22,12 +24,26 @@ const testState = vi.hoisted(() => ({...@@ -22,12 +24,26 @@ const testState = vi.hoisted(() => ({
22 toast: vi.fn(),24 toast: vi.fn(),
23}));25}));
2426
27vi.mock("@effect/atom-react", () => ({
28 useAtomValue: () => ({
29 environments: new Map(
30 testState.relayProviderKind === null
31 ? []
32 : [
33 [
34 "primary",
35 { environment: { endpoint: { providerKind: testState.relayProviderKind } } },
36 ],
37 ],
38 ),
39 }),
40}));
25vi.mock("@clerk/react", () => ({41vi.mock("@clerk/react", () => ({
26 useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }),42 useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }),
27}));43}));
28vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } }));44vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } }));
29vi.mock("../state/relay", () => ({45vi.mock("../state/relay", () => ({
30 relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery },46 relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery, stateValueAtom: {} },
31}));47}));
32vi.mock("../state/session", () => ({48vi.mock("../state/session", () => ({
33 readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope),49 readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope),
...@@ -105,6 +121,7 @@ beforeEach(() => {...@@ -105,6 +121,7 @@ beforeEach(() => {
105 testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]);121 testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]);
106 testState.snapshot = linkedState;122 testState.snapshot = linkedState;
107 testState.renderedSnapshot = linkedState;123 testState.renderedSnapshot = linkedState;
124 testState.relayProviderKind = null;
108 testState.getToken.mockReset().mockResolvedValue("clerk-token");125 testState.getToken.mockReset().mockResolvedValue("clerk-token");
109 for (const command of [126 for (const command of [
110 testState.link,127 testState.link,
...@@ -191,6 +208,30 @@ describe("useCloudLinkController", () => {...@@ -191,6 +208,30 @@ describe("useCloudLinkController", () => {
191 });208 });
192 });209 });
193210
211 it("shows a link the relay holds as publish-only as off and relinks it as managed", async () => {
212 testState.relayProviderKind = "manual";
213 await mountController();
214
215 expect(controller?.managedTunnelActive).toBe(false);
216 expect(controller?.managedTunnelOutOfSync).toBe(true);
217 expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true);
218 expect(testState.link).toHaveBeenCalledExactlyOnceWith({
219 target,
220 clerkToken: "clerk-token",
221 mode: "managed",
222 });
223 });
224
225 it("keeps a managed link that the relay also holds as managed", async () => {
226 testState.relayProviderKind = "cloudflare_tunnel";
227 await mountController();
228
229 expect(controller?.managedTunnelActive).toBe(true);
230 expect(controller?.managedTunnelOutOfSync).toBe(false);
231 expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true);
232 expect(testState.link).not.toHaveBeenCalled();
233 });
234
194 it("stops the preference write if management permission is lost after linking", async () => {235 it("stops the preference write if management permission is lost after linking", async () => {
195 await mountController();236 await mountController();
196 testState.link.mockImplementationOnce(async () => {237 testState.link.mockImplementationOnce(async () => {
apps/web/src/cloud/useCloudLinkController.ts+39-6
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";
1import { useAuth } from "@clerk/react";2import { useAuth } from "@clerk/react";
2import { findErrorTraceId } from "@t3tools/client-runtime/errors";3import { findErrorTraceId } from "@t3tools/client-runtime/errors";
3import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts";4import {
5 EnvironmentId,
6 AuthRelayReadScope,
7 AuthRelayWriteScope,
8 type EnvironmentCloudLinkStateResult,
9} from "@t3tools/contracts";
10import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
4import {11import {
5 isAtomCommandInterrupted,12 isAtomCommandInterrupted,
6 settlePromise,13 settlePromise,
...@@ -27,6 +34,24 @@ export interface CloudLinkDesiredState {...@@ -27,6 +34,24 @@ export interface CloudLinkDesiredState {
27 readonly holdWebhooksWhileOffline?: boolean;34 readonly holdWebhooksWhileOffline?: boolean;
28}35}
2936
37/**
38 * Whether the link has a working managed tunnel. The local tunnel config can
39 * outlive a relay link that drifted to publish-only, and clients then get
40 * `endpoint_provider_not_managed`. So when relay discovery has this
41 * environment's record, it must also say managed. Older environment servers
42 * predate managedTunnelActive; for them a link always implies a tunnel.
43 */
44function isManagedTunnelActive(
45 linkState: EnvironmentCloudLinkStateResult,
46 relayProviderKind: RelayManagedEndpointProviderKind | undefined,
47) {
48 const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked;
49 return (
50 localManagedTunnel &&
51 (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel")
52 );
53}
54
30/**55/**
31 * Drives the primary environment's T3 Connect link. T3 Connect (managed56 * Drives the primary environment's T3 Connect link. T3 Connect (managed
32 * tunnel) and agent-activity publishing are independent capabilities backed by57 * tunnel) and agent-activity publishing are independent capabilities backed by
...@@ -52,6 +77,11 @@ export function useCloudLinkController() {...@@ -52,6 +77,11 @@ export function useCloudLinkController() {
52 { reportFailure: false },77 { reportFailure: false },
53 );78 );
54 const primaryCloudLinkState = usePrimaryCloudLinkState();79 const primaryCloudLinkState = usePrimaryCloudLinkState();
80 const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments;
81 const primaryEnvironmentId = primaryCloudLinkState.target?.environmentId;
82 const relayProviderKind = primaryEnvironmentId
83 ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind
84 : undefined;
55 const [operationError, setOperationError] = useState<string | null>(null);85 const [operationError, setOperationError] = useState<string | null>(null);
5686
57 const reportUpdateFailure = (cause: unknown) => {87 const reportUpdateFailure = (cause: unknown) => {
...@@ -74,10 +104,12 @@ export function useCloudLinkController() {...@@ -74,10 +104,12 @@ export function useCloudLinkController() {
74 });104 });
75 };105 };
76106
77 // Older environment servers predate the managedTunnelActive field; for them a107 const linkData = primaryCloudLinkState.data;
78 // link always implies a managed tunnel, so fall back to `linked`.108 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
79 const managedTunnelActive =109 // Local state says managed but the relay holds a publish-only link. Turning
80 primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;110 // T3 Connect on relinks as managed, which repairs it.
111 const managedTunnelOutOfSync =
112 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
81 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;113 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
82 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;114 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
83 const linked = primaryCloudLinkState.data?.linked ?? false;115 const linked = primaryCloudLinkState.data?.linked ?? false;
...@@ -145,7 +177,7 @@ export function useCloudLinkController() {...@@ -145,7 +177,7 @@ export function useCloudLinkController() {
145 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));177 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
146 return false;178 return false;
147 }179 }
148 const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked;180 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);
149 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {181 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {
150 const linkResult = await linkPrimaryEnvironment({182 const linkResult = await linkPrimaryEnvironment({
151 target,183 target,
...@@ -194,6 +226,7 @@ export function useCloudLinkController() {...@@ -194,6 +226,7 @@ export function useCloudLinkController() {
194 linkState: primaryCloudLinkState,226 linkState: primaryCloudLinkState,
195 linked,227 linked,
196 managedTunnelActive,228 managedTunnelActive,
229 managedTunnelOutOfSync,
197 publishAgentActivity,230 publishAgentActivity,
198 holdWebhooksWhileOffline,231 holdWebhooksWhileOffline,
199 operationError,232 operationError,
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1
...@@ -1829,6 +1829,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1829,6 +1829,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1829 isSignedIn,1829 isSignedIn,
1830 linkState: primaryCloudLinkState,1830 linkState: primaryCloudLinkState,
1831 managedTunnelActive,1831 managedTunnelActive,
1832 managedTunnelOutOfSync,
1832 publishAgentActivity,1833 publishAgentActivity,
1833 holdWebhooksWhileOffline,1834 holdWebhooksWhileOffline,
1834 operationError,1835 operationError,
...@@ -1919,7 +1920,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1919,7 +1920,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1919 description={1920 description={
1920 managedTunnelActive1921 managedTunnelActive
1921 ? "This environment is available to your other devices through T3 Connect."1922 ? "This environment is available to your other devices through T3 Connect."
1922 : "Make this environment available to your other devices through T3 Connect."1923 : managedTunnelOutOfSync
1924 ? "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."
1925 : "Make this environment available to your other devices through T3 Connect."
1923 }1926 }
1924 status={operationError ?? primaryCloudLinkState.error}1927 status={operationError ?? primaryCloudLinkState.error}
1925 control={1928 control={
packages/client-runtime/src/connection/errors.test.ts+1
...@@ -61,6 +61,7 @@ describe("mapManagedRelayError", () => {...@@ -61,6 +61,7 @@ describe("mapManagedRelayError", () => {
61 expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({61 expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({
62 _tag: "ConnectionBlockedError",62 _tag: "ConnectionBlockedError",
63 reason: "permission",63 reason: "permission",
64 detail: expect.stringContaining("turn T3 Connect on"),
64 });65 });
65 expect(mapConnectRefusal(undefined)).toMatchObject({66 expect(mapConnectRefusal(undefined)).toMatchObject({
66 _tag: "ConnectionBlockedError",67 _tag: "ConnectionBlockedError",
packages/client-runtime/src/relay/errorPresentation.ts+3
...@@ -46,6 +46,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {...@@ -46,6 +46,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
46 if (error.reason === "managed_endpoint_allocation_not_ready") {46 if (error.reason === "managed_endpoint_allocation_not_ready") {
47 return "The environment's T3 Connect tunnel is still being set up.";47 return "The environment's T3 Connect tunnel is still being set up.";
48 }48 }
49 if (error.reason === "endpoint_provider_not_managed") {
50 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.";
51 }
49 return error.reason52 return error.reason
50 ? `Relay rejected the environment connection request (${error.reason}).`53 ? `Relay rejected the environment connection request (${error.reason}).`
51 : "Relay rejected the environment connection request.";54 : "Relay rejected the environment connection request.";