1import { useAtomValue } from "@effect/atom-react";
2import { useAuth } from "@clerk/react";
3import { findErrorTraceId } from "@t3tools/client-runtime/errors";
4import {
5 EnvironmentId,
6 AuthRelayReadScope,
7 AuthRelayWriteScope,
8 type EnvironmentCloudLinkStateResult,
9} from "@t3tools/contracts";
10import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
11import {
12 isAtomCommandInterrupted,
13 settlePromise,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
16import { useState } from "react";
17
18import { toastManager } from "../components/ui/toast";
19import { relayEnvironmentDiscovery } from "../state/relay";
20import { readEnvironmentScope } from "../state/session";
21import { useAtomCommand } from "../state/use-atom-command";
22import {
23 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
24 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
25 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
26} from "./linkEnvironmentAtoms";
27import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState";
28import { resolveRelayClerkTokenOptions } from "./publicConfig";
29
30export interface CloudLinkDesiredState {
31 readonly managedTunnel: boolean;
32 readonly publish: boolean;
33 /** Omit to leave the webhook-hold setting as it is. */
34 readonly holdWebhooksWhileOffline?: boolean;
35}
36
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. A link
43 * the relay rejected (usually after a Deregister) gives no access at all.
44 */
45function isManagedTunnelActive(
46 linkState: EnvironmentCloudLinkStateResult,
47 relayProviderKind: RelayManagedEndpointProviderKind | undefined,
48) {
49 const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked;
50 return (
51 linkState.linkRevoked !== true &&
52 localManagedTunnel &&
53 (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel")
54 );
55}
56
57/**
58 * Drives the primary environment's T3 Connect link. T3 Connect (managed
59 * tunnel) and agent-activity publishing are independent capabilities backed by
60 * a single relay link, so consumers express the full desired state and
61 * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise
62 * (re)link with the mode the managed-tunnel bit implies and set the publish
63 * preference. Re-linking only happens when the managed-tunnel mode actually
64 * changes, so flipping publish alone is cheap.
65 */
66export function useCloudLinkController() {
67 const { getToken, isSignedIn } = useAuth();
68 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
69 reportFailure: false,
70 });
71 const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, {
72 reportFailure: false,
73 });
74 const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {
75 reportFailure: false,
76 });
77 const updatePrimaryEnvironmentPreferences = useAtomCommand(
78 updatePrimaryEnvironmentPreferencesAtom,
79 { reportFailure: false },
80 );
81 const primaryCloudLinkState = usePrimaryCloudLinkState();
82 const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments;
83 const primaryEnvironmentId = primaryCloudLinkState.target?.environmentId;
84 const relayProviderKind = primaryEnvironmentId
85 ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind
86 : undefined;
87 const [operationError, setOperationError] = useState<string | null>(null);
88
89 const reportUpdateFailure = (cause: unknown) => {
90 const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
91 const traceId = findErrorTraceId(cause);
92 console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
93 setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
94 toastManager.add({
95 type: "error",
96 title: "Could not update T3 Connect",
97 description: message,
98 data: traceId
99 ? {
100 secondaryActionProps: {
101 children: "Copy trace ID",
102 onClick: () => void navigator.clipboard?.writeText(traceId),
103 },
104 }
105 : undefined,
106 });
107 };
108
109 const linkData = primaryCloudLinkState.data;
110 // A revoked link reads as off; turning it on links again.
111 const linkRevoked = linkData?.linkRevoked ?? false;
112 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
113 // Local state says managed but the relay holds a publish-only link. Turning
114 // T3 Connect on relinks as managed, which repairs it.
115 const managedTunnelOutOfSync =
116 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
117 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
118 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
119 const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false);
120
121 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
122 setOperationError(null);
123 const target = primaryCloudLinkState.target;
124 if (!target) {
125 reportUpdateFailure(new Error("Local environment is not ready yet."));
126 return false;
127 }
128 const environmentId = EnvironmentId.make(target.environmentId);
129 const canManageLink = () => {
130 if (
131 !readEnvironmentScope(environmentId, AuthRelayReadScope) ||
132 !readEnvironmentScope(environmentId, AuthRelayWriteScope)
133 ) {
134 reportUpdateFailure(
135 new Error("This connection needs permission to view and manage T3 Connect settings."),
136 );
137 return false;
138 }
139 return true;
140 };
141 const readLinkState = () => {
142 const state = readCachedPrimaryCloudLinkState(target);
143 if (state === null) {
144 reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read."));
145 }
146 return state;
147 };
148 if (!canManageLink()) return false;
149 const wantsLink = desired.managedTunnel || desired.publish;
150 if (wantsLink && readLinkState() === null) return false;
151 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
152 if (!canManageLink()) return false;
153
154 // A failure after this point may follow a partially applied mutation (e.g.
155 // the link succeeded but the preference update did not), so every exit —
156 // success or failure — refreshes the rendered state to whatever the server
157 // actually holds now.
158 if (!wantsLink) {
159 // Unlink works without a relay token — a failed token read must not
160 // leave the user unable to turn T3 Connect off.
161 const unlinkResult = await unlinkPrimaryEnvironment({
162 target,
163 clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null,
164 });
165 if (unlinkResult._tag === "Failure") {
166 if (!isAtomCommandInterrupted(unlinkResult)) {
167 reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
168 }
169 primaryCloudLinkState.refresh();
170 return false;
171 }
172 } else {
173 const currentLinkState = readLinkState();
174 if (currentLinkState === null) return false;
175 if (tokenResult._tag === "Failure") {
176 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
177 return false;
178 }
179 const clerkToken = tokenResult.value;
180 if (!clerkToken) {
181 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
182 return false;
183 }
184 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);
185 if (
186 !currentLinkState.linked ||
187 currentLinkState.linkRevoked === true ||
188 currentManagedTunnel !== desired.managedTunnel
189 ) {
190 const linkResult = await linkPrimaryEnvironment({
191 target,
192 clerkToken,
193 mode: desired.managedTunnel ? "managed" : "publish_only",
194 });
195 if (linkResult._tag === "Failure") {
196 if (!isAtomCommandInterrupted(linkResult)) {
197 reportUpdateFailure(squashAtomCommandFailure(linkResult));
198 }
199 primaryCloudLinkState.refresh();
200 return false;
201 }
202 }
203 if (!canManageLink() || readLinkState() === null) {
204 primaryCloudLinkState.refresh();
205 return false;
206 }
207 const prefResult = await updatePrimaryEnvironmentPreferences({
208 target,
209 publishAgentActivity: desired.publish,
210 ...(desired.holdWebhooksWhileOffline === undefined
211 ? {}
212 : { holdWebhooksWhileOffline: desired.holdWebhooksWhileOffline }),
213 });
214 if (prefResult._tag === "Failure") {
215 if (!isAtomCommandInterrupted(prefResult)) {
216 reportUpdateFailure(squashAtomCommandFailure(prefResult));
217 }
218 primaryCloudLinkState.refresh();
219 return false;
220 }
221 }
222
223 primaryCloudLinkState.refresh();
224 const refreshResult = await refreshRelayEnvironments();
225 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
226 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
227 return false;
228 }
229 return true;
230 };
231
232 return {
233 isSignedIn,
234 linkState: primaryCloudLinkState,
235 linked,
236 linkRevoked,
237 managedTunnelActive,
238 managedTunnelOutOfSync,
239 publishAgentActivity,
240 holdWebhooksWhileOffline,
241 operationError,
242 reconcileCloudState,
243 };
244}