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 {
44 EnvironmentId,
55 type EnvironmentCloudLinkStateResult,
66} from "@t3tools/contracts";
7import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
78import { AsyncResult } from "effect/reactivity";
89import { act, useLayoutEffect } from "react";
910import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({
1314 scopes: new Set<string>(),
1415 snapshot: null as EnvironmentCloudLinkStateResult | null,
1516 renderedSnapshot: null as EnvironmentCloudLinkStateResult | null,
17 relayProviderKind: null as RelayManagedEndpointProviderKind | null,
1618 getToken: vi.fn<() => Promise<string | null>>(),
1719 link: vi.fn(),
1820 unlink: vi.fn(),
......@@ -22,12 +24,26 @@ const testState = vi.hoisted(() => ({
2224 toast: vi.fn(),
2325}));
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}));
2541vi.mock("@clerk/react", () => ({
2642 useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }),
2743}));
2844vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } }));
2945vi.mock("../state/relay", () => ({
30 relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery },
46 relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery, stateValueAtom: {} },
3147}));
3248vi.mock("../state/session", () => ({
3349 readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope),
......@@ -105,6 +121,7 @@ beforeEach(() => {
105121 testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]);
106122 testState.snapshot = linkedState;
107123 testState.renderedSnapshot = linkedState;
124 testState.relayProviderKind = null;
108125 testState.getToken.mockReset().mockResolvedValue("clerk-token");
109126 for (const command of [
110127 testState.link,
......@@ -191,6 +208,30 @@ describe("useCloudLinkController", () => {
191208 });
192209 });
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
194235 it("stops the preference write if management permission is lost after linking", async () => {
195236 await mountController();
196237 testState.link.mockImplementationOnce(async () => {
apps/web/src/cloud/useCloudLinkController.ts+39-6
......@@ -1,6 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";
12import { useAuth } from "@clerk/react";
23import { 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";
411import {
512 isAtomCommandInterrupted,
613 settlePromise,
......@@ -27,6 +34,24 @@ export interface CloudLinkDesiredState {
2734 readonly holdWebhooksWhileOffline?: boolean;
2835}
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
3055/**
3156 * Drives the primary environment's T3 Connect link. T3 Connect (managed
3257 * tunnel) and agent-activity publishing are independent capabilities backed by
......@@ -52,6 +77,11 @@ export function useCloudLinkController() {
5277 { reportFailure: false },
5378 );
5479 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;
5585 const [operationError, setOperationError] = useState<string | null>(null);
5686
5787 const reportUpdateFailure = (cause: unknown) => {
......@@ -74,10 +104,12 @@ export function useCloudLinkController() {
74104 });
75105 };
76106
77 // Older environment servers predate the managedTunnelActive field; for them a
78 // link always implies a managed tunnel, so fall back to `linked`.
79 const managedTunnelActive =
80 primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
107 const linkData = primaryCloudLinkState.data;
108 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
109 // Local state says managed but the relay holds a publish-only link. Turning
110 // T3 Connect on relinks as managed, which repairs it.
111 const managedTunnelOutOfSync =
112 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
81113 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
82114 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
83115 const linked = primaryCloudLinkState.data?.linked ?? false;
......@@ -145,7 +177,7 @@ export function useCloudLinkController() {
145177 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
146178 return false;
147179 }
148 const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked;
180 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);
149181 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {
150182 const linkResult = await linkPrimaryEnvironment({
151183 target,
......@@ -194,6 +226,7 @@ export function useCloudLinkController() {
194226 linkState: primaryCloudLinkState,
195227 linked,
196228 managedTunnelActive,
229 managedTunnelOutOfSync,
197230 publishAgentActivity,
198231 holdWebhooksWhileOffline,
199232 operationError,
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1
......@@ -1829,6 +1829,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18291829 isSignedIn,
18301830 linkState: primaryCloudLinkState,
18311831 managedTunnelActive,
1832 managedTunnelOutOfSync,
18321833 publishAgentActivity,
18331834 holdWebhooksWhileOffline,
18341835 operationError,
......@@ -1919,7 +1920,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
19191920 description={
19201921 managedTunnelActive
19211922 ? "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."
19231926 }
19241927 status={operationError ?? primaryCloudLinkState.error}
19251928 control={
packages/client-runtime/src/connection/errors.test.ts+1
......@@ -61,6 +61,7 @@ describe("mapManagedRelayError", () => {
6161 expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({
6262 _tag: "ConnectionBlockedError",
6363 reason: "permission",
64 detail: expect.stringContaining("turn T3 Connect on"),
6465 });
6566 expect(mapConnectRefusal(undefined)).toMatchObject({
6667 _tag: "ConnectionBlockedError",
packages/client-runtime/src/relay/errorPresentation.ts+3
......@@ -46,6 +46,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
4646 if (error.reason === "managed_endpoint_allocation_not_ready") {
4747 return "The environment's T3 Connect tunnel is still being set up.";
4848 }
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 }
4952 return error.reason
5053 ? `Relay rejected the environment connection request (${error.reason}).`
5154 : "Relay rejected the environment connection request.";