1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import { HttpClient } from "effect/http";
7import {
8 EnvironmentCloudEndpointUnavailableError,
9 type EnvironmentCloudLinkStateResult,
10 EnvironmentHttpBadRequestError,
11 EnvironmentHttpConflictError,
12 EnvironmentHttpForbiddenError,
13 EnvironmentHttpInternalServerError,
14 EnvironmentHttpUnauthorizedError,
15 EnvironmentId,
16 WS_METHODS,
17} from "@t3tools/contracts";
18import {
19 type RelayEnvironmentLinkResponse,
20 type RelayManagedEndpointProviderKind,
21} from "@t3tools/contracts/relay";
22import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
23import { request, runStream } from "@t3tools/client-runtime/rpc";
24import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
25import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay";
26
27import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
28import { resolveCloudPublicConfig } from "./publicConfig";
29import {
30 finishRelayClientInstall,
31 reportRelayClientInstallProgress,
32 requestRelayClientInstallConfirmation,
33} from "./relayClientInstallDialog";
34
35function relayUrl(): string | null {
36 return resolveCloudPublicConfig().relayUrl;
37}
38
39export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
40 readonly message: string;
41 readonly cause?: unknown;
42 readonly traceId?: string;
43}> {}
44
45const relayClientRpcError = (message: string) => (cause: unknown) =>
46 new CloudEnvironmentLinkError({
47 message,
48 cause,
49 });
50
51function ensureRelayClientAvailable(
52 environmentId: EnvironmentId,
53): Effect.Effect<void, CloudEnvironmentLinkError, EnvironmentRegistry.EnvironmentRegistry> {
54 return Effect.gen(function* () {
55 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
56 const status = yield* registry
57 .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {}))
58 .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability.")));
59 if (status.status === "available") return;
60 if (status.status === "unsupported") {
61 return yield* new CloudEnvironmentLinkError({
62 message: `The Knife cannot install the relay client automatically on ${status.platform}-${status.arch}.`,
63 });
64 }
65
66 const confirmed = yield* Effect.tryPromise({
67 try: () => requestRelayClientInstallConfirmation(status.version),
68 catch: relayClientRpcError("Could not confirm relay client installation."),
69 });
70 if (!confirmed) {
71 return yield* new CloudEnvironmentLinkError({
72 message: "Relay client installation was cancelled.",
73 });
74 }
75
76 const installed = yield* registry
77 .runStream(
78 environmentId,
79 runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe(
80 Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))),
81 ),
82 )
83 .pipe(
84 Stream.runLast,
85 Effect.mapError(relayClientRpcError("Could not install the relay client.")),
86 Effect.ensuring(Effect.sync(finishRelayClientInstall)),
87 );
88 if (Option.isNone(installed) || installed.value.type !== "complete") {
89 return yield* new CloudEnvironmentLinkError({
90 message: "The relay client install completed without a final status.",
91 });
92 }
93 const installedStatus = installed.value.status;
94 if (installedStatus.status !== "available") {
95 return yield* new CloudEnvironmentLinkError({
96 message:
97 installedStatus.status === "unsupported"
98 ? `The Knife cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.`
99 : "The relay client is still unavailable after installation.",
100 });
101 }
102 });
103}
104
105const isEnvironmentCloudApiError = Schema.is(
106 Schema.Union([
107 EnvironmentHttpBadRequestError,
108 EnvironmentHttpUnauthorizedError,
109 EnvironmentHttpForbiddenError,
110 EnvironmentHttpConflictError,
111 EnvironmentHttpInternalServerError,
112 EnvironmentCloudEndpointUnavailableError,
113 ]),
114);
115
116function decodedRelayClientError(message: string) {
117 return (cause: ManagedRelay.ManagedRelayClientError) => {
118 const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined;
119 const traceId = failed?.traceId;
120 const detail = failed ? relayRequestFailureDetail(failed) : null;
121 return new CloudEnvironmentLinkError({
122 message: detail ? `${message}: ${detail}` : message,
123 cause,
124 ...(traceId ? { traceId } : {}),
125 });
126 };
127}
128
129function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
130 if (isEnvironmentCloudApiError(cause)) {
131 return cause;
132 }
133 if (typeof cause !== "object" || cause === null) {
134 return null;
135 }
136 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
137}
138
139const environmentApiError = (message: string) => (cause: unknown) => {
140 const environmentError = findEnvironmentCloudApiError(cause);
141 return new CloudEnvironmentLinkError({
142 message: environmentError
143 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
144 : message,
145 cause,
146 });
147};
148
149function endpointOrigin(httpBaseUrl: string) {
150 const url = new URL(httpBaseUrl);
151 return {
152 localHttpHost: "127.0.0.1",
153 localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
154 };
155}
156
157const MANAGED_ENDPOINT_PROVIDER_KIND =
158 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
159
160function ensureLinkedEnvironmentMatches(input: {
161 readonly expectedEnvironmentId: string;
162 readonly expectedProviderKind: RelayManagedEndpointProviderKind;
163 readonly link: RelayEnvironmentLinkResponse;
164}): Effect.Effect<void, CloudEnvironmentLinkError> {
165 if (input.link.environmentId !== input.expectedEnvironmentId) {
166 return new CloudEnvironmentLinkError({
167 message: "Relay returned credentials for a different environment.",
168 });
169 }
170 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {
171 return new CloudEnvironmentLinkError({
172 message: "Relay returned credentials for a different endpoint provider.",
173 });
174 }
175 return Effect.void;
176}
177
178export interface CloudLinkTarget {
179 readonly environmentId: string;
180 readonly label: string;
181 readonly httpBaseUrl: string;
182 readonly wsBaseUrl: string;
183}
184
185export type CloudLinkState = EnvironmentCloudLinkStateResult;
186
187export function readPrimaryCloudLinkState(input: {
188 readonly target: CloudLinkTarget;
189}): Effect.Effect<CloudLinkState | null, CloudEnvironmentLinkError, HttpClient.HttpClient> {
190 return Effect.gen(function* () {
191 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
192 return yield* client.connect
193 .linkState({ headers: {} })
194 .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state.")));
195 }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer));
196}
197
198export function updatePrimaryCloudPreferences(input: {
199 readonly target: CloudLinkTarget;
200 readonly publishAgentActivity: boolean;
201 readonly holdWebhooksWhileOffline?: boolean;
202}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
203 return Effect.gen(function* () {
204 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
205 const { target: _target, ...payload } = input;
206 return yield* client.connect
207 .preferences({
208 headers: {},
209 payload,
210 })
211 .pipe(
212 Effect.mapError(environmentApiError("Could not update environment cloud preferences.")),
213 );
214 }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer));
215}
216
217export function unlinkPrimaryEnvironmentFromCloud(input: {
218 readonly target: CloudLinkTarget;
219 readonly clerkToken: string | null;
220}): Effect.Effect<
221 void,
222 CloudEnvironmentLinkError,
223 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
224> {
225 return Effect.gen(function* () {
226 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
227 yield* client.connect
228 .unlink({ headers: {} })
229 .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")));
230
231 const configuredRelayUrl = relayUrl();
232 if (configuredRelayUrl && input.clerkToken) {
233 const relayClient = yield* ManagedRelay.ManagedRelayClient;
234 yield* relayClient
235 .unlinkEnvironment({
236 clerkToken: input.clerkToken,
237 environmentId: EnvironmentId.make(input.target.environmentId),
238 })
239 .pipe(
240 Effect.catch((cause) =>
241 Effect.logWarning("Could not revoke cloud environment link after local unlink.", {
242 cause,
243 }),
244 ),
245 );
246 }
247 }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer));
248}
249
250// "publish_only" links the environment to the relay for agent-activity
251// publishing alone: no managed tunnel is provisioned, so it can be toggled
252// independently of T3 Connect while clients reach the environment out of band.
253export type CloudLinkMode = "managed" | "publish_only";
254
255const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind;
256
257export function linkPrimaryEnvironmentToCloud(input: {
258 readonly target: CloudLinkTarget;
259 readonly clerkToken: string;
260 readonly mode?: CloudLinkMode;
261}): Effect.Effect<
262 void,
263 CloudEnvironmentLinkError,
264 EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
265> {
266 return Effect.gen(function* () {
267 const configuredRelayUrl = relayUrl();
268 if (!configuredRelayUrl) {
269 return yield* new CloudEnvironmentLinkError({
270 message: "T3CODE_RELAY_URL is not configured.",
271 });
272 }
273 const managedTunnelsEnabled = (input.mode ?? "managed") === "managed";
274 const providerKind = managedTunnelsEnabled
275 ? MANAGED_ENDPOINT_PROVIDER_KIND
276 : PUBLISH_ONLY_PROVIDER_KIND;
277 const relayClient = yield* ManagedRelay.ManagedRelayClient;
278 const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
279 if (managedTunnelsEnabled) {
280 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
281 }
282
283 const challenge = yield* relayClient
284 .createEnvironmentLinkChallenge({
285 clerkToken: input.clerkToken,
286 payload: {
287 notificationsEnabled: true,
288 liveActivitiesEnabled: true,
289 managedTunnelsEnabled,
290 },
291 })
292 .pipe(
293 Effect.mapError(
294 decodedRelayClientError(
295 `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,
296 ),
297 ),
298 );
299 const proof = yield* environmentClient.connect
300 .linkProof({
301 headers: {},
302 payload: {
303 challenge: challenge.challenge,
304 relayIssuer: configuredRelayUrl,
305 endpoint: {
306 httpBaseUrl: input.target.httpBaseUrl,
307 wsBaseUrl: input.target.wsBaseUrl,
308 providerKind,
309 },
310 origin: endpointOrigin(input.target.httpBaseUrl),
311 },
312 })
313 .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof.")));
314 const link = yield* relayClient
315 .linkEnvironment({
316 clerkToken: input.clerkToken,
317 payload: {
318 proof,
319 notificationsEnabled: true,
320 liveActivitiesEnabled: true,
321 managedTunnelsEnabled,
322 },
323 })
324 .pipe(
325 Effect.mapError(
326 decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),
327 ),
328 );
329 yield* ensureLinkedEnvironmentMatches({
330 expectedEnvironmentId: input.target.environmentId,
331 expectedProviderKind: providerKind,
332 link,
333 });
334
335 yield* environmentClient.connect
336 .relayConfig({
337 headers: {},
338 payload: {
339 relayUrl: configuredRelayUrl,
340 relayIssuer: link.relayIssuer,
341 cloudUserId: link.cloudUserId,
342 environmentCredential: link.environmentCredential,
343 cloudMintPublicKey: link.cloudMintPublicKey,
344 endpointRuntime: link.endpointRuntime,
345 },
346 })
347 .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access.")));
348 }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer));
349}