History

[codex] Structure web cloud-link failures (#3316)

Julius Marminge committed 4 months ago

Commit 65ed5d91 parent 252622a2 files · +674 −163Browse files

Unified · Split

2 files
  1. apps/web/src/cloud/linkEnvironment.test.ts +251 −3
  2. apps/web/src/cloud/linkEnvironment.ts +423 −160
apps/web/src/cloud/linkEnvironment.test.ts+251−3
@@ -2,12 +2,14 @@ import { · 1 unmodified line
2 type DesktopBridge,2 type DesktopBridge,
3 EnvironmentId,3 EnvironmentId,
4 type RelayClientInstallProgressEvent,4 type RelayClientInstallProgressEvent,
5 type RelayClientStatus,
5 WS_METHODS,6 WS_METHODS,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { RelayWebClientId } from "@t3tools/contracts/relay";8import { RelayWebClientId } from "@t3tools/contracts/relay";
8import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
12import * as Logger from "effect/Logger";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
12import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";15import * as SubscriptionRef from "effect/SubscriptionRef";
@@ -26,7 +28,9 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; · 12 unmodified lines
26import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";28import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";
27 29
28import {30import {
31 CloudEnvironmentLinkOperationError,
29 collectCloudLinkTargets,32 collectCloudLinkTargets,
33 isCloudEnvironmentLinkError,
30 linkPrimaryEnvironmentToCloud,34 linkPrimaryEnvironmentToCloud,
31 listManagedCloudEnvironments,35 listManagedCloudEnvironments,
32 normalizeRelayBaseUrl,36 normalizeRelayBaseUrl,
@@ -48,12 +52,21 @@ const relayClientInstallDialog = vi.hoisted(() => ({ · 15 unmodified lines
48 finish: vi.fn(),52 finish: vi.fn(),
49}));53}));
50 54
55const cloudPublicConfig = vi.hoisted(() => ({
56 relayUrl: "https://relay.example.test",
57}));
58
51vi.mock("./relayClientInstallDialog", () => ({59vi.mock("./relayClientInstallDialog", () => ({
52 requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation,60 requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation,
53 reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress,61 reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress,
54 finishRelayClientInstall: relayClientInstallDialog.finish,62 finishRelayClientInstall: relayClientInstallDialog.finish,
55}));63}));
56 64
65vi.mock("./publicConfig", async (importOriginal) => ({
66 ...(await importOriginal<typeof import("./publicConfig")>()),
67 resolveCloudPublicConfig: () => ({ relayUrl: cloudPublicConfig.relayUrl }),
68}));
69
57const createProof = vi.fn(() => Effect.succeed("dpop-proof"));70const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
58const dpopSignerLayer = Layer.succeed(71const dpopSignerLayer = Layer.succeed(
59 ManagedRelay.ManagedRelayDpopSigner,72 ManagedRelay.ManagedRelayDpopSigner,
@@ -75,7 +88,7 @@ function relayLayer() { · 15 unmodified lines
75}88}
76 89
77function registryLayer(options?: {90function registryLayer(options?: {
78 readonly status?: { readonly status: "available"; readonly version: string };91 readonly status?: RelayClientStatus;
79 readonly installEvents?: ReadonlyArray<RelayClientInstallProgressEvent>;92 readonly installEvents?: ReadonlyArray<RelayClientInstallProgressEvent>;
80}) {93}) {
81 return Layer.effect(94 return Layer.effect(
@@ -83,7 +96,14 @@ function registryLayer(options?: { · 1 unmodified line
83 Effect.gen(function* () {96 Effect.gen(function* () {
84 const client = {97 const client = {
85 [WS_METHODS.cloudGetRelayClientStatus]: () =>98 [WS_METHODS.cloudGetRelayClientStatus]: () =>
86 Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }),99 Effect.succeed(
100 options?.status ?? {
101 status: "available",
102 executablePath: "/managed/cloudflared",
103 source: "managed",
104 version: "2026.6.0",
105 },
106 ),
87 [WS_METHODS.cloudInstallRelayClient]: () =>107 [WS_METHODS.cloudInstallRelayClient]: () =>
88 Stream.fromIterable(options?.installEvents ?? []),108 Stream.fromIterable(options?.installEvents ?? []),
89 } as unknown as RpcSession["client"];109 } as unknown as RpcSession["client"];
@@ -141,6 +161,7 @@ function bodyText(body: BodyInit | null | undefined): string { · 51 unmodified lines
141 161
142beforeEach(() => {162beforeEach(() => {
143 vi.clearAllMocks();163 vi.clearAllMocks();
164 cloudPublicConfig.relayUrl = "https://relay.example.test";
144 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");165 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
145 relayClientInstallDialog.requestConfirmation.mockResolvedValue(true);166 relayClientInstallDialog.requestConfirmation.mockResolvedValue(true);
146});167});
@@ -195,6 +216,47 @@ describe("web cloud link environment client", () => { · 48 unmodified lines
195 }),216 }),
196 );217 );
197 218
219 it.effect("preserves structured relay failures and trace IDs", () =>
220 Effect.gen(function* () {
221 vi.stubGlobal(
222 "fetch",
223 vi.fn().mockResolvedValue(
224 Response.json(
225 {
226 _tag: "RelayAuthInvalidError",
227 code: "auth_invalid",
228 reason: "invalid_bearer",
229 traceId: "trace-web-cloud-link",
230 },
231 { status: 401 },
232 ),
233 ),
234 );
235
236 const error = yield* withServices(
237 listManagedCloudEnvironments({ clerkToken: "clerk-token" }),
238 ).pipe(Effect.flip);
239
240 expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError);
241 expect(error).toMatchObject({
242 action: "list relay-managed environments",
243 relayUrlInputLength: "https://relay.example.test".length,
244 relayUrlProtocol: "https:",
245 relayUrlHostname: "relay.example.test",
246 traceId: "trace-web-cloud-link",
247 relayError: {
248 _tag: "RelayAuthInvalidError",
249 reason: "invalid_bearer",
250 },
251 cause: {
252 _tag: "ManagedRelayRequestFailedError",
253 },
254 });
255 expect(error.message).toBe("Could not list relay-managed environments.");
256 expect(isCloudEnvironmentLinkError(error)).toBe(true);
257 }),
258 );
259
198 it.effect("reads primary cloud link state from the explicit target", () =>260 it.effect("reads primary cloud link state from the explicit target", () =>
199 Effect.gen(function* () {261 Effect.gen(function* () {
200 const fetchMock = vi.fn().mockResolvedValue(262 const fetchMock = vi.fn().mockResolvedValue(
@@ -225,6 +287,96 @@ describe("web cloud link environment client", () => { · 24 unmodified lines
225 }),287 }),
226 );288 );
227 289
290 it.effect("preserves structured environment API failures and their cause chain", () =>
291 Effect.gen(function* () {
292 vi.stubGlobal(
293 "fetch",
294 vi.fn().mockResolvedValue(
295 Response.json(
296 {
297 _tag: "EnvironmentHttpUnauthorizedError",
298 message: "Environment bearer token is invalid.",
299 },
300 { status: 401 },
301 ),
302 ),
303 );
304
305 const error = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })).pipe(
306 Effect.flip,
307 );
308
309 expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError);
310 expect(error).toMatchObject({
311 action: "read environment cloud link state",
312 environmentId: TARGET.environmentId,
313 httpBaseUrlInputLength: TARGET.httpBaseUrl.length,
314 httpBaseUrlProtocol: "http:",
315 httpBaseUrlHostname: "127.0.0.1",
316 environmentError: {
317 _tag: "EnvironmentHttpUnauthorizedError",
318 message: "Environment bearer token is invalid.",
319 },
320 });
321 expect(error.cause).toBeDefined();
322 expect(error.message).toBe(
323 `Could not read environment cloud link state for environment "${TARGET.environmentId}".`,
324 );
325 expect(isCloudEnvironmentLinkError(error)).toBe(true);
326 }),
327 );
328
329 it.effect("preserves invalid environment HTTP URL parser causes", () =>
330 Effect.gen(function* () {
331 const invalidUrl =
332 "https://user:password@[invalid-host]/private/path?access_token=secret#fragment";
333 const error = yield* withServices(
334 readPrimaryCloudLinkState({
335 target: {
336 ...TARGET,
337 httpBaseUrl: invalidUrl,
338 },
339 }),
340 ).pipe(Effect.flip);
341
342 expect(error).toMatchObject({
343 _tag: "CloudEnvironmentLinkOperationError",
344 action: "initialize the environment HTTP client",
345 environmentId: TARGET.environmentId,
346 httpBaseUrlInputLength: invalidUrl.length,
347 });
348 expect(error.cause).toBeInstanceOf(TypeError);
349 expect(error).not.toHaveProperty("httpBaseUrl");
350 expect(error).not.toHaveProperty("httpBaseUrlProtocol");
351 expect(error).not.toHaveProperty("httpBaseUrlHostname");
352 expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
353 }),
354 );
355
356 it("keeps environment endpoint secrets out of mapped error attributes", () => {
357 const httpBaseUrl =
358 "https://user:password@environment.example.test/private/path?access_token=secret#fragment";
359 const cause = new Error("request failed");
360 const error = CloudEnvironmentLinkOperationError.fromEnvironmentApi({
361 action: "read environment cloud link state",
362 environmentId: TARGET.environmentId,
363 httpBaseUrl,
364 cause,
365 });
366
367 expect(error).toMatchObject({
368 httpBaseUrlInputLength: httpBaseUrl.length,
369 httpBaseUrlProtocol: "https:",
370 httpBaseUrlHostname: "environment.example.test",
371 cause,
372 });
373 expect(error.cause).toBe(cause);
374 expect(error).not.toHaveProperty("httpBaseUrl");
375 const diagnostics = JSON.stringify(error);
376 expect(diagnostics).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
377 expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
378 });
379
228 it.effect("uses desktop bearer auth for primary cloud link state", () =>380 it.effect("uses desktop bearer auth for primary cloud link state", () =>
229 Effect.gen(function* () {381 Effect.gen(function* () {
230 const fetchMock = vi.fn().mockResolvedValue(382 const fetchMock = vi.fn().mockResolvedValue(
@@ -306,6 +458,37 @@ describe("web cloud link environment client", () => { · 75 unmodified lines
306 }),458 }),
307 );459 );
308 460
461 it.effect("validates the environment endpoint before prompting to install a relay client", () =>
462 Effect.gen(function* () {
463 const invalidUrl =
464 "https://user:password@[invalid-host]/private/path?access_token=secret#fragment";
465
466 const error = yield* withServices(
467 linkPrimaryEnvironmentToCloud({
468 target: {
469 ...TARGET,
470 httpBaseUrl: invalidUrl,
471 },
472 clerkToken: "clerk-token",
473 }),
474 {
475 status: { status: "missing", version: "2026.6.0" },
476 },
477 ).pipe(Effect.flip);
478
479 expect(error).toMatchObject({
480 _tag: "CloudEnvironmentLinkOperationError",
481 action: "derive the environment endpoint origin",
482 environmentId: TARGET.environmentId,
483 httpBaseUrlInputLength: invalidUrl.length,
484 });
485 expect(error.cause).toBeInstanceOf(TypeError);
486 expect(error).not.toHaveProperty("httpBaseUrl");
487 expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
488 expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled();
489 }),
490 );
491
309 it.effect("installs a missing relay client before linking", () =>492 it.effect("installs a missing relay client before linking", () =>
310 Effect.gen(function* () {493 Effect.gen(function* () {
311 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true })));494 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true })));
@@ -316,7 +499,12 @@ describe("web cloud link environment client", () => { · 4 unmodified lines
316 clerkToken: "clerk-token",499 clerkToken: "clerk-token",
317 }),500 }),
318 {501 {
319 status: { status: "available", version: "2026.6.0" },502 status: {
503 status: "available",
504 executablePath: "/managed/cloudflared",
505 source: "managed",
506 version: "2026.6.0",
507 },
320 installEvents: [],508 installEvents: [],
321 },509 },
322 ).pipe(Effect.flip);510 ).pipe(Effect.flip);
@@ -348,4 +536,64 @@ describe("web cloud link environment client", () => { · 25 unmodified lines
348 );536 );
349 }),537 }),
350 );538 );
539
540 it.effect("keeps configured relay URL secrets out of revoke warnings", () => {
541 const relayUrl =
542 "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment";
543 cloudPublicConfig.relayUrl = relayUrl;
544 const capturedLogs: Array<ReadonlyArray<unknown>> = [];
545 const logger = Logger.make(({ message }) => {
546 capturedLogs.push(Array.isArray(message) ? message : [message]);
547 });
548 const fetchMock = vi
549 .fn()
550 .mockResolvedValueOnce(
551 Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
552 )
553 .mockResolvedValueOnce(Response.json({ malformed: true }, { status: 500 }));
554 vi.stubGlobal("fetch", fetchMock);
555
556 return unlinkPrimaryEnvironmentFromCloud({
557 target: TARGET,
558 clerkToken: "clerk-token",
559 }).pipe(
560 Effect.provide(
561 Layer.merge(
562 services(),
563 Logger.layer([logger], {
564 mergeWithExisting: false,
565 }),
566 ),
567 ),
568 Effect.tap(() =>
569 Effect.sync(() => {
570 expect(capturedLogs).toHaveLength(1);
571 const logFields = capturedLogs[0]?.find(
572 (value): value is Record<string, unknown> =>
573 typeof value === "object" && value !== null,
574 );
575 expect(logFields).toMatchObject({
576 relayUrlInputLength: relayUrl.length,
577 relayUrlProtocol: "https:",
578 relayUrlHostname: "relay.example.test",
579 causeTag: "ManagedRelayRequestFailedError",
580 });
581 expect(logFields).not.toHaveProperty("relayUrl");
582 expect(logFields).not.toHaveProperty("cause");
583 const logText = capturedLogs[0]
584 ?.filter((value): value is string => typeof value === "string")
585 .join(" ");
586 for (const secret of [
587 "relay-user",
588 "relay-password",
589 "/private/workspace",
590 "access_token=relay-secret",
591 "relay-fragment",
592 ]) {
593 expect(logText).not.toContain(secret);
594 }
595 }),
596 ),
597 );
598 });
351});599});
apps/web/src/cloud/linkEnvironment.ts+423−160
@@ -1,4 +1,3 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";2import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
@@ -19,13 +18,14 @@ import { · 14 unmodified lines
19 type RelayClientDeviceRecord,18 type RelayClientDeviceRecord,
20 type RelayClientEnvironmentRecord,19 type RelayClientEnvironmentRecord,
21 type RelayEnvironmentLinkResponse,20 type RelayEnvironmentLinkResponse,
22 type RelayProtectedError as RelayProtectedErrorType,
23 type RelayManagedEndpointProviderKind,21 type RelayManagedEndpointProviderKind,
22 RelayProtectedError,
24} from "@t3tools/contracts/relay";23} from "@t3tools/contracts/relay";
25import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";24import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
26import { request, runStream } from "@t3tools/client-runtime/rpc";25import { request, runStream } from "@t3tools/client-runtime/rpc";
27import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";26import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";27import { ManagedRelay } from "@t3tools/client-runtime/relay";
28import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
29 29
30import {30import {
31 readPrimaryEnvironmentDescriptor,31 readPrimaryEnvironmentDescriptor,
@@ -51,17 +51,218 @@ function relayUrl(): string | null { · 19 unmodified lines
51 return resolveCloudPublicConfig().relayUrl;51 return resolveCloudPublicConfig().relayUrl;
52}52}
53 53
54export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{54const EnvironmentCloudApiError = Schema.Union([
55 readonly message: string;55 EnvironmentHttpBadRequestError,
56 readonly cause?: unknown;56 EnvironmentHttpUnauthorizedError,
57 readonly traceId?: string;57 EnvironmentHttpForbiddenError,
58}> {}58 EnvironmentHttpConflictError,
59 EnvironmentHttpInternalServerError,
60 EnvironmentCloudEndpointUnavailableError,
61]);
62type EnvironmentCloudApiError = typeof EnvironmentCloudApiError.Type;
63const isEnvironmentCloudApiError = Schema.is(EnvironmentCloudApiError);
59 64
60const relayClientRpcError = (message: string) => (cause: unknown) =>65function relayUrlDiagnosticFields(relayUrl: string | undefined) {
61 new CloudEnvironmentLinkError({66 if (relayUrl === undefined) return {};
62 message,67 const diagnostics = getUrlDiagnostics(relayUrl);
63 cause,68 return {
64 });69 relayUrlInputLength: diagnostics.inputLength,
70 ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }),
71 ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }),
72 };
73}
74
75function httpBaseUrlDiagnosticFields(httpBaseUrl: string | undefined) {
76 if (httpBaseUrl === undefined) return {};
77 const diagnostics = getUrlDiagnostics(httpBaseUrl);
78 return {
79 httpBaseUrlInputLength: diagnostics.inputLength,
80 ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }),
81 ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }),
82 };
83}
84
85export const CloudEnvironmentLinkAction = Schema.Literals([
86 "check relay client availability",
87 "confirm relay client installation",
88 "install the relay client",
89 "list relay-managed environments",
90 "list cloud devices",
91 "read environment cloud link state",
92 "update environment cloud preferences",
93 "unlink the environment from cloud",
94 "revoke the cloud environment link",
95 "create an environment link challenge",
96 "obtain an environment link proof",
97 "link the environment",
98 "derive the environment endpoint origin",
99 "initialize the environment HTTP client",
100 "configure environment relay access",
101]);
102export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type;
103
104export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass<CloudEnvironmentLinkOperationError>()(
105 "CloudEnvironmentLinkOperationError",
106 {
107 action: CloudEnvironmentLinkAction,
108 environmentId: Schema.optionalKey(Schema.String),
109 relayUrlInputLength: Schema.optionalKey(Schema.Number),
110 relayUrlProtocol: Schema.optionalKey(Schema.String),
111 relayUrlHostname: Schema.optionalKey(Schema.String),
112 httpBaseUrlInputLength: Schema.optionalKey(Schema.Number),
113 httpBaseUrlProtocol: Schema.optionalKey(Schema.String),
114 httpBaseUrlHostname: Schema.optionalKey(Schema.String),
115 traceId: Schema.optionalKey(Schema.String),
116 relayError: Schema.optionalKey(RelayProtectedError),
117 environmentError: Schema.optionalKey(EnvironmentCloudApiError),
118 cause: Schema.Defect(),
119 },
120) {
121 static fromManagedRelay(input: {
122 readonly action: CloudEnvironmentLinkAction;
123 readonly cause: ManagedRelay.ManagedRelayClientError;
124 readonly environmentId?: string;
125 readonly relayUrl?: string;
126 readonly httpBaseUrl?: string;
127 }): CloudEnvironmentLinkOperationError {
128 const requestFailure =
129 input.cause._tag === "ManagedRelayRequestFailedError" ? input.cause : undefined;
130 return new CloudEnvironmentLinkOperationError({
131 action: input.action,
132 cause: input.cause,
133 ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }),
134 ...relayUrlDiagnosticFields(input.relayUrl),
135 ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
136 ...(requestFailure?.traceId === undefined ? {} : { traceId: requestFailure.traceId }),
137 ...(requestFailure?.relayError === undefined
138 ? {}
139 : { relayError: requestFailure.relayError }),
140 });
141 }
142
143 static fromEnvironmentApi(input: {
144 readonly action: CloudEnvironmentLinkAction;
145 readonly cause: unknown;
146 readonly environmentId: string;
147 readonly httpBaseUrl: string;
148 }): CloudEnvironmentLinkOperationError {
149 const environmentError = CloudEnvironmentLinkOperationError.findEnvironmentApiError(
150 input.cause,
151 );
152 return new CloudEnvironmentLinkOperationError({
153 action: input.action,
154 environmentId: input.environmentId,
155 ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
156 cause: input.cause,
157 ...(environmentError === undefined ? {} : { environmentError }),
158 });
159 }
160
161 private static findEnvironmentApiError(cause: unknown): EnvironmentCloudApiError | undefined {
162 const seen = new Set<unknown>();
163 let current = cause;
164 while (typeof current === "object" && current !== null && !seen.has(current)) {
165 if (isEnvironmentCloudApiError(current)) {
166 return current;
167 }
168 seen.add(current);
169 current = "cause" in current ? current.cause : undefined;
170 }
171 return undefined;
172 }
173
174 override get message(): string {
175 const environment =
176 this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`;
177 return `Could not ${this.action}${environment}.`;
178 }
179}
180
181export class CloudRelayUrlNotConfiguredError extends Schema.TaggedErrorClass<CloudRelayUrlNotConfiguredError>()(
182 "CloudRelayUrlNotConfiguredError",
183 { environmentId: Schema.optionalKey(Schema.String) },
184) {
185 override get message(): string {
186 return "T3CODE_RELAY_URL is not configured.";
187 }
188}
189
190export class CloudRelayClientInstallUnsupportedError extends Schema.TaggedErrorClass<CloudRelayClientInstallUnsupportedError>()(
191 "CloudRelayClientInstallUnsupportedError",
192 {
193 environmentId: Schema.String,
194 phase: Schema.Literals(["preflight", "post-install"]),
195 version: Schema.String,
196 platform: Schema.String,
197 architecture: Schema.String,
198 },
199) {
200 override get message(): string {
201 return `T3 Code cannot install the relay client automatically on ${this.platform}-${this.architecture}.`;
202 }
203}
204
205export class CloudRelayClientInstallCancelledError extends Schema.TaggedErrorClass<CloudRelayClientInstallCancelledError>()(
206 "CloudRelayClientInstallCancelledError",
207 {
208 environmentId: Schema.String,
209 version: Schema.String,
210 },
211) {
212 override get message(): string {
213 return "Relay client installation was cancelled.";
214 }
215}
216
217export class CloudRelayClientInstallIncompleteError extends Schema.TaggedErrorClass<CloudRelayClientInstallIncompleteError>()(
218 "CloudRelayClientInstallIncompleteError",
219 {
220 environmentId: Schema.String,
221 version: Schema.String,
222 },
223) {
224 override get message(): string {
225 return "The relay client install completed without a final status.";
226 }
227}
228
229export class CloudRelayClientUnavailableAfterInstallError extends Schema.TaggedErrorClass<CloudRelayClientUnavailableAfterInstallError>()(
230 "CloudRelayClientUnavailableAfterInstallError",
231 {
232 environmentId: Schema.String,
233 version: Schema.String,
234 },
235) {
236 override get message(): string {
237 return "The relay client is still unavailable after installation.";
238 }
239}
240
241export class CloudEnvironmentLinkResponseMismatchError extends Schema.TaggedErrorClass<CloudEnvironmentLinkResponseMismatchError>()(
242 "CloudEnvironmentLinkResponseMismatchError",
243 {
244 environmentId: Schema.String,
245 field: Schema.Literals(["environment id", "endpoint provider"]),
246 expected: Schema.String,
247 actual: Schema.String,
248 },
249) {
250 override get message(): string {
251 return `Relay returned link credentials with an unexpected ${this.field}.`;
252 }
253}
254
255export const CloudEnvironmentLinkError = Schema.Union([
256 CloudEnvironmentLinkOperationError,
257 CloudRelayUrlNotConfiguredError,
258 CloudRelayClientInstallUnsupportedError,
259 CloudRelayClientInstallCancelledError,
260 CloudRelayClientInstallIncompleteError,
261 CloudRelayClientUnavailableAfterInstallError,
262 CloudEnvironmentLinkResponseMismatchError,
263]);
264export type CloudEnvironmentLinkError = typeof CloudEnvironmentLinkError.Type;
265export const isCloudEnvironmentLinkError = Schema.is(CloudEnvironmentLinkError);
65 266
66function ensureRelayClientAvailable(267function ensureRelayClientAvailable(
67 environmentId: EnvironmentId,268 environmentId: EnvironmentId,
@@ -70,21 +271,40 @@ function ensureRelayClientAvailable( · 2 unmodified lines
70 const registry = yield* EnvironmentRegistry;271 const registry = yield* EnvironmentRegistry;
71 const status = yield* registry272 const status = yield* registry
72 .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {}))273 .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {}))
73 .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability.")));274 .pipe(
275 Effect.mapError(
276 (cause) =>
277 new CloudEnvironmentLinkOperationError({
278 action: "check relay client availability",
279 environmentId,
280 cause,
281 }),
282 ),
283 );
74 if (status.status === "available") return;284 if (status.status === "available") return;
75 if (status.status === "unsupported") {285 if (status.status === "unsupported") {
76 return yield* new CloudEnvironmentLinkError({286 return yield* new CloudRelayClientInstallUnsupportedError({
77 message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`,287 environmentId,
288 phase: "preflight",
289 version: status.version,
290 platform: status.platform,
291 architecture: status.arch,
78 });292 });
79 }293 }
80 294
81 const confirmed = yield* Effect.tryPromise({295 const confirmed = yield* Effect.tryPromise({
82 try: () => requestRelayClientInstallConfirmation(status.version),296 try: () => requestRelayClientInstallConfirmation(status.version),
83 catch: relayClientRpcError("Could not confirm relay client installation."),297 catch: (cause) =>
298 new CloudEnvironmentLinkOperationError({
299 action: "confirm relay client installation",
300 environmentId,
301 cause,
302 }),
84 });303 });
85 if (!confirmed) {304 if (!confirmed) {
86 return yield* new CloudEnvironmentLinkError({305 return yield* new CloudRelayClientInstallCancelledError({
87 message: "Relay client installation was cancelled.",306 environmentId,
307 version: status.version,
88 });308 });
89 }309 }
90 310
@@ -97,112 +317,73 @@ function ensureRelayClientAvailable( · 6 unmodified lines
97 )317 )
98 .pipe(318 .pipe(
99 Stream.runLast,319 Stream.runLast,
100 Effect.mapError(relayClientRpcError("Could not install the relay client.")),320 Effect.mapError(
321 (cause) =>
322 new CloudEnvironmentLinkOperationError({
323 action: "install the relay client",
324 environmentId,
325 cause,
326 }),
327 ),
101 Effect.ensuring(Effect.sync(finishRelayClientInstall)),328 Effect.ensuring(Effect.sync(finishRelayClientInstall)),
102 );329 );
103 if (Option.isNone(installed) || installed.value.type !== "complete") {330 if (Option.isNone(installed) || installed.value.type !== "complete") {
104 return yield* new CloudEnvironmentLinkError({331 return yield* new CloudRelayClientInstallIncompleteError({
105 message: "The relay client install completed without a final status.",332 environmentId,
333 version: status.version,
106 });334 });
107 }335 }
108 const installedStatus = installed.value.status;336 const installedStatus = installed.value.status;
109 if (installedStatus.status !== "available") {337 if (installedStatus.status !== "available") {
110 return yield* new CloudEnvironmentLinkError({338 return yield* installedStatus.status === "unsupported"
111 message:339 ? new CloudRelayClientInstallUnsupportedError({
112 installedStatus.status === "unsupported"340 environmentId,
113 ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.`341 phase: "post-install",
114 : "The relay client is still unavailable after installation.",342 version: installedStatus.version,
115 });343 platform: installedStatus.platform,
344 architecture: installedStatus.arch,
345 })
346 : new CloudRelayClientUnavailableAfterInstallError({
347 environmentId,
348 version: installedStatus.version,
349 });
116 }350 }
117 });351 });
118}352}
119 353
120const isEnvironmentCloudApiError = Schema.is(354function endpointOrigin(input: { readonly environmentId: string; readonly httpBaseUrl: string }) {
121 Schema.Union([355 return Effect.try({
122 EnvironmentHttpBadRequestError,356 try: () => {
123 EnvironmentHttpUnauthorizedError,357 const url = new URL(input.httpBaseUrl);
124 EnvironmentHttpForbiddenError,358 return {
125 EnvironmentHttpConflictError,359 localHttpHost: "127.0.0.1",
126 EnvironmentHttpInternalServerError,360 localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
127 EnvironmentCloudEndpointUnavailableError,361 };
128 ]),362 },
129);363 catch: (cause) =>
130 364 new CloudEnvironmentLinkOperationError({
131function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {365 action: "derive the environment endpoint origin",
132 switch (error._tag) {366 environmentId: input.environmentId,
133 case "RelayAuthInvalidError":367 ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
134 switch (error.reason) {368 cause,
135 case "missing_bearer":369 }),
136 case "invalid_bearer":
137 return "Relay rejected the cloud session token.";
138 case "invalid_dpop":
139 return "Relay rejected the DPoP proof.";
140 case "not_authorized":
141 return "Relay rejected the authenticated request.";
142 }
143 case "RelayEnvironmentLinkProofExpiredError":
144 return "Relay rejected an expired environment link proof.";
145 case "RelayEnvironmentLinkProofInvalidError":
146 return `Relay rejected the environment link proof (${error.reason}).`;
147 case "RelayEnvironmentConnectNotAuthorizedError":
148 return "Relay rejected the environment connection request.";
149 case "RelayEnvironmentEndpointUnavailableError":
150 return `Relay could not reach the environment endpoint (${error.reason}).`;
151 case "RelayEnvironmentEndpointTimedOutError":
152 return "Relay timed out while contacting the environment endpoint.";
153 case "RelayEnvironmentLinkFailedError":
154 return `Relay could not link the environment (${error.reason}).`;
155 case "RelayEnvironmentLinkUnavailableError":
156 return `Relay cannot provision the managed endpoint (${error.reason}).`;
157 case "RelayAgentActivityPublishProofExpiredError":
158 return "Relay rejected an expired agent activity publish proof.";
159 case "RelayAgentActivityPublishProofInvalidError":
160 return `Relay rejected the agent activity publish proof (${error.reason}).`;
161 case "RelayInternalError":
162 return `Relay encountered an internal error (${error.reason}).`;
163 }
164}
165
166function decodedRelayClientError(message: string) {
167 return (cause: ManagedRelay.ManagedRelayClientError) => {
168 const relayError =
169 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
170 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
171 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
172 return new CloudEnvironmentLinkError({
173 message: detail ? `${message}: ${detail}` : message,
174 cause,
175 ...(traceId ? { traceId } : {}),
176 });
177 };
178}
179
180function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
181 if (isEnvironmentCloudApiError(cause)) {
182 return cause;
183 }
184 if (typeof cause !== "object" || cause === null) {
185 return null;
186 }
187 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
188}
189
190const environmentApiError = (message: string) => (cause: unknown) => {
191 const environmentError = findEnvironmentCloudApiError(cause);
192 return new CloudEnvironmentLinkError({
193 message: environmentError
194 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
195 : message,
196 cause,
197 });370 });
198};371}
199 372
200function endpointOrigin(httpBaseUrl: string) {373function makeCloudEnvironmentHttpApiClient(input: {
201 const url = new URL(httpBaseUrl);374 readonly environmentId: string;
202 return {375 readonly httpBaseUrl: string;
203 localHttpHost: "127.0.0.1",376}) {
204 localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),377 return Effect.try({
205 };378 try: () => makeEnvironmentHttpApiClient(input.httpBaseUrl),
379 catch: (cause) =>
380 new CloudEnvironmentLinkOperationError({
381 action: "initialize the environment HTTP client",
382 environmentId: input.environmentId,
383 ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
384 cause,
385 }),
386 }).pipe(Effect.flatten);
206}387}
207 388
208const MANAGED_ENDPOINT_PROVIDER_KIND =389const MANAGED_ENDPOINT_PROVIDER_KIND =
@@ -214,13 +395,19 @@ function ensureLinkedEnvironmentMatches(input: { · 5 unmodified lines
214 readonly link: RelayEnvironmentLinkResponse;395 readonly link: RelayEnvironmentLinkResponse;
215}): Effect.Effect<void, CloudEnvironmentLinkError> {396}): Effect.Effect<void, CloudEnvironmentLinkError> {
216 if (input.link.environmentId !== input.expectedEnvironmentId) {397 if (input.link.environmentId !== input.expectedEnvironmentId) {
217 return new CloudEnvironmentLinkError({398 return new CloudEnvironmentLinkResponseMismatchError({
218 message: "Relay returned credentials for a different environment.",399 environmentId: input.expectedEnvironmentId,
400 field: "environment id",
401 expected: input.expectedEnvironmentId,
402 actual: input.link.environmentId,
219 });403 });
220 }404 }
221 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {405 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {
222 return new CloudEnvironmentLinkError({406 return new CloudEnvironmentLinkResponseMismatchError({
223 message: "Relay returned credentials for a different endpoint provider.",407 environmentId: input.expectedEnvironmentId,
408 field: "endpoint provider",
409 expected: input.expectedProviderKind,
410 actual: input.link.endpoint.providerKind,
224 });411 });
225 }412 }
226 return Effect.void;413 return Effect.void;
@@ -275,9 +462,7 @@ export function listManagedCloudEnvironments(input: { · 48 unmodified lines
275 return Effect.gen(function* () {462 return Effect.gen(function* () {
276 const configuredRelayUrl = relayUrl();463 const configuredRelayUrl = relayUrl();
277 if (!configuredRelayUrl) {464 if (!configuredRelayUrl) {
278 return yield* new CloudEnvironmentLinkError({465 return yield* new CloudRelayUrlNotConfiguredError({});
279 message: "T3CODE_RELAY_URL is not configured.",
280 });
281 }466 }
282 const relayClient = yield* ManagedRelay.ManagedRelayClient;467 const relayClient = yield* ManagedRelay.ManagedRelayClient;
283 return yield* relayClient468 return yield* relayClient
@@ -285,12 +470,12 @@ export function listManagedCloudEnvironments(input: { · 1 unmodified line
285 clerkToken: input.clerkToken,470 clerkToken: input.clerkToken,
286 })471 })
287 .pipe(472 .pipe(
288 Effect.mapError(473 Effect.mapError((cause) =>
289 (cause) =>474 CloudEnvironmentLinkOperationError.fromManagedRelay({
290 new CloudEnvironmentLinkError({475 action: "list relay-managed environments",
291 message: "Could not list relay-managed environments.",476 relayUrl: configuredRelayUrl,
292 cause,477 cause,
293 }),478 }),
294 ),479 ),
295 );480 );
296 });481 });
@@ -304,19 +489,18 @@ export function listCloudDevices(input: { · 7 unmodified lines
304 ManagedRelay.ManagedRelayClient489 ManagedRelay.ManagedRelayClient
305> {490> {
306 return Effect.gen(function* () {491 return Effect.gen(function* () {
307 if (!relayUrl()) {492 const configuredRelayUrl = relayUrl();
308 return yield* new CloudEnvironmentLinkError({493 if (!configuredRelayUrl) {
309 message: "T3CODE_RELAY_URL is not configured.",494 return yield* new CloudRelayUrlNotConfiguredError({});
310 });
311 }495 }
312 const relayClient = yield* ManagedRelay.ManagedRelayClient;496 const relayClient = yield* ManagedRelay.ManagedRelayClient;
313 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(497 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(
314 Effect.mapError(498 Effect.mapError((cause) =>
315 (cause) =>499 CloudEnvironmentLinkOperationError.fromManagedRelay({
316 new CloudEnvironmentLinkError({500 action: "list cloud devices",
317 message: "Could not list cloud devices.",501 relayUrl: configuredRelayUrl,
318 cause,502 cause,
319 }),503 }),
320 ),504 ),
321 );505 );
322 });506 });
@@ -326,10 +510,20 @@ export function readPrimaryCloudLinkState(input: { · 3 unmodified lines
326 readonly target: CloudLinkTarget;510 readonly target: CloudLinkTarget;
327}): Effect.Effect<CloudLinkState | null, CloudEnvironmentLinkError, HttpClient.HttpClient> {511}): Effect.Effect<CloudLinkState | null, CloudEnvironmentLinkError, HttpClient.HttpClient> {
328 return Effect.gen(function* () {512 return Effect.gen(function* () {
329 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);513 const client = yield* makeCloudEnvironmentHttpApiClient({
330 return yield* client.connect514 environmentId: input.target.environmentId,
331 .linkState({ headers: {} })515 httpBaseUrl: input.target.httpBaseUrl,
332 .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state.")));516 });
517 return yield* client.connect.linkState({ headers: {} }).pipe(
518 Effect.mapError((cause) =>
519 CloudEnvironmentLinkOperationError.fromEnvironmentApi({
520 action: "read environment cloud link state",
521 environmentId: input.target.environmentId,
522 httpBaseUrl: input.target.httpBaseUrl,
523 cause,
524 }),
525 ),
526 );
333 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));527 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
334}528}
335 529
@@ -338,14 +532,24 @@ export function updatePrimaryCloudPreferences(input: { · 2 unmodified lines
338 readonly publishAgentActivity: boolean;532 readonly publishAgentActivity: boolean;
339}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {533}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
340 return Effect.gen(function* () {534 return Effect.gen(function* () {
341 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);535 const client = yield* makeCloudEnvironmentHttpApiClient({
536 environmentId: input.target.environmentId,
537 httpBaseUrl: input.target.httpBaseUrl,
538 });
342 return yield* client.connect539 return yield* client.connect
343 .preferences({540 .preferences({
344 headers: {},541 headers: {},
345 payload: input,542 payload: input,
346 })543 })
347 .pipe(544 .pipe(
348 Effect.mapError(environmentApiError("Could not update environment cloud preferences.")),545 Effect.mapError((cause) =>
546 CloudEnvironmentLinkOperationError.fromEnvironmentApi({
547 action: "update environment cloud preferences",
548 environmentId: input.target.environmentId,
549 httpBaseUrl: input.target.httpBaseUrl,
550 cause,
551 }),
552 ),
349 );553 );
350 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));554 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
351}555}
@@ -359,10 +563,20 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { · 7 unmodified lines
359 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient563 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
360> {564> {
361 return Effect.gen(function* () {565 return Effect.gen(function* () {
362 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);566 const client = yield* makeCloudEnvironmentHttpApiClient({
363 yield* client.connect567 environmentId: input.target.environmentId,
364 .unlink({ headers: {} })568 httpBaseUrl: input.target.httpBaseUrl,
365 .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")));569 });
570 yield* client.connect.unlink({ headers: {} }).pipe(
571 Effect.mapError((cause) =>
572 CloudEnvironmentLinkOperationError.fromEnvironmentApi({
573 action: "unlink the environment from cloud",
574 environmentId: input.target.environmentId,
575 httpBaseUrl: input.target.httpBaseUrl,
576 cause,
577 }),
578 ),
579 );
366 580
367 const configuredRelayUrl = relayUrl();581 const configuredRelayUrl = relayUrl();
368 if (configuredRelayUrl && input.clerkToken) {582 if (configuredRelayUrl && input.clerkToken) {
@@ -373,11 +587,27 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { · 4 unmodified lines
373 environmentId: EnvironmentId.make(input.target.environmentId),587 environmentId: EnvironmentId.make(input.target.environmentId),
374 })588 })
375 .pipe(589 .pipe(
376 Effect.catch((cause) =>590 Effect.catch((cause) => {
377 Effect.logWarning("Could not revoke cloud environment link after local unlink.", {591 const error = CloudEnvironmentLinkOperationError.fromManagedRelay({
592 action: "revoke the cloud environment link",
593 environmentId: input.target.environmentId,
594 relayUrl: configuredRelayUrl,
378 cause,595 cause,
379 }),596 });
380 ),597 return Effect.logWarning(error.message, {
598 environmentId: input.target.environmentId,
599 ...(error.relayUrlInputLength === undefined
600 ? {}
601 : { relayUrlInputLength: error.relayUrlInputLength }),
602 ...(error.relayUrlProtocol === undefined
603 ? {}
604 : { relayUrlProtocol: error.relayUrlProtocol }),
605 ...(error.relayUrlHostname === undefined
606 ? {}
607 : { relayUrlHostname: error.relayUrlHostname }),
608 causeTag: cause._tag,
609 });
610 }),
381 );611 );
382 }612 }
383 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));613 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
@@ -394,12 +624,19 @@ export function linkPrimaryEnvironmentToCloud(input: { · 10 unmodified lines
394 return Effect.gen(function* () {624 return Effect.gen(function* () {
395 const configuredRelayUrl = relayUrl();625 const configuredRelayUrl = relayUrl();
396 if (!configuredRelayUrl) {626 if (!configuredRelayUrl) {
397 return yield* new CloudEnvironmentLinkError({627 return yield* new CloudRelayUrlNotConfiguredError({
398 message: "T3CODE_RELAY_URL is not configured.",628 environmentId: input.target.environmentId,
399 });629 });
400 }630 }
631 const origin = yield* endpointOrigin({
632 environmentId: input.target.environmentId,
633 httpBaseUrl: input.target.httpBaseUrl,
634 });
635 const environmentClient = yield* makeCloudEnvironmentHttpApiClient({
636 environmentId: input.target.environmentId,
637 httpBaseUrl: input.target.httpBaseUrl,
638 });
401 const relayClient = yield* ManagedRelay.ManagedRelayClient;639 const relayClient = yield* ManagedRelay.ManagedRelayClient;
402 const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
403 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));640 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
404 641
405 const challenge = yield* relayClient642 const challenge = yield* relayClient
@@ -412,10 +649,13 @@ export function linkPrimaryEnvironmentToCloud(input: { · 6 unmodified lines
412 },649 },
413 })650 })
414 .pipe(651 .pipe(
415 Effect.mapError(652 Effect.mapError((cause) =>
416 decodedRelayClientError(653 CloudEnvironmentLinkOperationError.fromManagedRelay({
417 `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,654 action: "create an environment link challenge",
418 ),655 environmentId: input.target.environmentId,
656 relayUrl: configuredRelayUrl,
657 cause,
658 }),
419 ),659 ),
420 );660 );
421 const proof = yield* environmentClient.connect661 const proof = yield* environmentClient.connect
@@ -429,10 +669,19 @@ export function linkPrimaryEnvironmentToCloud(input: { · 7 unmodified lines
429 wsBaseUrl: input.target.wsBaseUrl,669 wsBaseUrl: input.target.wsBaseUrl,
430 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,670 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
431 },671 },
432 origin: endpointOrigin(input.target.httpBaseUrl),672 origin,
433 },673 },
434 })674 })
435 .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof.")));675 .pipe(
676 Effect.mapError((cause) =>
677 CloudEnvironmentLinkOperationError.fromEnvironmentApi({
678 action: "obtain an environment link proof",
679 environmentId: input.target.environmentId,
680 httpBaseUrl: input.target.httpBaseUrl,
681 cause,
682 }),
683 ),
684 );
436 const link = yield* relayClient685 const link = yield* relayClient
437 .linkEnvironment({686 .linkEnvironment({
438 clerkToken: input.clerkToken,687 clerkToken: input.clerkToken,
@@ -444,8 +693,13 @@ export function linkPrimaryEnvironmentToCloud(input: { · 5 unmodified lines
444 },693 },
445 })694 })
446 .pipe(695 .pipe(
447 Effect.mapError(696 Effect.mapError((cause) =>
448 decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),697 CloudEnvironmentLinkOperationError.fromManagedRelay({
698 action: "link the environment",
699 environmentId: input.target.environmentId,
700 relayUrl: configuredRelayUrl,
701 cause,
702 }),
449 ),703 ),
450 );704 );
451 yield* ensureLinkedEnvironmentMatches({705 yield* ensureLinkedEnvironmentMatches({
@@ -466,6 +720,15 @@ export function linkPrimaryEnvironmentToCloud(input: { · 14 unmodified lines
466 endpointRuntime: link.endpointRuntime,720 endpointRuntime: link.endpointRuntime,
467 },721 },
468 })722 })
469 .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access.")));723 .pipe(
724 Effect.mapError((cause) =>
725 CloudEnvironmentLinkOperationError.fromEnvironmentApi({
726 action: "configure environment relay access",
727 environmentId: input.target.environmentId,
728 httpBaseUrl: input.target.httpBaseUrl,
729 cause,
730 }),
731 ),
732 );
470 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));733 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
471}734}