authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 20:46:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 20:46:20-07:00
log68e50db20b3784bad69dd754165020a08f2cd42a
tree93d48344ba55115c7888676eca4a99d98d75b9ff
parentff873b2ec1972b701c3f9edd6a0f727c91604dc4
signature Signed by PGP key B5690EEEBB952194

refactor(server): CloudLink fails with its own errors; the connect routes map them to HTTP (#16341)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 files changed, 577 insertions(+), 337 deletions(-)

apps/server/src/cloud/CloudLink.lifecycle.test.ts+8-11
...@@ -45,7 +45,6 @@ import {...@@ -45,7 +45,6 @@ import {
45 managedTunnelStartupAction,45 managedTunnelStartupAction,
46 retryManagedTunnelRegistration,46 retryManagedTunnelRegistration,
47} from "./managedTunnelStartup.ts";47} from "./managedTunnelStartup.ts";
48import { shouldRetryCloudLink } from "./relayResponse.ts";
49import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";48import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
5049
51const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");50const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
...@@ -109,10 +108,7 @@ describe("reconcileDesiredCloudLink", () => {...@@ -109,10 +108,7 @@ describe("reconcileDesiredCloudLink", () => {
109 const link = yield* CloudLink.CloudLink;108 const link = yield* CloudLink.CloudLink;
110 const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774"));109 const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774"));
111110
112 expect(error).toMatchObject({111 expect(error._tag).toBe("CloudLinkAuthorizationMissingError");
113 _tag: "EnvironmentHttpUnauthorizedError",
114 message: "Run `t3 connect link` to authorize this environment.",
115 });
116 }).pipe(112 }).pipe(
117 Effect.provide(CloudLink.layer),113 Effect.provide(CloudLink.layer),
118 Effect.provideService(HttpServer.HttpServer, idleHttpServer),114 Effect.provideService(HttpServer.HttpServer, idleHttpServer),
...@@ -689,7 +685,7 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -689,7 +685,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
689 link685 link
690 .registerManagedTunnelRecovery(localOrigin)686 .registerManagedTunnelRecovery(localOrigin)
691 .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))),687 .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))),
692 shouldRetryCloudLink,688 CloudLink.shouldRetryCloudLink,
693 link689 link
694 .startManagedTunnelIfOriginConfirmed(localOrigin, {690 .startManagedTunnelIfOriginConfirmed(localOrigin, {
695 requireConfirmedOrigin: false,691 requireConfirmedOrigin: false,
...@@ -1051,10 +1047,10 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -1051,10 +1047,10 @@ describe("releaseManagedTunnelOnShutdown", () => {
1051 });1047 });
10521048
1053 it.effect.each([1049 it.effect.each([
1054 { status: 401, errorTag: "EnvironmentHttpUnauthorizedError" },1050 { status: 401, rejection: "unauthorized" },
1055 { status: 403, errorTag: "EnvironmentHttpForbiddenError" },1051 { status: 403, rejection: "forbidden" },
1056 { status: 409, errorTag: "EnvironmentHttpBadRequestError" },1052 { status: 409, rejection: "rejected" },
1057 ])("preserves a permanent $status relay recovery failure", ({ status, errorTag }) => {1053 ])("preserves a permanent $status relay recovery failure", ({ status, rejection }) => {
1058 const { store } = makeMemorySecretStore([1054 const { store } = makeMemorySecretStore([
1059 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],1055 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],
1060 [RELAY_URL_SECRET, "https://relay.example.test"],1056 [RELAY_URL_SECRET, "https://relay.example.test"],
...@@ -1068,7 +1064,8 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -1068,7 +1064,8 @@ describe("releaseManagedTunnelOnShutdown", () => {
1068 const link = yield* CloudLink.CloudLink;1064 const link = yield* CloudLink.CloudLink;
1069 const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773"));1065 const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773"));
10701066
1071 expect(error._tag).toBe(errorTag);1067 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection });
1068 expect(CloudLink.shouldRetryCloudLink(error)).toBe(false);
1072 expect(requests).toHaveLength(1);1069 expect(requests).toHaveLength(1);
1073 expect(applyConfigCalls).toEqual([]);1070 expect(applyConfigCalls).toEqual([]);
1074 }).pipe(1071 }).pipe(
apps/server/src/cloud/CloudLink.test.ts+10-4
...@@ -137,7 +137,10 @@ it.effect("puts the relay back when the local save fails", () =>...@@ -137,7 +137,10 @@ it.effect("puts the relay back when the local save fails", () =>
137 const error = yield* preferences137 const error = yield* preferences
138 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })138 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
139 .pipe(Effect.flip);139 .pipe(Effect.flip);
140 assert.equal(error._tag, "EnvironmentHttpInternalServerError");140 assert.deepInclude(error, {
141 _tag: "CloudLinkInternalError",
142 operation: "persist-preferences",
143 });
141 assert.deepEqual(relayCalls, [true, false]);144 assert.deepEqual(relayCalls, [true, false]);
142 assert.equal(145 assert.equal(
143 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),146 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
...@@ -153,7 +156,10 @@ it.effect("leaves the relay untouched when the activity setting can't be saved",...@@ -153,7 +156,10 @@ it.effect("leaves the relay untouched when the activity setting can't be saved",
153 const error = yield* preferences156 const error = yield* preferences
154 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })157 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
155 .pipe(Effect.flip);158 .pipe(Effect.flip);
156 assert.equal(error._tag, "EnvironmentHttpInternalServerError");159 assert.deepInclude(error, {
160 _tag: "CloudLinkInternalError",
161 operation: "persist-preferences",
162 });
157 assert.deepEqual(relayCalls, []);163 assert.deepEqual(relayCalls, []);
158 assert.equal(164 assert.equal(
159 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),165 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
...@@ -184,7 +190,7 @@ it.effect("changes nothing when the current activity setting can't be read", ()...@@ -184,7 +190,7 @@ it.effect("changes nothing when the current activity setting can't be read", ()
184 const error = yield* preferences190 const error = yield* preferences
185 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })191 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
186 .pipe(Effect.flip);192 .pipe(Effect.flip);
187 assert.equal(error._tag, "EnvironmentHttpInternalServerError");193 assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" });
188 assert.deepEqual(relayCalls, []);194 assert.deepEqual(relayCalls, []);
189 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");195 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
190 }),196 }),
...@@ -231,7 +237,7 @@ it.effect("changes nothing when the current hold setting can't be read", () =>...@@ -231,7 +237,7 @@ it.effect("changes nothing when the current hold setting can't be read", () =>
231 const error = yield* preferences237 const error = yield* preferences
232 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false })238 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false })
233 .pipe(Effect.flip);239 .pipe(Effect.flip);
234 assert.equal(error._tag, "EnvironmentHttpInternalServerError");240 assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" });
235 assert.deepEqual(relayCalls, []);241 assert.deepEqual(relayCalls, []);
236 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");242 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
237 }),243 }),
apps/server/src/cloud/CloudLink.ts+302-258
...@@ -7,15 +7,9 @@...@@ -7,15 +7,9 @@
7import * as NodeCrypto from "node:crypto";7import * as NodeCrypto from "node:crypto";
8import {8import {
9 AuthStandardClientScopes,9 AuthStandardClientScopes,
10 EnvironmentCloudEndpointUnavailableError,
11 type EnvironmentCloudLinkStateResult,10 type EnvironmentCloudLinkStateResult,
12 type EnvironmentCloudPreferencesRequest,11 type EnvironmentCloudPreferencesRequest,
13 type EnvironmentCloudRelayConfigResult,12 type EnvironmentCloudRelayConfigResult,
14 EnvironmentHttpBadRequestError,
15 EnvironmentHttpConflictError,
16 type EnvironmentHttpForbiddenError,
17 EnvironmentHttpInternalServerError,
18 EnvironmentHttpUnauthorizedError,
19} from "@t3tools/contracts";13} from "@t3tools/contracts";
20import {14import {
21 RelayCloudEnvironmentHealthProofPayload,15 RelayCloudEnvironmentHealthProofPayload,
...@@ -106,7 +100,12 @@ import {...@@ -106,7 +100,12 @@ import {
106import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";100import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
107import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";101import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
108import { relayUrlConfig } from "./publicConfig.ts";102import { relayUrlConfig } from "./publicConfig.ts";
109import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";103import {
104 filterRelayResponse,
105 relayRequestError,
106 type RelayRequestError,
107 shouldRetryRelayRequest,
108} from "./relayResponse.ts";
110import {109import {
111 CLOUD_HEALTH_JTI_PREFIX,110 CLOUD_HEALTH_JTI_PREFIX,
112 CLOUD_HEALTH_NONCE_PREFIX,111 CLOUD_HEALTH_NONCE_PREFIX,
...@@ -126,15 +125,187 @@ import {...@@ -126,15 +125,187 @@ import {
126import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts";125import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts";
127126
128const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2);127const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2);
129const failEnvironmentCloudInternalError =128const RELAY_CONFIG_FIELD_MESSAGES = {
130 (message: string) =>129 relayUrl: "Relay URL must be a secure absolute HTTPS URL.",
131 (cause: unknown): Effect.Effect<never, EnvironmentHttpInternalServerError> =>130 relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.",
132 Effect.logError(message, { cause }).pipe(131 environmentCredential: "Relay environment credential is required.",
133 Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))),132 cloudUserId: "Cloud user id is required.",
134 );133 cloudMintPublicKey: "Cloud mint public key must be a valid Ed25519 public key.",
134} as const;
135
136/** The relay sent a link configuration this environment cannot install. */
137export class CloudLinkRelayConfigInvalidError extends Schema.TaggedError<CloudLinkRelayConfigInvalidError>()(
138 "CloudLinkRelayConfigInvalidError",
139 {
140 field: Schema.Literals([
141 "relayUrl",
142 "relayIssuer",
143 "environmentCredential",
144 "cloudUserId",
145 "cloudMintPublicKey",
146 ]),
147 },
148) {
149 override get message(): string {
150 return RELAY_CONFIG_FIELD_MESSAGES[this.field];
151 }
152}
153
154/**
155 * A link was asked to point at an origin it may not serve: a link proof request
156 * that did not reach this server directly on loopback (`endpoint`), or a local
157 * origin that is not a bare http(s) origin (`local`).
158 */
159export class CloudLinkOriginInvalidError extends Schema.TaggedError<CloudLinkOriginInvalidError>()(
160 "CloudLinkOriginInvalidError",
161 { origin: Schema.Literals(["endpoint", "local"]) },
162) {
163 override get message(): string {
164 return this.origin === "endpoint"
165 ? "Invalid managed endpoint origin."
166 : "Could not resolve local environment origin.";
167 }
168}
169
170export class CloudLinkNotLinkedError extends Schema.TaggedError<CloudLinkNotLinkedError>()(
171 "CloudLinkNotLinkedError",
172 {},
173) {
174 override get message(): string {
175 return "Link this environment to T3 Connect first.";
176 }
177}
178
179export class CloudLinkAccountMismatchError extends Schema.TaggedError<CloudLinkAccountMismatchError>()(
180 "CloudLinkAccountMismatchError",
181 {},
182) {
183 override get message(): string {
184 return "This environment is already linked to a different cloud account. Unlink it before switching accounts.";
185 }
186}
187
188/** Linking from the CLI needs the authorization `t3 connect link` stores. */
189export class CloudLinkAuthorizationMissingError extends Schema.TaggedError<CloudLinkAuthorizationMissingError>()(
190 "CloudLinkAuthorizationMissingError",
191 {},
192) {
193 override get message(): string {
194 return "Run `t3 connect link` to authorize this environment.";
195 }
196}
197
198const SignedRelayRequest = Schema.Literals(["health", "mint"]);
135199
136const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) =>200/** A signed relay request whose proof does not verify for this environment. */
137 failEnvironmentCloudInternalError(error.message)(error);201export class CloudLinkProofRejectedError extends Schema.TaggedError<CloudLinkProofRejectedError>()(
202 "CloudLinkProofRejectedError",
203 { request: SignedRelayRequest },
204) {
205 override get message(): string {
206 return `Invalid cloud ${this.request} request.`;
207 }
208}
209
210/** A signed relay request whose proof was already used once. */
211export class CloudLinkProofReplayedError extends Schema.TaggedError<CloudLinkProofReplayedError>()(
212 "CloudLinkProofReplayedError",
213 { request: SignedRelayRequest },
214) {
215 override get message(): string {
216 return `Cloud ${this.request} request was already consumed.`;
217 }
218}
219
220/** The stored tunnel changed while its registration was in flight. */
221export class CloudLinkTunnelSupersededError extends Schema.TaggedError<CloudLinkTunnelSupersededError>()(
222 "CloudLinkTunnelSupersededError",
223 {},
224) {
225 override get message(): string {
226 return "The managed tunnel configuration changed during registration.";
227 }
228}
229
230/**
231 * The managed tunnel is not serving: its connector did not start
232 * (`runtime-not-started`, with the runtime's status), or the relay never
233 * confirmed this server's origin (`origin-unconfirmed`).
234 */
235export class CloudLinkEndpointUnavailableError extends Schema.TaggedError<CloudLinkEndpointUnavailableError>()(
236 "CloudLinkEndpointUnavailableError",
237 {
238 reason: Schema.Literals(["runtime-not-started", "origin-unconfirmed"]),
239 endpointRuntimeStatus: Schema.Unknown,
240 },
241) {
242 override get message(): string {
243 return this.reason === "runtime-not-started"
244 ? "Managed endpoint runtime could not be started."
245 : "Managed endpoint origin could not be confirmed.";
246 }
247}
248
249const INTERNAL_OPERATION_MESSAGES = {
250 "relay-url-unconfigured":
251 "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.",
252 "generate-link-proof": "Could not generate environment link proof.",
253 "persist-relay-config": "Could not persist environment relay configuration.",
254 "register-endpoint-origin": "Could not register the managed endpoint origin.",
255 "resolve-server-origin": "Could not resolve the local server origin.",
256 "persist-desired-link": "Could not persist desired T3 Connect link state.",
257 "sign-recovery-proof": "Could not sign the managed tunnel recovery request.",
258 "unsupported-recovered-tunnel":
259 "T3 Connect returned an unsupported managed tunnel configuration.",
260 "persist-recovered-tunnel": "Could not persist the recovered managed tunnel configuration.",
261 "read-relay-config": "Could not read environment relay configuration.",
262 "remove-relay-config": "Could not remove environment relay configuration.",
263 "update-webhook-settings": "Could not update T3 Connect webhook settings.",
264 "read-preferences": "Could not read environment cloud preferences.",
265 "persist-preferences": "Could not persist environment cloud preferences.",
266 "answer-health": "Could not answer cloud health request.",
267 "issue-credential": "Could not issue cloud connection credential.",
268} as const;
269
270/** A link step failed on this machine: storage, signing, or an unexpected relay answer. */
271export class CloudLinkInternalError extends Schema.TaggedError<CloudLinkInternalError>()(
272 "CloudLinkInternalError",
273 {
274 operation: Schema.Literals(
275 Object.keys(INTERNAL_OPERATION_MESSAGES) as [
276 keyof typeof INTERNAL_OPERATION_MESSAGES,
277 ...Array<keyof typeof INTERNAL_OPERATION_MESSAGES>,
278 ],
279 ),
280 cause: Schema.optional(Schema.Defect()),
281 },
282) {
283 override get message(): string {
284 return INTERNAL_OPERATION_MESSAGES[this.operation];
285 }
286}
287
288const internalError =
289 (operation: CloudLinkInternalError["operation"]) =>
290 (cause: unknown): Effect.Effect<never, CloudLinkInternalError> =>
291 Effect.fail(new CloudLinkInternalError({ operation, cause }));
292
293const isPermanentLinkError = Schema.is(
294 Schema.Union([
295 CloudLinkRelayConfigInvalidError,
296 CloudLinkOriginInvalidError,
297 CloudLinkNotLinkedError,
298 CloudLinkAccountMismatchError,
299 CloudLinkAuthorizationMissingError,
300 CloudLinkProofRejectedError,
301 CloudLinkProofReplayedError,
302 CloudLinkTunnelSupersededError,
303 ]),
304);
305
306/** Whether a failed link step may succeed on retry: not when the relay or this server refused it. */
307export const shouldRetryCloudLink = (error: unknown): boolean =>
308 shouldRetryRelayRequest(error) && !isPermanentLinkError(error);
138309
139/** A failed rollback leaves a setting changed; it is logged, not hidden. */310/** A failed rollback leaves a setting changed; it is logged, not hidden. */
140const rollbackFailed = (cause: unknown) =>311const rollbackFailed = (cause: unknown) =>
...@@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) =>...@@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) =>
142313
143const requireRelayUrl = relayUrlConfig.pipe(314const requireRelayUrl = relayUrlConfig.pipe(
144 Effect.mapError(315 Effect.mapError(
145 () =>316 (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }),
146 new EnvironmentHttpInternalServerError({
147 message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.",
148 }),
149 ),317 ),
150);318);
151319
...@@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array {...@@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array {
159327
160function validateCloudMintPublicKey(328function validateCloudMintPublicKey(
161 publicKey: string,329 publicKey: string,
162): Effect.Effect<void, EnvironmentHttpBadRequestError> {330): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
331 const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" });
163 return Effect.try({332 return Effect.try({
164 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),333 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
165 catch: () =>334 catch: () => invalid,
166 new EnvironmentHttpBadRequestError({
167 message: "Cloud mint public key must be a valid Ed25519 public key.",
168 }),
169 }).pipe(335 }).pipe(
170 Effect.flatMap((key) =>336 Effect.flatMap((key) =>
171 key.asymmetricKeyType === "ed25519"337 key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid),
172 ? Effect.void
173 : Effect.fail(
174 new EnvironmentHttpBadRequestError({
175 message: "Cloud mint public key must be a valid Ed25519 public key.",
176 }),
177 ),
178 ),338 ),
179 );339 );
180}340}
181341
182function validateRelayConfigPayload(342function invalidRelayConfigField(
183 payload: RelayEnvironmentConfigRequest,343 payload: RelayEnvironmentConfigRequest,
184): Effect.Effect<void, EnvironmentHttpBadRequestError> {344): CloudLinkRelayConfigInvalidError["field"] | null {
185 if (!isSecureRelayUrl(payload.relayUrl)) {345 if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl";
186 return Effect.fail(
187 new EnvironmentHttpBadRequestError({
188 message: "Relay URL must be a secure absolute HTTPS URL.",
189 }),
190 );
191 }
192 if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) {346 if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) {
193 return Effect.fail(347 return "relayIssuer";
194 new EnvironmentHttpBadRequestError({
195 message: "Relay issuer must be a secure absolute HTTPS URL.",
196 }),
197 );
198 }
199 if (payload.environmentCredential.trim().length === 0) {
200 return Effect.fail(
201 new EnvironmentHttpBadRequestError({
202 message: "Relay environment credential is required.",
203 }),
204 );
205 }348 }
206 if (payload.cloudUserId.trim().length === 0) {349 if (payload.environmentCredential.trim().length === 0) return "environmentCredential";
207 return Effect.fail(350 if (payload.cloudUserId.trim().length === 0) return "cloudUserId";
208 new EnvironmentHttpBadRequestError({351 return null;
209 message: "Cloud user id is required.",352}
210 }),353
211 );354function validateRelayConfigPayload(
212 }355 payload: RelayEnvironmentConfigRequest,
213 return Effect.void;356): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
357 const field = invalidRelayConfigField(payload);
358 return field === null
359 ? Effect.void
360 : Effect.fail(new CloudLinkRelayConfigInvalidError({ field }));
214}361}
215362
216function normalizePemForSignedPayload(value: string): string {363function normalizePemForSignedPayload(value: string): string {
...@@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = {...@@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = {
233 | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin }380 | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin }
234);381);
235382
236/** Failures of the link work that runs outside a request: startup, recovery and shutdown. */383/** Every failure CloudLink constructs itself. */
384export type CloudLinkError =
385 | CloudLinkAccountMismatchError
386 | CloudLinkAuthorizationMissingError
387 | CloudLinkEndpointUnavailableError
388 | CloudLinkInternalError
389 | CloudLinkNotLinkedError
390 | CloudLinkOriginInvalidError
391 | CloudLinkProofRejectedError
392 | CloudLinkProofReplayedError
393 | CloudLinkRelayConfigInvalidError
394 | CloudLinkTunnelSupersededError;
395
396/** Failures of the link work that talks to the relay: linking, recovery and shutdown. */
237type CloudLinkBackgroundError =397type CloudLinkBackgroundError =
238 | EnvironmentCloudEndpointUnavailableError398 | CloudLinkEndpointUnavailableError
239 | EnvironmentHttpBadRequestError399 | CloudLinkInternalError
240 | EnvironmentHttpForbiddenError400 | CloudLinkOriginInvalidError
241 | EnvironmentHttpInternalServerError401 | RelayRequestError
242 | EnvironmentHttpUnauthorizedError
243 | ServerSecretStore.SecretStoreError402 | ServerSecretStore.SecretStoreError
244 | Schema.SchemaError403 | Schema.SchemaError
245 | PlatformError.PlatformError;404 | PlatformError.PlatformError;
...@@ -264,29 +423,28 @@ export class CloudLink extends Context.Service<...@@ -264,29 +423,28 @@ export class CloudLink extends Context.Service<
264 httpRequest: HttpServerRequest.HttpServerRequest,423 httpRequest: HttpServerRequest.HttpServerRequest,
265 ) => Effect.Effect<424 ) => Effect.Effect<
266 RelayEnvironmentLinkProof,425 RelayEnvironmentLinkProof,
267 EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError426 CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError
268 >;427 >;
269 /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */428 /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */
270 readonly applyRelayConfig: (429 readonly applyRelayConfig: (
271 payload: RelayEnvironmentConfigRequest,430 payload: RelayEnvironmentConfigRequest,
272 ) => Effect.Effect<431 ) => Effect.Effect<
273 EnvironmentCloudRelayConfigResult,432 EnvironmentCloudRelayConfigResult,
274 | EnvironmentCloudEndpointUnavailableError433 | CloudLinkAccountMismatchError
275 | EnvironmentHttpBadRequestError434 | CloudLinkEndpointUnavailableError
276 | EnvironmentHttpConflictError435 | CloudLinkInternalError
277 | EnvironmentHttpForbiddenError436 | CloudLinkOriginInvalidError
278 | EnvironmentHttpInternalServerError437 | CloudLinkRelayConfigInvalidError
279 | EnvironmentHttpUnauthorizedError438 | CloudLinkTunnelSupersededError
439 | EnvironmentAuth.ServerAuthInternalError
440 | RelayRequestError
280 >;441 >;
281 readonly linkState: () => Effect.Effect<442 readonly linkState: () => Effect.Effect<
282 EnvironmentCloudLinkStateResult,443 EnvironmentCloudLinkStateResult,
283 EnvironmentHttpInternalServerError444 CloudLinkInternalError
284 >;445 >;
285 /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */446 /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */
286 readonly unlink: () => Effect.Effect<447 readonly unlink: () => Effect.Effect<EnvironmentCloudRelayConfigResult, CloudLinkInternalError>;
287 EnvironmentCloudRelayConfigResult,
288 EnvironmentHttpInternalServerError
289 >;
290 /**448 /**
291 * Saves this environment's T3 Connect preferences, all or nothing, and449 * Saves this environment's T3 Connect preferences, all or nothing, and
292 * returns the link state. The activity setting is saved first. Holding450 * returns the link state. The activity setting is saved first. Holding
...@@ -298,25 +456,27 @@ export class CloudLink extends Context.Service<...@@ -298,25 +456,27 @@ export class CloudLink extends Context.Service<
298 input: EnvironmentCloudPreferencesRequest,456 input: EnvironmentCloudPreferencesRequest,
299 ) => Effect.Effect<457 ) => Effect.Effect<
300 EnvironmentCloudLinkStateResult,458 EnvironmentCloudLinkStateResult,
301 EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError459 CloudLinkNotLinkedError | CloudLinkInternalError
302 >;460 >;
303 /** Answers the relay's signed health check once per proof. */461 /** Answers the relay's signed health check once per proof. */
304 readonly answerHealthRequest: (462 readonly answerHealthRequest: (
305 request: RelayCloudEnvironmentHealthRequest,463 request: RelayCloudEnvironmentHealthRequest,
306 ) => Effect.Effect<464 ) => Effect.Effect<
307 RelayEnvironmentHealthResponse,465 RelayEnvironmentHealthResponse,
308 | EnvironmentHttpConflictError466 | CloudLinkProofRejectedError
309 | EnvironmentHttpInternalServerError467 | CloudLinkProofReplayedError
310 | EnvironmentHttpUnauthorizedError468 | CloudLinkInternalError
469 | EnvironmentAuth.ServerAuthInternalError
311 >;470 >;
312 /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */471 /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */
313 readonly mintCredential: (472 readonly mintCredential: (
314 request: RelayCloudMintCredentialRequest,473 request: RelayCloudMintCredentialRequest,
315 ) => Effect.Effect<474 ) => Effect.Effect<
316 RelayEnvironmentMintResponse,475 RelayEnvironmentMintResponse,
317 | EnvironmentHttpConflictError476 | CloudLinkProofRejectedError
318 | EnvironmentHttpInternalServerError477 | CloudLinkProofReplayedError
319 | EnvironmentHttpUnauthorizedError478 | CloudLinkInternalError
479 | EnvironmentAuth.ServerAuthInternalError
320 >;480 >;
321 /** Links this environment with the stored CLI authorization and records the CLI's wish. */481 /** Links this environment with the stored CLI authorization and records the CLI's wish. */
322 readonly reconcileDesiredLink: (482 readonly reconcileDesiredLink: (
...@@ -324,9 +484,11 @@ export class CloudLink extends Context.Service<...@@ -324,9 +484,11 @@ export class CloudLink extends Context.Service<
324 ) => Effect.Effect<484 ) => Effect.Effect<
325 CliDesiredLinkMode,485 CliDesiredLinkMode,
326 | CloudLinkBackgroundError486 | CloudLinkBackgroundError
327 | EnvironmentHttpConflictError487 | CloudLinkAccountMismatchError
488 | CloudLinkAuthorizationMissingError
489 | CloudLinkRelayConfigInvalidError
328 | EnvironmentAuth.ServerAuthInternalError490 | EnvironmentAuth.ServerAuthInternalError
329 | CliTokenManager.CloudCliAuthorizationDeniedError491 | CliTokenManager.CloudCliTokenManagerError
330 >;492 >;
331 /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */493 /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */
332 readonly reconcileDesiredLinkIfStillDesired: (494 readonly reconcileDesiredLinkIfStillDesired: (
...@@ -334,9 +496,11 @@ export class CloudLink extends Context.Service<...@@ -334,9 +496,11 @@ export class CloudLink extends Context.Service<
334 ) => Effect.Effect<496 ) => Effect.Effect<
335 CliDesiredLinkMode | null,497 CliDesiredLinkMode | null,
336 | CloudLinkBackgroundError498 | CloudLinkBackgroundError
337 | EnvironmentHttpConflictError499 | CloudLinkAccountMismatchError
500 | CloudLinkAuthorizationMissingError
501 | CloudLinkRelayConfigInvalidError
338 | EnvironmentAuth.ServerAuthInternalError502 | EnvironmentAuth.ServerAuthInternalError
339 | CliTokenManager.CloudCliAuthorizationDeniedError503 | CliTokenManager.CloudCliTokenManagerError
340 >;504 >;
341 /** Tells the relay which local origin the stored tunnel serves, then starts it. */505 /** Tells the relay which local origin the stored tunnel serves, then starts it. */
342 readonly registerManagedTunnelRecovery: (506 readonly registerManagedTunnelRecovery: (
...@@ -359,8 +523,8 @@ export class CloudLink extends Context.Service<...@@ -359,8 +523,8 @@ export class CloudLink extends Context.Service<
359 options?: { readonly requireConfirmedOrigin?: boolean },523 options?: { readonly requireConfirmedOrigin?: boolean },
360 ) => Effect.Effect<524 ) => Effect.Effect<
361 boolean,525 boolean,
362 | EnvironmentCloudEndpointUnavailableError526 | CloudLinkEndpointUnavailableError
363 | EnvironmentHttpBadRequestError527 | CloudLinkOriginInvalidError
364 | ServerSecretStore.SecretStoreError528 | ServerSecretStore.SecretStoreError
365 >;529 >;
366 /** Deletes a CLI-managed tunnel when this server goes offline for good. */530 /** Deletes a CLI-managed tunnel when this server goes offline for good. */
...@@ -399,7 +563,7 @@ const make = Effect.gen(function* () {...@@ -399,7 +563,7 @@ const make = Effect.gen(function* () {
399563
400 const validateLinkedCloudUser = (564 const validateLinkedCloudUser = (
401 cloudUserId: string,565 cloudUserId: string,
402 ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> =>566 ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | CloudLinkAccountMismatchError> =>
403 secrets.get(CLOUD_LINKED_USER_ID).pipe(567 secrets.get(CLOUD_LINKED_USER_ID).pipe(
404 Effect.mapError(568 Effect.mapError(
405 (cause) =>569 (cause) =>
...@@ -414,12 +578,7 @@ const make = Effect.gen(function* () {...@@ -414,12 +578,7 @@ const make = Effect.gen(function* () {
414 const existingCloudUserId = bytesToString(existing.value);578 const existingCloudUserId = bytesToString(existing.value);
415 return existingCloudUserId === cloudUserId579 return existingCloudUserId === cloudUserId
416 ? Effect.void580 ? Effect.void
417 : Effect.fail(581 : Effect.fail(new CloudLinkAccountMismatchError({}));
418 new EnvironmentHttpConflictError({
419 message:
420 "This environment is already linked to a different cloud account. Unlink it before switching accounts.",
421 }),
422 );
423 }),582 }),
424 );583 );
425584
...@@ -450,9 +609,7 @@ const make = Effect.gen(function* () {...@@ -450,9 +609,7 @@ const make = Effect.gen(function* () {
450 requestUrl,609 requestUrl,
451 })610 })
452 ) {611 ) {
453 return yield* new EnvironmentHttpBadRequestError({612 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
454 message: "Invalid managed endpoint origin.",
455 });
456 }613 }
457 const now = yield* DateTime.now;614 const now = yield* DateTime.now;
458 const expiresAt = DateTime.add(now, { minutes: 5 });615 const expiresAt = DateTime.add(now, { minutes: 5 });
...@@ -491,24 +648,13 @@ const make = Effect.gen(function* () {...@@ -491,24 +648,13 @@ const make = Effect.gen(function* () {
491 function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) {648 function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) {
492 const requestUrl = requestAbsoluteUrl(httpRequest);649 const requestUrl = requestAbsoluteUrl(httpRequest);
493 if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) {650 if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) {
494 return yield* new EnvironmentHttpBadRequestError({651 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
495 message: "Invalid managed endpoint origin.",
496 });
497 }652 }
498 const proof = yield* makeCloudLinkProof(request, requestUrl);653 const proof = yield* makeCloudLinkProof(request, requestUrl);
499 return proof satisfies RelayEnvironmentLinkProof;654 return proof satisfies RelayEnvironmentLinkProof;
500 },655 },
501 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>656 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")),
502 failEnvironmentCloudInternalError(error.message)(error),657 Effect.catchTag("PlatformError", internalError("generate-link-proof")),
503 ),
504 Effect.catchIf(
505 ServerSecretStore.isSecretStoreError,
506 failEnvironmentCloudInternalError("Could not generate environment link proof."),
507 ),
508 Effect.catchTag(
509 "PlatformError",
510 failEnvironmentCloudInternalError("Could not generate environment link proof."),
511 ),
512 );658 );
513659
514 const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(660 const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(
...@@ -528,8 +674,8 @@ const make = Effect.gen(function* () {...@@ -528,8 +674,8 @@ const make = Effect.gen(function* () {
528 }674 }
529 const status = yield* endpointRuntime.applyConfig(input.config);675 const status = yield* endpointRuntime.applyConfig(input.config);
530 if (status.status !== "running") {676 if (status.status !== "running") {
531 return yield* new EnvironmentCloudEndpointUnavailableError({677 return yield* new CloudLinkEndpointUnavailableError({
532 message: "Managed endpoint runtime could not be started.",678 reason: "runtime-not-started",
533 endpointRuntimeStatus: status,679 endpointRuntimeStatus: status,
534 });680 });
535 }681 }
...@@ -557,7 +703,7 @@ const make = Effect.gen(function* () {...@@ -557,7 +703,7 @@ const make = Effect.gen(function* () {
557 ? activate.pipe(703 ? activate.pipe(
558 Effect.retry({704 Effect.retry({
559 while: (error) =>705 while: (error) =>
560 error._tag === "EnvironmentCloudEndpointUnavailableError" &&706 error._tag === "CloudLinkEndpointUnavailableError" &&
561 ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus(707 ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus(
562 error.endpointRuntimeStatus,708 error.endpointRuntimeStatus,
563 ),709 ),
...@@ -578,10 +724,7 @@ const make = Effect.gen(function* () {...@@ -578,10 +724,7 @@ const make = Effect.gen(function* () {
578 const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true;724 const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true;
579 const parsedOrigin = yield* Effect.try({725 const parsedOrigin = yield* Effect.try({
580 try: () => parseManagedEndpointLocalOrigin(localOrigin),726 try: () => parseManagedEndpointLocalOrigin(localOrigin),
581 catch: () =>727 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
582 new EnvironmentHttpBadRequestError({
583 message: "Could not resolve local environment origin.",
584 }),
585 });728 });
586 return yield* endpointRuntime.withLinkStateLock(729 return yield* endpointRuntime.withLinkStateLock(
587 Effect.gen(function* () {730 Effect.gen(function* () {
...@@ -610,8 +753,8 @@ const make = Effect.gen(function* () {...@@ -610,8 +753,8 @@ const make = Effect.gen(function* () {
610 }753 }
611 const status = yield* endpointRuntime.applyConfig(config);754 const status = yield* endpointRuntime.applyConfig(config);
612 if (status.status !== "running") {755 if (status.status !== "running") {
613 return yield* new EnvironmentCloudEndpointUnavailableError({756 return yield* new CloudLinkEndpointUnavailableError({
614 message: "Managed endpoint runtime could not be started.",757 reason: "runtime-not-started",
615 endpointRuntimeStatus: status,758 endpointRuntimeStatus: status,
616 });759 });
617 }760 }
...@@ -637,8 +780,8 @@ const make = Effect.gen(function* () {...@@ -637,8 +780,8 @@ const make = Effect.gen(function* () {
637 payload.endpointRuntime !== null &&780 payload.endpointRuntime !== null &&
638 payload.endpointRuntime.providerKind !== "cloudflare_tunnel"781 payload.endpointRuntime.providerKind !== "cloudflare_tunnel"
639 ) {782 ) {
640 return yield* new EnvironmentCloudEndpointUnavailableError({783 return yield* new CloudLinkEndpointUnavailableError({
641 message: "Managed endpoint runtime could not be started.",784 reason: "runtime-not-started",
642 endpointRuntimeStatus: {785 endpointRuntimeStatus: {
643 status: "unsupported",786 status: "unsupported",
644 providerKind: payload.endpointRuntime.providerKind,787 providerKind: payload.endpointRuntime.providerKind,
...@@ -674,8 +817,8 @@ const make = Effect.gen(function* () {...@@ -674,8 +817,8 @@ const make = Effect.gen(function* () {
674 }817 }
675 const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime);818 const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime);
676 if (endpointRuntimeStatus.status !== "running") {819 if (endpointRuntimeStatus.status !== "running") {
677 return yield* new EnvironmentCloudEndpointUnavailableError({820 return yield* new CloudLinkEndpointUnavailableError({
678 message: "Managed endpoint runtime could not be started.",821 reason: "runtime-not-started",
679 endpointRuntimeStatus,822 endpointRuntimeStatus,
680 });823 });
681 }824 }
...@@ -711,20 +854,12 @@ const make = Effect.gen(function* () {...@@ -711,20 +854,12 @@ const make = Effect.gen(function* () {
711 function* (localOrigin: string) {854 function* (localOrigin: string) {
712 const parsedOrigin = yield* Effect.try({855 const parsedOrigin = yield* Effect.try({
713 try: () => parseManagedEndpointLocalOrigin(localOrigin),856 try: () => parseManagedEndpointLocalOrigin(localOrigin),
714 catch: () =>857 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
715 new EnvironmentHttpBadRequestError({
716 message: "Could not resolve local environment origin.",
717 }),
718 });858 });
719 const token = yield* cliTokenManager.getExisting.pipe(859 const token = yield* cliTokenManager.getExisting.pipe(
720 Effect.flatMap(860 Effect.flatMap(
721 Option.match({861 Option.match({
722 onNone: () =>862 onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})),
723 Effect.fail(
724 new EnvironmentHttpUnauthorizedError({
725 message: "Run `t3 connect link` to authorize this environment.",
726 }),
727 ),
728 onSome: Effect.succeed,863 onSome: Effect.succeed,
729 }),864 }),
730 ),865 ),
...@@ -786,17 +921,7 @@ const make = Effect.gen(function* () {...@@ -786,17 +921,7 @@ const make = Effect.gen(function* () {
786 // actually used, not from a value read before the relay round trip.921 // actually used, not from a value read before the relay round trip.
787 return mode;922 return mode;
788 },923 },
789 Effect.catchIf(924 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")),
790 ServerSecretStore.isSecretStoreError,
791 failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."),
792 ),
793 Effect.catchTags({
794 CloudCliCredentialRemovalError: failCloudCliTokenManagerError,
795 CloudCliCredentialRefreshError: failCloudCliTokenManagerError,
796 CloudCliCredentialReadError: failCloudCliTokenManagerError,
797 CloudCliAuthorizationError: failCloudCliTokenManagerError,
798 CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError,
799 }),
800 );925 );
801926
802 const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* (927 const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* (
...@@ -853,10 +978,7 @@ const make = Effect.gen(function* () {...@@ -853,10 +978,7 @@ const make = Effect.gen(function* () {
853 payload,978 payload,
854 }).pipe(979 }).pipe(
855 Effect.mapError(980 Effect.mapError(
856 () =>981 (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }),
857 new EnvironmentHttpInternalServerError({
858 message: "Could not sign the managed tunnel recovery request.",
859 }),
860 ),982 ),
861 );983 );
862 });984 });
...@@ -886,10 +1008,7 @@ const make = Effect.gen(function* () {...@@ -886,10 +1008,7 @@ const make = Effect.gen(function* () {
8861008
887 const parsedOrigin = yield* Effect.try({1009 const parsedOrigin = yield* Effect.try({
888 try: () => parseManagedEndpointLocalOrigin(localOrigin),1010 try: () => parseManagedEndpointLocalOrigin(localOrigin),
889 catch: () =>1011 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
890 new EnvironmentHttpBadRequestError({
891 message: "Could not resolve local environment origin.",
892 }),
893 });1012 });
894 if (config.tunnelId === undefined) {1013 if (config.tunnelId === undefined) {
895 return { status: "recovery_required" as const, config };1014 return { status: "recovery_required" as const, config };
...@@ -967,10 +1086,7 @@ const make = Effect.gen(function* () {...@@ -967,10 +1086,7 @@ const make = Effect.gen(function* () {
9671086
968 const parsedOrigin = yield* Effect.try({1087 const parsedOrigin = yield* Effect.try({
969 try: () => parseManagedEndpointLocalOrigin(localOrigin),1088 try: () => parseManagedEndpointLocalOrigin(localOrigin),
970 catch: () =>1089 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
971 new EnvironmentHttpBadRequestError({
972 message: "Could not resolve local environment origin.",
973 }),
974 });1090 });
9751091
976 const environmentId = yield* environment.getEnvironmentId;1092 const environmentId = yield* environment.getEnvironmentId;
...@@ -996,17 +1112,12 @@ const make = Effect.gen(function* () {...@@ -996,17 +1112,12 @@ const make = Effect.gen(function* () {
996 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,1112 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,
997 });1113 });
998 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {1114 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {
999 return yield* new EnvironmentHttpInternalServerError({1115 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });
1000 message: "T3 Connect returned an unsupported managed tunnel configuration.",
1001 });
1002 }1116 }
10031117
1004 const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe(1118 const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe(
1005 Effect.mapError(1119 Effect.mapError(
1006 () =>1120 (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }),
1007 new EnvironmentHttpInternalServerError({
1008 message: "Could not persist the recovered managed tunnel configuration.",
1009 }),
1010 ),1121 ),
1011 );1122 );
1012 const stored = yield* endpointRuntime.withLinkStateLock(1123 const stored = yield* endpointRuntime.withLinkStateLock(
...@@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () {...@@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () {
1041 if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") {1152 if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") {
1042 const address = httpServer.address;1153 const address = httpServer.address;
1043 if (typeof address === "string" || !("port" in address)) {1154 if (typeof address === "string" || !("port" in address)) {
1044 return yield* new EnvironmentHttpInternalServerError({1155 return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" });
1045 message: "Could not resolve the local server origin.",
1046 });
1047 }1156 }
1048 const registration = yield* registerManagedTunnelRecovery(1157 const registration = yield* registerManagedTunnelRecovery(
1049 `http://127.0.0.1:${address.port}`,1158 `http://127.0.0.1:${address.port}`,
...@@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () {...@@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () {
1051 Effect.retry({1160 Effect.retry({
1052 times: 2,1161 times: 2,
1053 while: (error) =>1162 while: (error) =>
1054 shouldRetryCloudLink(error) &&1163 shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError",
1055 error._tag !== "EnvironmentCloudEndpointUnavailableError",
1056 }),1164 }),
1057 );1165 );
1058 if (registration.status === "superseded") {1166 if (registration.status === "superseded") {
1059 return yield* new EnvironmentHttpConflictError({1167 return yield* new CloudLinkTunnelSupersededError({});
1060 message: "The managed tunnel configuration changed during registration.",
1061 });
1062 }1168 }
1063 if (registration.status === "recovery_required") {1169 if (registration.status === "recovery_required") {
1064 yield* endpointRuntime.requestRecovery(registration.config);1170 yield* endpointRuntime.requestRecovery(registration.config);
1065 }1171 }
1066 if (registration.status !== "ready") {1172 if (registration.status !== "ready") {
1067 return yield* new EnvironmentCloudEndpointUnavailableError({1173 return yield* new CloudLinkEndpointUnavailableError({
1068 message: "Managed endpoint origin could not be confirmed.",1174 reason: "origin-unconfirmed",
1069 endpointRuntimeStatus: { status: "disabled" },1175 endpointRuntimeStatus: { status: "disabled" },
1070 });1176 });
1071 }1177 }
...@@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () {...@@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () {
1076 }1182 }
1077 return result;1183 return result;
1078 },1184 },
1079 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>1185 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")),
1080 failEnvironmentCloudInternalError(error.message)(error),
1081 ),
1082 Effect.catchIf(
1083 ServerSecretStore.isSecretStoreError,
1084 failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
1085 ),
1086 Effect.catchTags({1186 Effect.catchTags({
1087 SchemaError: failEnvironmentCloudInternalError(1187 SchemaError: internalError("persist-relay-config"),
1088 "Could not persist environment relay configuration.",1188 PlatformError: internalError("register-endpoint-origin"),
1089 ),
1090 PlatformError: failEnvironmentCloudInternalError(
1091 "Could not register the managed endpoint origin.",
1092 ),
1093 }),1189 }),
1094 );1190 );
10951191
...@@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () {...@@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () {
1216 function* () {1312 function* () {
1217 return yield* readCloudLinkState();1313 return yield* readCloudLinkState();
1218 },1314 },
1219 Effect.catchIf(1315 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")),
1220 ServerSecretStore.isSecretStoreError,
1221 failEnvironmentCloudInternalError("Could not read environment relay configuration."),
1222 ),
1223 );1316 );
12241317
1225 const unlink = Effect.fn("environment.cloud.unlink")(1318 const unlink = Effect.fn("environment.cloud.unlink")(
...@@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () {...@@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () {
1246 }),1339 }),
1247 );1340 );
1248 },1341 },
1249 Effect.catchIf(1342 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")),
1250 ServerSecretStore.isSecretStoreError,
1251 failEnvironmentCloudInternalError("Could not remove environment relay configuration."),
1252 ),
1253 );1343 );
12541344
1255 const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(1345 const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(
1256 function* (holdWebhooksWhileOffline: boolean) {1346 function* (holdWebhooksWhileOffline: boolean) {
1257 const connection = yield* readRelayConnection.pipe(withSecrets);1347 const connection = yield* readRelayConnection.pipe(withSecrets);
1258 if (connection === null) {1348 if (connection === null) {
1259 return yield* new EnvironmentHttpBadRequestError({1349 return yield* new CloudLinkNotLinkedError({});
1260 message: "Link this environment to T3 Connect first.",
1261 });
1262 }1350 }
1263 const environmentId = yield* environment.getEnvironmentId;1351 const environmentId = yield* environment.getEnvironmentId;
1264 const client = yield* makeRelayEnvironmentClient(connection);1352 const client = yield* makeRelayEnvironmentClient(connection);
...@@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () {...@@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () {
1267 params: { environmentId },1355 params: { environmentId },
1268 payload: { holdWebhooksWhileOffline },1356 payload: { holdWebhooksWhileOffline },
1269 })1357 })
1270 .pipe(1358 .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings")));
1271 Effect.timeout("10 seconds"),
1272 Effect.catch(
1273 failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."),
1274 ),
1275 );
1276 },1359 },
1277 );1360 );
12781361
1279 const savePreference = (name: string, value: boolean) =>1362 const savePreference = (name: string, value: boolean) =>
1280 secrets1363 secrets
1281 .set(name, stringToBytes(String(value)))1364 .set(name, stringToBytes(String(value)))
1282 .pipe(1365 .pipe(Effect.catch(internalError("persist-preferences")));
1283 Effect.catch(
1284 failEnvironmentCloudInternalError("Could not persist environment cloud preferences."),
1285 ),
1286 );
12871366
1288 // One update at a time, so two requests can't each leave one setting behind.1367 // One update at a time, so two requests can't each leave one setting behind.
1289 const preferencesLock = yield* Semaphore.make(1);1368 const preferencesLock = yield* Semaphore.make(1);
...@@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () {...@@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () {
1295 // current values are read up front; a failed read stops here, before1374 // current values are read up front; a failed read stops here, before
1296 // anything changes, because a guessed value would be the rollback target.1375 // anything changes, because a guessed value would be the rollback target.
1297 const readCurrent = (name: string) =>1376 const readCurrent = (name: string) =>
1298 secrets1377 secrets.get(name).pipe(Effect.catch(internalError("read-preferences")));
1299 .get(name)
1300 .pipe(
1301 Effect.catch(
1302 failEnvironmentCloudInternalError("Could not read environment cloud preferences."),
1303 ),
1304 );
1305 const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET);1378 const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET);
1306 const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET);1379 const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET);
1307 yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);1380 yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);
...@@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () {...@@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () {
1342 yield* savePreferences(input);1415 yield* savePreferences(input);
1343 return yield* readCloudLinkState();1416 return yield* readCloudLinkState();
1344 },1417 },
1345 Effect.catchIf(1418 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")),
1346 ServerSecretStore.isSecretStoreError,
1347 failEnvironmentCloudInternalError("Could not read environment cloud preferences."),
1348 ),
1349 );1419 );
13501420
1351 const answerHealthRequest = Effect.fn("environment.cloud.health")(1421 const answerHealthRequest = Effect.fn("environment.cloud.health")(
...@@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () {...@@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () {
1397 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||1467 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
1398 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" })1468 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" })
1399 ) {1469 ) {
1400 return yield* new EnvironmentHttpUnauthorizedError({1470 return yield* new CloudLinkProofRejectedError({ request: "health" });
1401 message: "Invalid cloud health request.",
1402 });
1403 }1471 }
1404 const proof = proofOption.value;1472 const proof = proofOption.value;
14051473
...@@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () {...@@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () {
1411 value: stringToBytes(DateTime.formatIso(now)),1479 value: stringToBytes(DateTime.formatIso(now)),
1412 });1480 });
1413 if (!consumedReplayGuards) {1481 if (!consumedReplayGuards) {
1414 return yield* new EnvironmentHttpConflictError({1482 return yield* new CloudLinkProofReplayedError({ request: "health" });
1415 message: "Cloud health request was already consumed.",
1416 });
1417 }1483 }
14181484
1419 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);1485 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
...@@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () {...@@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () {
1452 proof: responseProof,1518 proof: responseProof,
1453 } satisfies RelayEnvironmentHealthResponse;1519 } satisfies RelayEnvironmentHealthResponse;
1454 },1520 },
1455 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>1521 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")),
1456 failEnvironmentCloudInternalError(error.message)(error),1522 Effect.catchTag("PlatformError", internalError("answer-health")),
1457 ),
1458 Effect.catchIf(
1459 ServerSecretStore.isSecretStoreError,
1460 failEnvironmentCloudInternalError("Could not answer cloud health request."),
1461 ),
1462 Effect.catchTag(
1463 "PlatformError",
1464 failEnvironmentCloudInternalError("Could not answer cloud health request."),
1465 ),
1466 );1523 );
14671524
1468 const mintCredential = Effect.fn("environment.cloud.mintCredential")(1525 const mintCredential = Effect.fn("environment.cloud.mintCredential")(
...@@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () {...@@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () {
1515 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||1572 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
1516 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" })1573 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" })
1517 ) {1574 ) {
1518 return yield* new EnvironmentHttpUnauthorizedError({1575 return yield* new CloudLinkProofRejectedError({ request: "mint" });
1519 message: "Invalid cloud mint request.",
1520 });
1521 }1576 }
1522 const proof = proofOption.value;1577 const proof = proofOption.value;
15231578
...@@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () {...@@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () {
1529 value: stringToBytes(DateTime.formatIso(now)),1584 value: stringToBytes(DateTime.formatIso(now)),
1530 });1585 });
1531 if (!consumedReplayGuards) {1586 if (!consumedReplayGuards) {
1532 return yield* new EnvironmentHttpConflictError({1587 return yield* new CloudLinkProofReplayedError({ request: "mint" });
1533 message: "Cloud mint request was already consumed.",
1534 });
1535 }1588 }
15361589
1537 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);1590 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
...@@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () {...@@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () {
1572 proof: responseProof,1625 proof: responseProof,
1573 } satisfies RelayEnvironmentMintResponse;1626 } satisfies RelayEnvironmentMintResponse;
1574 },1627 },
1575 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>1628 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")),
1576 failEnvironmentCloudInternalError(error.message)(error),1629 Effect.catchTag("PlatformError", internalError("issue-credential")),
1577 ),
1578 Effect.catchIf(
1579 ServerSecretStore.isSecretStoreError,
1580 failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
1581 ),
1582 Effect.catchTag(
1583 "PlatformError",
1584 failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
1585 ),
1586 );1630 );
15871631
1588 return CloudLink.of({1632 return CloudLink.of({
apps/server/src/cloud/http.test.ts created+98
...@@ -0,0 +1,98 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentHttpApi } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Etag from "effect/http/Etag";
7import * as HttpPlatform from "effect/http/HttpPlatform";
8import * as HttpRouter from "effect/http/HttpRouter";
9import * as HttpApi from "effect/http-api/HttpApi";
10import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
11
12import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
13import * as AuthHttp from "../auth/http.ts";
14import * as CloudLink from "./CloudLink.ts";
15import * as ConnectHttp from "./http.ts";
16
17class ConnectTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.connect) {}
18
19// The signed relay routes need no session, so a CloudLink that fails each
20// request shows how the transport answers that failure.
21type HealthFailure = Effect.Error<
22 ReturnType<CloudLink.CloudLink["Service"]["answerHealthRequest"]>
23>;
24
25const answerHealthWith = async (failure: HealthFailure) => {
26 const layerRoutes = HttpApiBuilder.layer(ConnectTestApi).pipe(
27 Layer.provide(ConnectHttp.layer),
28 Layer.provide(
29 Layer.mock(CloudLink.CloudLink)({
30 answerHealthRequest: () => Effect.fail(failure),
31 }),
32 ),
33 // The session-gated routes are declared too; this request never reaches them.
34 Layer.provide(AuthHttp.layerAuthenticatedAuth),
35 Layer.provide(Layer.mock(EnvironmentAuth.EnvironmentAuth)({})),
36 Layer.provideMerge(
37 HttpPlatform.layer.pipe(
38 Layer.provideMerge(NodeServices.layer),
39 Layer.provideMerge(Etag.layerWeak),
40 ),
41 ),
42 );
43 const { handler, dispose } = HttpRouter.toWebHandler(layerRoutes, {
44 disableLogger: true,
45 });
46 try {
47 const response = await handler(
48 new Request("http://127.0.0.1/api/t3-connect/health", {
49 method: "POST",
50 headers: { "content-type": "application/json" },
51 body: JSON.stringify({ proof: "proof" }),
52 }),
53 );
54 return { status: response.status, body: (await response.json()) as unknown };
55 } finally {
56 await dispose();
57 }
58};
59
60describe("connect routes", () => {
61 it.each([
62 {
63 failure: new CloudLink.CloudLinkProofRejectedError({ request: "health" }),
64 status: 401,
65 body: { _tag: "EnvironmentHttpUnauthorizedError", message: "Invalid cloud health request." },
66 },
67 {
68 failure: new CloudLink.CloudLinkProofReplayedError({ request: "health" }),
69 status: 409,
70 body: {
71 _tag: "EnvironmentHttpConflictError",
72 message: "Cloud health request was already consumed.",
73 },
74 },
75 {
76 failure: new CloudLink.CloudLinkInternalError({
77 operation: "answer-health",
78 cause: new Error("disk full"),
79 }),
80 status: 500,
81 body: {
82 _tag: "EnvironmentHttpInternalServerError",
83 message: "Could not answer cloud health request.",
84 },
85 },
86 {
87 failure: new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}),
88 status: 500,
89 body: {
90 _tag: "EnvironmentHttpInternalServerError",
91 message: "Cloud mint public key is not installed for this environment.",
92 },
93 },
94 ])("answers $failure._tag with HTTP $status", async ({ failure, status, body }) => {
95 const response = await answerHealthWith(failure);
96 expect(response).toEqual({ status, body });
97 });
98});
apps/server/src/cloud/http.ts+106-15
...@@ -1,11 +1,23 @@...@@ -1,11 +1,23 @@
1import { AuthRelayReadScope, AuthRelayWriteScope, EnvironmentHttpApi } from "@t3tools/contracts";1import {
2 AuthRelayReadScope,
3 AuthRelayWriteScope,
4 EnvironmentCloudEndpointUnavailableError,
5 EnvironmentHttpApi,
6 EnvironmentHttpBadRequestError,
7 EnvironmentHttpConflictError,
8 EnvironmentHttpForbiddenError,
9 EnvironmentHttpInternalServerError,
10 EnvironmentHttpUnauthorizedError,
11} from "@t3tools/contracts";
2import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
3import * as HttpEffect from "effect/http/HttpEffect";13import * as HttpEffect from "effect/http/HttpEffect";
4import { HttpServerRequest, HttpServerResponse } from "effect/http";14import { HttpServerRequest, HttpServerResponse } from "effect/http";
5import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";15import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
616
7import { requireEnvironmentScope } from "../auth/http.ts";17import { requireEnvironmentScope } from "../auth/http.ts";
18import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
8import * as CloudLink from "./CloudLink.ts";19import * as CloudLink from "./CloudLink.ts";
20import type { RelayRequestError } from "./relayResponse.ts";
9import { traceRelayRequest } from "./traceRelayRequest.ts";21import { traceRelayRequest } from "./traceRelayRequest.ts";
1022
11const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {23const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
...@@ -18,6 +30,81 @@ const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler...@@ -18,6 +30,81 @@ const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler
18 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),30 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
19);31);
2032
33const internalServerError = (error: { readonly message: string }, cause: unknown) =>
34 Effect.logError(error.message, { cause }).pipe(
35 Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message: error.message }))),
36 );
37
38const relayFailure = (error: RelayRequestError) => {
39 const message = error.message;
40 switch (error.rejection) {
41 case "unauthorized":
42 return Effect.fail(new EnvironmentHttpUnauthorizedError({ message }));
43 case "forbidden":
44 return Effect.fail(new EnvironmentHttpForbiddenError({ message }));
45 case "rejected":
46 return Effect.fail(new EnvironmentHttpBadRequestError({ message }));
47 case "unavailable":
48 return Effect.fail(new EnvironmentHttpInternalServerError({ message }));
49 }
50};
51
52const badRequest = (error: { readonly message: string }) =>
53 Effect.fail(new EnvironmentHttpBadRequestError({ message: error.message }));
54const unauthorized = (error: { readonly message: string }) =>
55 Effect.fail(new EnvironmentHttpUnauthorizedError({ message: error.message }));
56const conflict = (error: { readonly message: string }) =>
57 Effect.fail(new EnvironmentHttpConflictError({ message: error.message }));
58
59/** How a connect route answers each CloudLink failure. Messages carry through unchanged. */
60const connectErrorCases = {
61 CloudLinkRelayConfigInvalidError: badRequest,
62 CloudLinkOriginInvalidError: badRequest,
63 CloudLinkNotLinkedError: badRequest,
64 CloudLinkAccountMismatchError: conflict,
65 CloudLinkAuthorizationMissingError: unauthorized,
66 CloudLinkProofRejectedError: unauthorized,
67 CloudLinkProofReplayedError: conflict,
68 CloudLinkTunnelSupersededError: conflict,
69 CloudLinkInternalError: (error: CloudLink.CloudLinkInternalError) =>
70 internalServerError(error, error.cause),
71 RelayRequestError: relayFailure,
72} as const;
73
74type ConnectFailure =
75 | Exclude<CloudLink.CloudLinkError, CloudLink.CloudLinkEndpointUnavailableError>
76 | EnvironmentAuth.ServerAuthInternalError
77 | RelayRequestError;
78
79/** Internal failures are logged with their cause before the route answers 500. */
80const toHttpError = <A, R>(effect: Effect.Effect<A, ConnectFailure, R>) =>
81 effect.pipe(
82 Effect.catchTags(connectErrorCases),
83 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
84 internalServerError(error, error),
85 ),
86 );
87
88/** Relay configuration is the one route that answers 503 when the tunnel cannot serve. */
89const toHttpErrorOrUnavailable = <A, R>(
90 effect: Effect.Effect<A, ConnectFailure | CloudLink.CloudLinkEndpointUnavailableError, R>,
91) =>
92 effect.pipe(
93 Effect.catchTags({
94 ...connectErrorCases,
95 CloudLinkEndpointUnavailableError: (error) =>
96 Effect.fail(
97 new EnvironmentCloudEndpointUnavailableError({
98 message: error.message,
99 endpointRuntimeStatus: error.endpointRuntimeStatus,
100 }),
101 ),
102 }),
103 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
104 internalServerError(error, error),
105 ),
106 );
107
21export const layer = HttpApiBuilder.group(108export const layer = HttpApiBuilder.group(
22 EnvironmentHttpApi,109 EnvironmentHttpApi,
23 "connect",110 "connect",
...@@ -28,42 +115,46 @@ export const layer = HttpApiBuilder.group(...@@ -28,42 +115,46 @@ export const layer = HttpApiBuilder.group(
28 Effect.gen(function* () {115 Effect.gen(function* () {
29 yield* requireEnvironmentScope(AuthRelayWriteScope);116 yield* requireEnvironmentScope(AuthRelayWriteScope);
30 const request = yield* HttpServerRequest.HttpServerRequest;117 const request = yield* HttpServerRequest.HttpServerRequest;
31 const proof = yield* cloudLink.linkProof(payload, request);118 const proof = yield* toHttpError(cloudLink.linkProof(payload, request));
32 yield* appendCloudCredentialResponseHeaders;119 yield* appendCloudCredentialResponseHeaders;
33 return proof;120 return proof;
34 }),121 }),
35 )122 )
36 .handle("relayConfig", ({ payload }) =>123 .handle("relayConfig", ({ payload }) =>
37 requireEnvironmentScope(AuthRelayWriteScope).pipe(124 requireEnvironmentScope(AuthRelayWriteScope).pipe(
38 Effect.andThen(cloudLink.applyRelayConfig(payload)),125 Effect.andThen(toHttpErrorOrUnavailable(cloudLink.applyRelayConfig(payload))),
39 ),126 ),
40 )127 )
41 .handle("linkState", () =>128 .handle("linkState", () =>
42 requireEnvironmentScope(AuthRelayReadScope).pipe(Effect.andThen(cloudLink.linkState())),129 requireEnvironmentScope(AuthRelayReadScope).pipe(
130 Effect.andThen(toHttpError(cloudLink.linkState())),
131 ),
43 )132 )
44 .handle("unlink", () =>133 .handle("unlink", () =>
45 requireEnvironmentScope(AuthRelayWriteScope).pipe(Effect.andThen(cloudLink.unlink())),134 requireEnvironmentScope(AuthRelayWriteScope).pipe(
135 Effect.andThen(toHttpError(cloudLink.unlink())),
136 ),
46 )137 )
47 .handle("preferences", ({ payload }) =>138 .handle("preferences", ({ payload }) =>
48 requireEnvironmentScope(AuthRelayWriteScope).pipe(139 requireEnvironmentScope(AuthRelayWriteScope).pipe(
49 Effect.andThen(cloudLink.updatePreferences(payload)),140 Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))),
50 ),141 ),
51 )142 )
52 .handle("health", ({ payload }) =>143 .handle("health", ({ payload }) =>
53 cloudLink144 toHttpError(cloudLink.answerHealthRequest(payload)).pipe(
54 .answerHealthRequest(payload)145 Effect.tap(() => appendCloudCredentialResponseHeaders),
55 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),146 ),
56 )147 )
57 .handle("mintCredential", ({ payload }) =>148 .handle("mintCredential", ({ payload }) =>
58 cloudLink149 toHttpError(cloudLink.mintCredential(payload)).pipe(
59 .mintCredential(payload)150 Effect.tap(() => appendCloudCredentialResponseHeaders),
60 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),151 ),
61 )152 )
62 .handle("t3MintCredential", ({ payload }) =>153 .handle("t3MintCredential", ({ payload }) =>
63 traceRelayRequest(154 traceRelayRequest(
64 cloudLink155 toHttpError(cloudLink.mintCredential(payload)).pipe(
65 .mintCredential(payload)156 Effect.tap(() => appendCloudCredentialResponseHeaders),
66 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),157 ),
67 ),158 ),
68 );159 );
69 }),160 }),
apps/server/src/cloud/relayResponse.test.ts+13-9
...@@ -3,7 +3,11 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,11 @@ import * as Effect from "effect/Effect";
3import { HttpClientRequest, HttpClientResponse } from "effect/http";3import { HttpClientRequest, HttpClientResponse } from "effect/http";
4import * as HttpClientError from "effect/http/HttpClientError";4import * as HttpClientError from "effect/http/HttpClientError";
55
6import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";6import {
7 filterRelayResponse,
8 relayRequestError,
9 shouldRetryRelayRequest,
10} from "./relayResponse.ts";
711
8const response = (12const response = (
9 status: number,13 status: number,
...@@ -28,12 +32,12 @@ it("reports a transport failure category without exposing request or cause detai...@@ -28,12 +32,12 @@ it("reports a transport failure category without exposing request or cause detai
28 }),32 }),
29 );33 );
3034
31 expect(error._tag).toBe("EnvironmentHttpInternalServerError");35 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
32 expect(error.message).toContain("TransportError");36 expect(error.message).toContain("TransportError");
33 expect(error.message).toContain("network connection");37 expect(error.message).toContain("network connection");
34 expect(error.message).not.toContain("relay.example.test");38 expect(error.message).not.toContain("relay.example.test");
35 expect(error.message).not.toContain("private");39 expect(error.message).not.toContain("private");
36 expect(shouldRetryCloudLink(error)).toBe(true);40 expect(shouldRetryRelayRequest(error)).toBe(true);
37});41});
3842
39it.effect("reports the tunnel limit and relay trace instead of a generic 403", () =>43it.effect("reports the tunnel limit and relay trace instead of a generic 403", () =>
...@@ -47,7 +51,7 @@ it.effect("reports the tunnel limit and relay trace instead of a generic 403", (...@@ -47,7 +51,7 @@ it.effect("reports the tunnel limit and relay trace instead of a generic 403", (
47 }),51 }),
48 ).pipe(Effect.mapError(relayRequestError), Effect.flip);52 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
4953
50 expect(error._tag).toBe("EnvironmentHttpForbiddenError");54 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
51 expect(error.message).toContain("at most 3 tunnels");55 expect(error.message).toContain("at most 3 tunnels");
52 expect(error.message).toContain("Unlink an unused environment");56 expect(error.message).toContain("Unlink an unused environment");
53 expect(error.message).toContain("Trace ID: trace-limit");57 expect(error.message).toContain("Trace ID: trace-limit");
...@@ -65,7 +69,7 @@ it.effect("makes revoked authorization actionable and non-retryable", () =>...@@ -65,7 +69,7 @@ it.effect("makes revoked authorization actionable and non-retryable", () =>
65 }),69 }),
66 ).pipe(Effect.mapError(relayRequestError), Effect.flip);70 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
6771
68 expect(error._tag).toBe("EnvironmentHttpUnauthorizedError");72 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" });
69 expect(error.message).toContain("invalid_bearer");73 expect(error.message).toContain("invalid_bearer");
70 expect(error.message).toContain("t3 connect login");74 expect(error.message).toContain("t3 connect login");
71 expect(error.message).toContain("Trace ID: trace-auth");75 expect(error.message).toContain("Trace ID: trace-auth");
...@@ -81,7 +85,7 @@ it.effect("reports an unrecognized access denial without printing its response b...@@ -81,7 +85,7 @@ it.effect("reports an unrecognized access denial without printing its response b
81 }),85 }),
82 ).pipe(Effect.flip);86 ).pipe(Effect.flip);
8387
84 expect(error._tag).toBe("EnvironmentHttpForbiddenError");88 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
85 expect(error.message).toContain("HTTP 403");89 expect(error.message).toContain("HTTP 403");
86 expect(error.message).toContain("proxy or firewall");90 expect(error.message).toContain("proxy or firewall");
87 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");91 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");
...@@ -94,7 +98,7 @@ it.effect.each([408, 429, 500, 502, 503, 504])(...@@ -94,7 +98,7 @@ it.effect.each([408, 429, 500, 502, 503, 504])(
94 (status) =>98 (status) =>
95 Effect.gen(function* () {99 Effect.gen(function* () {
96 const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip);100 const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip);
97 expect(error._tag).toBe("EnvironmentHttpInternalServerError");101 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
98 expect(error.message).toContain(`HTTP ${status}`);102 expect(error.message).toContain(`HTTP ${status}`);
99 }),103 }),
100);104);
...@@ -112,7 +116,7 @@ it.effect.each([...@@ -112,7 +116,7 @@ it.effect.each([
112 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));116 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));
113 }).pipe(117 }).pipe(
114 Effect.mapError(relayRequestError),118 Effect.mapError(relayRequestError),
115 Effect.retry({ while: shouldRetryCloudLink, times: 1 }),119 Effect.retry({ while: shouldRetryRelayRequest, times: 1 }),
116 Effect.result,120 Effect.result,
117 );121 );
118 expect(requests).toBe(attempts);122 expect(requests).toBe(attempts);
...@@ -131,7 +135,7 @@ it.effect("keeps the relay failure reason and trace when tunnel cleanup fails",...@@ -131,7 +135,7 @@ it.effect("keeps the relay failure reason and trace when tunnel cleanup fails",
131 }),135 }),
132 ).pipe(Effect.flip);136 ).pipe(Effect.flip);
133137
134 expect(error._tag).toBe("EnvironmentHttpInternalServerError");138 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
135 expect(error.message).toContain("upstream_unavailable");139 expect(error.message).toContain("upstream_unavailable");
136 expect(error.message).toContain("Trace ID: trace-cleanup");140 expect(error.message).toContain("Trace ID: trace-cleanup");
137 }),141 }),
apps/server/src/cloud/relayResponse.ts+35-34
...@@ -1,10 +1,3 @@...@@ -1,10 +1,3 @@
1import {
2 EnvironmentHttpBadRequestError,
3 EnvironmentHttpConflictError,
4 EnvironmentHttpForbiddenError,
5 EnvironmentHttpInternalServerError,
6 EnvironmentHttpUnauthorizedError,
7} from "@t3tools/contracts";
8import { RelayProtectedError } from "@t3tools/contracts/relay";1import { RelayProtectedError } from "@t3tools/contracts/relay";
9import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";3import * as Option from "effect/Option";
...@@ -12,33 +5,37 @@ import * as Schema from "effect/Schema";...@@ -12,33 +5,37 @@ import * as Schema from "effect/Schema";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";5import * as HttpClientResponse from "effect/http/HttpClientResponse";
13import { isHttpClientError } from "effect/http/HttpClientError";6import { isHttpClientError } from "effect/http/HttpClientError";
147
15const isRelayResponseError = Schema.is(8/**
16 Schema.Union([9 * A relay request that did not succeed. `unavailable` means it may succeed on
17 EnvironmentHttpBadRequestError,10 * retry; the relay refused the other kinds. The description is safe to show:
18 EnvironmentHttpForbiddenError,11 * it carries the relay's own explanation and trace ID, never request details.
19 EnvironmentHttpInternalServerError,12 */
20 EnvironmentHttpUnauthorizedError,13export class RelayRequestError extends Schema.TaggedError<RelayRequestError>()(
21 ]),14 "RelayRequestError",
22);15 {
16 rejection: Schema.Literals(["unauthorized", "forbidden", "rejected", "unavailable"]),
17 description: Schema.String,
18 },
19) {
20 override get message(): string {
21 return this.description;
22 }
23}
24
25const isRelayRequestError = Schema.is(RelayRequestError);
2326
24export function relayRequestError(cause: unknown) {27export function relayRequestError(cause: unknown): RelayRequestError {
25 return isRelayResponseError(cause)28 return isRelayRequestError(cause)
26 ? cause29 ? cause
27 : new EnvironmentHttpInternalServerError({30 : new RelayRequestError({
28 message: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`,31 rejection: "unavailable",
32 description: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`,
29 });33 });
30}34}
3135
32const isPermanentCloudLinkError = Schema.is(36/** Whether a failure may succeed on retry: anything but a relay refusal. */
33 Schema.Union([37export const shouldRetryRelayRequest = (error: unknown): boolean =>
34 EnvironmentHttpBadRequestError,38 !isRelayRequestError(error) || error.rejection === "unavailable";
35 EnvironmentHttpForbiddenError,
36 EnvironmentHttpUnauthorizedError,
37 EnvironmentHttpConflictError,
38 ]),
39);
40
41export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error);
4239
43function recoveryHint(error: RelayProtectedError): string {40function recoveryHint(error: RelayProtectedError): string {
44 switch (error._tag) {41 switch (error._tag) {
...@@ -64,19 +61,23 @@ export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(func...@@ -64,19 +61,23 @@ export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(func
64 );61 );
65 const ray = response.headers["cf-ray"];62 const ray = response.headers["cf-ray"];
66 const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : "";63 const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : "";
67 const message = Option.isSome(decoded)64 const description = Option.isSome(decoded)
68 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`65 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`
69 : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`;66 : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`;
7067
71 if (response.status === 401) return yield* new EnvironmentHttpUnauthorizedError({ message });68 if (response.status === 401) {
72 if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message });69 return yield* new RelayRequestError({ rejection: "unauthorized", description });
70 }
71 if (response.status === 403) {
72 return yield* new RelayRequestError({ rejection: "forbidden", description });
73 }
73 if (74 if (
74 response.status >= 400 &&75 response.status >= 400 &&
75 response.status < 500 &&76 response.status < 500 &&
76 response.status !== 408 &&77 response.status !== 408 &&
77 response.status !== 42978 response.status !== 429
78 ) {79 ) {
79 return yield* new EnvironmentHttpBadRequestError({ message });80 return yield* new RelayRequestError({ rejection: "rejected", description });
80 }81 }
81 return yield* new EnvironmentHttpInternalServerError({ message });82 return yield* new RelayRequestError({ rejection: "unavailable", description });
82});83});
apps/server/src/server.ts+5-6
...@@ -126,7 +126,6 @@ import * as CloudHttp from "./cloud/http.ts";...@@ -126,7 +126,6 @@ import * as CloudHttp from "./cloud/http.ts";
126import * as CloudLink from "./cloud/CloudLink.ts";126import * as CloudLink from "./cloud/CloudLink.ts";
127import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";127import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";
128import * as RelayTracing from "./cloud/relayTracing.ts";128import * as RelayTracing from "./cloud/relayTracing.ts";
129import { shouldRetryCloudLink } from "./cloud/relayResponse.ts";
130import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";129import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
131import {130import {
132 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,131 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,
...@@ -845,8 +844,8 @@ const layerMakeServer = Layer.unwrap(...@@ -845,8 +844,8 @@ const layerMakeServer = Layer.unwrap(
845 ),844 ),
846 Effect.retry({845 Effect.retry({
847 while: (error) =>846 while: (error) =>
848 shouldRetryCloudLink(error) &&847 CloudLink.shouldRetryCloudLink(error) &&
849 error._tag !== "EnvironmentCloudEndpointUnavailableError",848 error._tag !== "CloudLinkEndpointUnavailableError",
850 schedule: Schedule.exponential("1 second").pipe(849 schedule: Schedule.exponential("1 second").pipe(
851 Schedule.modifyDelay(({ duration }) =>850 Schedule.modifyDelay(({ duration }) =>
852 Effect.succeed(Duration.min(duration, Duration.seconds(30))),851 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
...@@ -931,8 +930,8 @@ const layerMakeServer = Layer.unwrap(...@@ -931,8 +930,8 @@ const layerMakeServer = Layer.unwrap(
931 retryRuntimeFailures: true,930 retryRuntimeFailures: true,
932 }),931 }),
933 (error) =>932 (error) =>
934 shouldRetryCloudLink(error) &&933 CloudLink.shouldRetryCloudLink(error) &&
935 error._tag !== "EnvironmentCloudEndpointUnavailableError",934 error._tag !== "CloudLinkEndpointUnavailableError",
936 startedConfirmed ? Effect.void : startStoredManagedTunnel,935 startedConfirmed ? Effect.void : startStoredManagedTunnel,
937 ).pipe(936 ).pipe(
938 Effect.tap((result) =>937 Effect.tap((result) =>
...@@ -977,7 +976,7 @@ const layerMakeServer = Layer.unwrap(...@@ -977,7 +976,7 @@ const layerMakeServer = Layer.unwrap(
977 .reconcileDesiredLinkIfStillDesired(localOrigin)976 .reconcileDesiredLinkIfStillDesired(localOrigin)
978 .pipe(977 .pipe(
979 Effect.retry({978 Effect.retry({
980 while: shouldRetryCloudLink,979 while: CloudLink.shouldRetryCloudLink,
981 schedule: Schedule.exponential("1 second").pipe(980 schedule: Schedule.exponential("1 second").pipe(
982 Schedule.modifyDelay(({ duration }) =>981 Schedule.modifyDelay(({ duration }) =>
983 Effect.succeed(Duration.min(duration, Duration.seconds(30))),982 Effect.succeed(Duration.min(duration, Duration.seconds(30))),