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 {
4545 managedTunnelStartupAction,
4646 retryManagedTunnelRegistration,
4747} from "./managedTunnelStartup.ts";
48import { shouldRetryCloudLink } from "./relayResponse.ts";
4948import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
5049
5150const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
......@@ -109,10 +108,7 @@ describe("reconcileDesiredCloudLink", () => {
109108 const link = yield* CloudLink.CloudLink;
110109 const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774"));
111110
112 expect(error).toMatchObject({
113 _tag: "EnvironmentHttpUnauthorizedError",
114 message: "Run `t3 connect link` to authorize this environment.",
115 });
111 expect(error._tag).toBe("CloudLinkAuthorizationMissingError");
116112 }).pipe(
117113 Effect.provide(CloudLink.layer),
118114 Effect.provideService(HttpServer.HttpServer, idleHttpServer),
......@@ -689,7 +685,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
689685 link
690686 .registerManagedTunnelRecovery(localOrigin)
691687 .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))),
692 shouldRetryCloudLink,
688 CloudLink.shouldRetryCloudLink,
693689 link
694690 .startManagedTunnelIfOriginConfirmed(localOrigin, {
695691 requireConfirmedOrigin: false,
......@@ -1051,10 +1047,10 @@ describe("releaseManagedTunnelOnShutdown", () => {
10511047 });
10521048
10531049 it.effect.each([
1054 { status: 401, errorTag: "EnvironmentHttpUnauthorizedError" },
1055 { status: 403, errorTag: "EnvironmentHttpForbiddenError" },
1056 { status: 409, errorTag: "EnvironmentHttpBadRequestError" },
1057 ])("preserves a permanent $status relay recovery failure", ({ status, errorTag }) => {
1050 { status: 401, rejection: "unauthorized" },
1051 { status: 403, rejection: "forbidden" },
1052 { status: 409, rejection: "rejected" },
1053 ])("preserves a permanent $status relay recovery failure", ({ status, rejection }) => {
10581054 const { store } = makeMemorySecretStore([
10591055 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],
10601056 [RELAY_URL_SECRET, "https://relay.example.test"],
......@@ -1068,7 +1064,8 @@ describe("releaseManagedTunnelOnShutdown", () => {
10681064 const link = yield* CloudLink.CloudLink;
10691065 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);
10721069 expect(requests).toHaveLength(1);
10731070 expect(applyConfigCalls).toEqual([]);
10741071 }).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", () =>
137137 const error = yield* preferences
138138 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
139139 .pipe(Effect.flip);
140 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
140 assert.deepInclude(error, {
141 _tag: "CloudLinkInternalError",
142 operation: "persist-preferences",
143 });
141144 assert.deepEqual(relayCalls, [true, false]);
142145 assert.equal(
143146 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",
153156 const error = yield* preferences
154157 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
155158 .pipe(Effect.flip);
156 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
159 assert.deepInclude(error, {
160 _tag: "CloudLinkInternalError",
161 operation: "persist-preferences",
162 });
157163 assert.deepEqual(relayCalls, []);
158164 assert.equal(
159165 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", ()
184190 const error = yield* preferences
185191 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
186192 .pipe(Effect.flip);
187 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
193 assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" });
188194 assert.deepEqual(relayCalls, []);
189195 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
190196 }),
......@@ -231,7 +237,7 @@ it.effect("changes nothing when the current hold setting can't be read", () =>
231237 const error = yield* preferences
232238 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false })
233239 .pipe(Effect.flip);
234 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
240 assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" });
235241 assert.deepEqual(relayCalls, []);
236242 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
237243 }),
apps/server/src/cloud/CloudLink.ts+302-258
......@@ -7,15 +7,9 @@
77import * as NodeCrypto from "node:crypto";
88import {
99 AuthStandardClientScopes,
10 EnvironmentCloudEndpointUnavailableError,
1110 type EnvironmentCloudLinkStateResult,
1211 type EnvironmentCloudPreferencesRequest,
1312 type EnvironmentCloudRelayConfigResult,
14 EnvironmentHttpBadRequestError,
15 EnvironmentHttpConflictError,
16 type EnvironmentHttpForbiddenError,
17 EnvironmentHttpInternalServerError,
18 EnvironmentHttpUnauthorizedError,
1913} from "@t3tools/contracts";
2014import {
2115 RelayCloudEnvironmentHealthProofPayload,
......@@ -106,7 +100,12 @@ import {
106100import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
107101import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
108102import { 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";
110109import {
111110 CLOUD_HEALTH_JTI_PREFIX,
112111 CLOUD_HEALTH_NONCE_PREFIX,
......@@ -126,15 +125,187 @@ import {
126125import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts";
127126
128127const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2);
129const failEnvironmentCloudInternalError =
130 (message: string) =>
131 (cause: unknown): Effect.Effect<never, EnvironmentHttpInternalServerError> =>
132 Effect.logError(message, { cause }).pipe(
133 Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))),
134 );
128const RELAY_CONFIG_FIELD_MESSAGES = {
129 relayUrl: "Relay URL must be a secure absolute HTTPS URL.",
130 relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.",
131 environmentCredential: "Relay environment credential is required.",
132 cloudUserId: "Cloud user id is required.",
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) =>
137 failEnvironmentCloudInternalError(error.message)(error);
200/** A signed relay request whose proof does not verify for this environment. */
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
139310/** A failed rollback leaves a setting changed; it is logged, not hidden. */
140311const rollbackFailed = (cause: unknown) =>
......@@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) =>
142313
143314const requireRelayUrl = relayUrlConfig.pipe(
144315 Effect.mapError(
145 () =>
146 new EnvironmentHttpInternalServerError({
147 message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.",
148 }),
316 (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }),
149317 ),
150318);
151319
......@@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array {
159327
160328function validateCloudMintPublicKey(
161329 publicKey: string,
162): Effect.Effect<void, EnvironmentHttpBadRequestError> {
330): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
331 const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" });
163332 return Effect.try({
164333 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
165 catch: () =>
166 new EnvironmentHttpBadRequestError({
167 message: "Cloud mint public key must be a valid Ed25519 public key.",
168 }),
334 catch: () => invalid,
169335 }).pipe(
170336 Effect.flatMap((key) =>
171 key.asymmetricKeyType === "ed25519"
172 ? Effect.void
173 : Effect.fail(
174 new EnvironmentHttpBadRequestError({
175 message: "Cloud mint public key must be a valid Ed25519 public key.",
176 }),
177 ),
337 key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid),
178338 ),
179339 );
180340}
181341
182function validateRelayConfigPayload(
342function invalidRelayConfigField(
183343 payload: RelayEnvironmentConfigRequest,
184): Effect.Effect<void, EnvironmentHttpBadRequestError> {
185 if (!isSecureRelayUrl(payload.relayUrl)) {
186 return Effect.fail(
187 new EnvironmentHttpBadRequestError({
188 message: "Relay URL must be a secure absolute HTTPS URL.",
189 }),
190 );
191 }
344): CloudLinkRelayConfigInvalidError["field"] | null {
345 if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl";
192346 if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) {
193 return Effect.fail(
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 );
347 return "relayIssuer";
205348 }
206 if (payload.cloudUserId.trim().length === 0) {
207 return Effect.fail(
208 new EnvironmentHttpBadRequestError({
209 message: "Cloud user id is required.",
210 }),
211 );
212 }
213 return Effect.void;
349 if (payload.environmentCredential.trim().length === 0) return "environmentCredential";
350 if (payload.cloudUserId.trim().length === 0) return "cloudUserId";
351 return null;
352}
353
354function validateRelayConfigPayload(
355 payload: RelayEnvironmentConfigRequest,
356): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
357 const field = invalidRelayConfigField(payload);
358 return field === null
359 ? Effect.void
360 : Effect.fail(new CloudLinkRelayConfigInvalidError({ field }));
214361}
215362
216363function normalizePemForSignedPayload(value: string): string {
......@@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = {
233380 | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin }
234381);
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. */
237397type CloudLinkBackgroundError =
238 | EnvironmentCloudEndpointUnavailableError
239 | EnvironmentHttpBadRequestError
240 | EnvironmentHttpForbiddenError
241 | EnvironmentHttpInternalServerError
242 | EnvironmentHttpUnauthorizedError
398 | CloudLinkEndpointUnavailableError
399 | CloudLinkInternalError
400 | CloudLinkOriginInvalidError
401 | RelayRequestError
243402 | ServerSecretStore.SecretStoreError
244403 | Schema.SchemaError
245404 | PlatformError.PlatformError;
......@@ -264,29 +423,28 @@ export class CloudLink extends Context.Service<
264423 httpRequest: HttpServerRequest.HttpServerRequest,
265424 ) => Effect.Effect<
266425 RelayEnvironmentLinkProof,
267 EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError
426 CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError
268427 >;
269428 /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */
270429 readonly applyRelayConfig: (
271430 payload: RelayEnvironmentConfigRequest,
272431 ) => Effect.Effect<
273432 EnvironmentCloudRelayConfigResult,
274 | EnvironmentCloudEndpointUnavailableError
275 | EnvironmentHttpBadRequestError
276 | EnvironmentHttpConflictError
277 | EnvironmentHttpForbiddenError
278 | EnvironmentHttpInternalServerError
279 | EnvironmentHttpUnauthorizedError
433 | CloudLinkAccountMismatchError
434 | CloudLinkEndpointUnavailableError
435 | CloudLinkInternalError
436 | CloudLinkOriginInvalidError
437 | CloudLinkRelayConfigInvalidError
438 | CloudLinkTunnelSupersededError
439 | EnvironmentAuth.ServerAuthInternalError
440 | RelayRequestError
280441 >;
281442 readonly linkState: () => Effect.Effect<
282443 EnvironmentCloudLinkStateResult,
283 EnvironmentHttpInternalServerError
444 CloudLinkInternalError
284445 >;
285446 /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */
286 readonly unlink: () => Effect.Effect<
287 EnvironmentCloudRelayConfigResult,
288 EnvironmentHttpInternalServerError
289 >;
447 readonly unlink: () => Effect.Effect<EnvironmentCloudRelayConfigResult, CloudLinkInternalError>;
290448 /**
291449 * Saves this environment's T3 Connect preferences, all or nothing, and
292450 * returns the link state. The activity setting is saved first. Holding
......@@ -298,25 +456,27 @@ export class CloudLink extends Context.Service<
298456 input: EnvironmentCloudPreferencesRequest,
299457 ) => Effect.Effect<
300458 EnvironmentCloudLinkStateResult,
301 EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError
459 CloudLinkNotLinkedError | CloudLinkInternalError
302460 >;
303461 /** Answers the relay's signed health check once per proof. */
304462 readonly answerHealthRequest: (
305463 request: RelayCloudEnvironmentHealthRequest,
306464 ) => Effect.Effect<
307465 RelayEnvironmentHealthResponse,
308 | EnvironmentHttpConflictError
309 | EnvironmentHttpInternalServerError
310 | EnvironmentHttpUnauthorizedError
466 | CloudLinkProofRejectedError
467 | CloudLinkProofReplayedError
468 | CloudLinkInternalError
469 | EnvironmentAuth.ServerAuthInternalError
311470 >;
312471 /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */
313472 readonly mintCredential: (
314473 request: RelayCloudMintCredentialRequest,
315474 ) => Effect.Effect<
316475 RelayEnvironmentMintResponse,
317 | EnvironmentHttpConflictError
318 | EnvironmentHttpInternalServerError
319 | EnvironmentHttpUnauthorizedError
476 | CloudLinkProofRejectedError
477 | CloudLinkProofReplayedError
478 | CloudLinkInternalError
479 | EnvironmentAuth.ServerAuthInternalError
320480 >;
321481 /** Links this environment with the stored CLI authorization and records the CLI's wish. */
322482 readonly reconcileDesiredLink: (
......@@ -324,9 +484,11 @@ export class CloudLink extends Context.Service<
324484 ) => Effect.Effect<
325485 CliDesiredLinkMode,
326486 | CloudLinkBackgroundError
327 | EnvironmentHttpConflictError
487 | CloudLinkAccountMismatchError
488 | CloudLinkAuthorizationMissingError
489 | CloudLinkRelayConfigInvalidError
328490 | EnvironmentAuth.ServerAuthInternalError
329 | CliTokenManager.CloudCliAuthorizationDeniedError
491 | CliTokenManager.CloudCliTokenManagerError
330492 >;
331493 /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */
332494 readonly reconcileDesiredLinkIfStillDesired: (
......@@ -334,9 +496,11 @@ export class CloudLink extends Context.Service<
334496 ) => Effect.Effect<
335497 CliDesiredLinkMode | null,
336498 | CloudLinkBackgroundError
337 | EnvironmentHttpConflictError
499 | CloudLinkAccountMismatchError
500 | CloudLinkAuthorizationMissingError
501 | CloudLinkRelayConfigInvalidError
338502 | EnvironmentAuth.ServerAuthInternalError
339 | CliTokenManager.CloudCliAuthorizationDeniedError
503 | CliTokenManager.CloudCliTokenManagerError
340504 >;
341505 /** Tells the relay which local origin the stored tunnel serves, then starts it. */
342506 readonly registerManagedTunnelRecovery: (
......@@ -359,8 +523,8 @@ export class CloudLink extends Context.Service<
359523 options?: { readonly requireConfirmedOrigin?: boolean },
360524 ) => Effect.Effect<
361525 boolean,
362 | EnvironmentCloudEndpointUnavailableError
363 | EnvironmentHttpBadRequestError
526 | CloudLinkEndpointUnavailableError
527 | CloudLinkOriginInvalidError
364528 | ServerSecretStore.SecretStoreError
365529 >;
366530 /** Deletes a CLI-managed tunnel when this server goes offline for good. */
......@@ -399,7 +563,7 @@ const make = Effect.gen(function* () {
399563
400564 const validateLinkedCloudUser = (
401565 cloudUserId: string,
402 ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> =>
566 ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | CloudLinkAccountMismatchError> =>
403567 secrets.get(CLOUD_LINKED_USER_ID).pipe(
404568 Effect.mapError(
405569 (cause) =>
......@@ -414,12 +578,7 @@ const make = Effect.gen(function* () {
414578 const existingCloudUserId = bytesToString(existing.value);
415579 return existingCloudUserId === cloudUserId
416580 ? Effect.void
417 : Effect.fail(
418 new EnvironmentHttpConflictError({
419 message:
420 "This environment is already linked to a different cloud account. Unlink it before switching accounts.",
421 }),
422 );
581 : Effect.fail(new CloudLinkAccountMismatchError({}));
423582 }),
424583 );
425584
......@@ -450,9 +609,7 @@ const make = Effect.gen(function* () {
450609 requestUrl,
451610 })
452611 ) {
453 return yield* new EnvironmentHttpBadRequestError({
454 message: "Invalid managed endpoint origin.",
455 });
612 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
456613 }
457614 const now = yield* DateTime.now;
458615 const expiresAt = DateTime.add(now, { minutes: 5 });
......@@ -491,24 +648,13 @@ const make = Effect.gen(function* () {
491648 function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) {
492649 const requestUrl = requestAbsoluteUrl(httpRequest);
493650 if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) {
494 return yield* new EnvironmentHttpBadRequestError({
495 message: "Invalid managed endpoint origin.",
496 });
651 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
497652 }
498653 const proof = yield* makeCloudLinkProof(request, requestUrl);
499654 return proof satisfies RelayEnvironmentLinkProof;
500655 },
501 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
502 failEnvironmentCloudInternalError(error.message)(error),
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 ),
656 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")),
657 Effect.catchTag("PlatformError", internalError("generate-link-proof")),
512658 );
513659
514660 const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(
......@@ -528,8 +674,8 @@ const make = Effect.gen(function* () {
528674 }
529675 const status = yield* endpointRuntime.applyConfig(input.config);
530676 if (status.status !== "running") {
531 return yield* new EnvironmentCloudEndpointUnavailableError({
532 message: "Managed endpoint runtime could not be started.",
677 return yield* new CloudLinkEndpointUnavailableError({
678 reason: "runtime-not-started",
533679 endpointRuntimeStatus: status,
534680 });
535681 }
......@@ -557,7 +703,7 @@ const make = Effect.gen(function* () {
557703 ? activate.pipe(
558704 Effect.retry({
559705 while: (error) =>
560 error._tag === "EnvironmentCloudEndpointUnavailableError" &&
706 error._tag === "CloudLinkEndpointUnavailableError" &&
561707 ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus(
562708 error.endpointRuntimeStatus,
563709 ),
......@@ -578,10 +724,7 @@ const make = Effect.gen(function* () {
578724 const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true;
579725 const parsedOrigin = yield* Effect.try({
580726 try: () => parseManagedEndpointLocalOrigin(localOrigin),
581 catch: () =>
582 new EnvironmentHttpBadRequestError({
583 message: "Could not resolve local environment origin.",
584 }),
727 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
585728 });
586729 return yield* endpointRuntime.withLinkStateLock(
587730 Effect.gen(function* () {
......@@ -610,8 +753,8 @@ const make = Effect.gen(function* () {
610753 }
611754 const status = yield* endpointRuntime.applyConfig(config);
612755 if (status.status !== "running") {
613 return yield* new EnvironmentCloudEndpointUnavailableError({
614 message: "Managed endpoint runtime could not be started.",
756 return yield* new CloudLinkEndpointUnavailableError({
757 reason: "runtime-not-started",
615758 endpointRuntimeStatus: status,
616759 });
617760 }
......@@ -637,8 +780,8 @@ const make = Effect.gen(function* () {
637780 payload.endpointRuntime !== null &&
638781 payload.endpointRuntime.providerKind !== "cloudflare_tunnel"
639782 ) {
640 return yield* new EnvironmentCloudEndpointUnavailableError({
641 message: "Managed endpoint runtime could not be started.",
783 return yield* new CloudLinkEndpointUnavailableError({
784 reason: "runtime-not-started",
642785 endpointRuntimeStatus: {
643786 status: "unsupported",
644787 providerKind: payload.endpointRuntime.providerKind,
......@@ -674,8 +817,8 @@ const make = Effect.gen(function* () {
674817 }
675818 const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime);
676819 if (endpointRuntimeStatus.status !== "running") {
677 return yield* new EnvironmentCloudEndpointUnavailableError({
678 message: "Managed endpoint runtime could not be started.",
820 return yield* new CloudLinkEndpointUnavailableError({
821 reason: "runtime-not-started",
679822 endpointRuntimeStatus,
680823 });
681824 }
......@@ -711,20 +854,12 @@ const make = Effect.gen(function* () {
711854 function* (localOrigin: string) {
712855 const parsedOrigin = yield* Effect.try({
713856 try: () => parseManagedEndpointLocalOrigin(localOrigin),
714 catch: () =>
715 new EnvironmentHttpBadRequestError({
716 message: "Could not resolve local environment origin.",
717 }),
857 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
718858 });
719859 const token = yield* cliTokenManager.getExisting.pipe(
720860 Effect.flatMap(
721861 Option.match({
722 onNone: () =>
723 Effect.fail(
724 new EnvironmentHttpUnauthorizedError({
725 message: "Run `t3 connect link` to authorize this environment.",
726 }),
727 ),
862 onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})),
728863 onSome: Effect.succeed,
729864 }),
730865 ),
......@@ -786,17 +921,7 @@ const make = Effect.gen(function* () {
786921 // actually used, not from a value read before the relay round trip.
787922 return mode;
788923 },
789 Effect.catchIf(
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 }),
924 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")),
800925 );
801926
802927 const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* (
......@@ -853,10 +978,7 @@ const make = Effect.gen(function* () {
853978 payload,
854979 }).pipe(
855980 Effect.mapError(
856 () =>
857 new EnvironmentHttpInternalServerError({
858 message: "Could not sign the managed tunnel recovery request.",
859 }),
981 (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }),
860982 ),
861983 );
862984 });
......@@ -886,10 +1008,7 @@ const make = Effect.gen(function* () {
8861008
8871009 const parsedOrigin = yield* Effect.try({
8881010 try: () => parseManagedEndpointLocalOrigin(localOrigin),
889 catch: () =>
890 new EnvironmentHttpBadRequestError({
891 message: "Could not resolve local environment origin.",
892 }),
1011 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
8931012 });
8941013 if (config.tunnelId === undefined) {
8951014 return { status: "recovery_required" as const, config };
......@@ -967,10 +1086,7 @@ const make = Effect.gen(function* () {
9671086
9681087 const parsedOrigin = yield* Effect.try({
9691088 try: () => parseManagedEndpointLocalOrigin(localOrigin),
970 catch: () =>
971 new EnvironmentHttpBadRequestError({
972 message: "Could not resolve local environment origin.",
973 }),
1089 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
9741090 });
9751091
9761092 const environmentId = yield* environment.getEnvironmentId;
......@@ -996,17 +1112,12 @@ const make = Effect.gen(function* () {
9961112 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,
9971113 });
9981114 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {
999 return yield* new EnvironmentHttpInternalServerError({
1000 message: "T3 Connect returned an unsupported managed tunnel configuration.",
1001 });
1115 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });
10021116 }
10031117
10041118 const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe(
10051119 Effect.mapError(
1006 () =>
1007 new EnvironmentHttpInternalServerError({
1008 message: "Could not persist the recovered managed tunnel configuration.",
1009 }),
1120 (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }),
10101121 ),
10111122 );
10121123 const stored = yield* endpointRuntime.withLinkStateLock(
......@@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () {
10411152 if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") {
10421153 const address = httpServer.address;
10431154 if (typeof address === "string" || !("port" in address)) {
1044 return yield* new EnvironmentHttpInternalServerError({
1045 message: "Could not resolve the local server origin.",
1046 });
1155 return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" });
10471156 }
10481157 const registration = yield* registerManagedTunnelRecovery(
10491158 `http://127.0.0.1:${address.port}`,
......@@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () {
10511160 Effect.retry({
10521161 times: 2,
10531162 while: (error) =>
1054 shouldRetryCloudLink(error) &&
1055 error._tag !== "EnvironmentCloudEndpointUnavailableError",
1163 shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError",
10561164 }),
10571165 );
10581166 if (registration.status === "superseded") {
1059 return yield* new EnvironmentHttpConflictError({
1060 message: "The managed tunnel configuration changed during registration.",
1061 });
1167 return yield* new CloudLinkTunnelSupersededError({});
10621168 }
10631169 if (registration.status === "recovery_required") {
10641170 yield* endpointRuntime.requestRecovery(registration.config);
10651171 }
10661172 if (registration.status !== "ready") {
1067 return yield* new EnvironmentCloudEndpointUnavailableError({
1068 message: "Managed endpoint origin could not be confirmed.",
1173 return yield* new CloudLinkEndpointUnavailableError({
1174 reason: "origin-unconfirmed",
10691175 endpointRuntimeStatus: { status: "disabled" },
10701176 });
10711177 }
......@@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () {
10761182 }
10771183 return result;
10781184 },
1079 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
1080 failEnvironmentCloudInternalError(error.message)(error),
1081 ),
1082 Effect.catchIf(
1083 ServerSecretStore.isSecretStoreError,
1084 failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
1085 ),
1185 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")),
10861186 Effect.catchTags({
1087 SchemaError: failEnvironmentCloudInternalError(
1088 "Could not persist environment relay configuration.",
1089 ),
1090 PlatformError: failEnvironmentCloudInternalError(
1091 "Could not register the managed endpoint origin.",
1092 ),
1187 SchemaError: internalError("persist-relay-config"),
1188 PlatformError: internalError("register-endpoint-origin"),
10931189 }),
10941190 );
10951191
......@@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () {
12161312 function* () {
12171313 return yield* readCloudLinkState();
12181314 },
1219 Effect.catchIf(
1220 ServerSecretStore.isSecretStoreError,
1221 failEnvironmentCloudInternalError("Could not read environment relay configuration."),
1222 ),
1315 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")),
12231316 );
12241317
12251318 const unlink = Effect.fn("environment.cloud.unlink")(
......@@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () {
12461339 }),
12471340 );
12481341 },
1249 Effect.catchIf(
1250 ServerSecretStore.isSecretStoreError,
1251 failEnvironmentCloudInternalError("Could not remove environment relay configuration."),
1252 ),
1342 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")),
12531343 );
12541344
12551345 const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(
12561346 function* (holdWebhooksWhileOffline: boolean) {
12571347 const connection = yield* readRelayConnection.pipe(withSecrets);
12581348 if (connection === null) {
1259 return yield* new EnvironmentHttpBadRequestError({
1260 message: "Link this environment to T3 Connect first.",
1261 });
1349 return yield* new CloudLinkNotLinkedError({});
12621350 }
12631351 const environmentId = yield* environment.getEnvironmentId;
12641352 const client = yield* makeRelayEnvironmentClient(connection);
......@@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () {
12671355 params: { environmentId },
12681356 payload: { holdWebhooksWhileOffline },
12691357 })
1270 .pipe(
1271 Effect.timeout("10 seconds"),
1272 Effect.catch(
1273 failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."),
1274 ),
1275 );
1358 .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings")));
12761359 },
12771360 );
12781361
12791362 const savePreference = (name: string, value: boolean) =>
12801363 secrets
12811364 .set(name, stringToBytes(String(value)))
1282 .pipe(
1283 Effect.catch(
1284 failEnvironmentCloudInternalError("Could not persist environment cloud preferences."),
1285 ),
1286 );
1365 .pipe(Effect.catch(internalError("persist-preferences")));
12871366
12881367 // One update at a time, so two requests can't each leave one setting behind.
12891368 const preferencesLock = yield* Semaphore.make(1);
......@@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () {
12951374 // current values are read up front; a failed read stops here, before
12961375 // anything changes, because a guessed value would be the rollback target.
12971376 const readCurrent = (name: string) =>
1298 secrets
1299 .get(name)
1300 .pipe(
1301 Effect.catch(
1302 failEnvironmentCloudInternalError("Could not read environment cloud preferences."),
1303 ),
1304 );
1377 secrets.get(name).pipe(Effect.catch(internalError("read-preferences")));
13051378 const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET);
13061379 const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET);
13071380 yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);
......@@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () {
13421415 yield* savePreferences(input);
13431416 return yield* readCloudLinkState();
13441417 },
1345 Effect.catchIf(
1346 ServerSecretStore.isSecretStoreError,
1347 failEnvironmentCloudInternalError("Could not read environment cloud preferences."),
1348 ),
1418 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")),
13491419 );
13501420
13511421 const answerHealthRequest = Effect.fn("environment.cloud.health")(
......@@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () {
13971467 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
13981468 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" })
13991469 ) {
1400 return yield* new EnvironmentHttpUnauthorizedError({
1401 message: "Invalid cloud health request.",
1402 });
1470 return yield* new CloudLinkProofRejectedError({ request: "health" });
14031471 }
14041472 const proof = proofOption.value;
14051473
......@@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () {
14111479 value: stringToBytes(DateTime.formatIso(now)),
14121480 });
14131481 if (!consumedReplayGuards) {
1414 return yield* new EnvironmentHttpConflictError({
1415 message: "Cloud health request was already consumed.",
1416 });
1482 return yield* new CloudLinkProofReplayedError({ request: "health" });
14171483 }
14181484
14191485 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
......@@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () {
14521518 proof: responseProof,
14531519 } satisfies RelayEnvironmentHealthResponse;
14541520 },
1455 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
1456 failEnvironmentCloudInternalError(error.message)(error),
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 ),
1521 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")),
1522 Effect.catchTag("PlatformError", internalError("answer-health")),
14661523 );
14671524
14681525 const mintCredential = Effect.fn("environment.cloud.mintCredential")(
......@@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () {
15151572 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
15161573 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" })
15171574 ) {
1518 return yield* new EnvironmentHttpUnauthorizedError({
1519 message: "Invalid cloud mint request.",
1520 });
1575 return yield* new CloudLinkProofRejectedError({ request: "mint" });
15211576 }
15221577 const proof = proofOption.value;
15231578
......@@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () {
15291584 value: stringToBytes(DateTime.formatIso(now)),
15301585 });
15311586 if (!consumedReplayGuards) {
1532 return yield* new EnvironmentHttpConflictError({
1533 message: "Cloud mint request was already consumed.",
1534 });
1587 return yield* new CloudLinkProofReplayedError({ request: "mint" });
15351588 }
15361589
15371590 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
......@@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () {
15721625 proof: responseProof,
15731626 } satisfies RelayEnvironmentMintResponse;
15741627 },
1575 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
1576 failEnvironmentCloudInternalError(error.message)(error),
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 ),
1628 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")),
1629 Effect.catchTag("PlatformError", internalError("issue-credential")),
15861630 );
15871631
15881632 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 @@
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";
212import * as Effect from "effect/Effect";
313import * as HttpEffect from "effect/http/HttpEffect";
414import { HttpServerRequest, HttpServerResponse } from "effect/http";
515import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
616
717import { requireEnvironmentScope } from "../auth/http.ts";
18import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
819import * as CloudLink from "./CloudLink.ts";
20import type { RelayRequestError } from "./relayResponse.ts";
921import { traceRelayRequest } from "./traceRelayRequest.ts";
1022
1123const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
......@@ -18,6 +30,81 @@ const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler
1830 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
1931);
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
21108export const layer = HttpApiBuilder.group(
22109 EnvironmentHttpApi,
23110 "connect",
......@@ -28,42 +115,46 @@ export const layer = HttpApiBuilder.group(
28115 Effect.gen(function* () {
29116 yield* requireEnvironmentScope(AuthRelayWriteScope);
30117 const request = yield* HttpServerRequest.HttpServerRequest;
31 const proof = yield* cloudLink.linkProof(payload, request);
118 const proof = yield* toHttpError(cloudLink.linkProof(payload, request));
32119 yield* appendCloudCredentialResponseHeaders;
33120 return proof;
34121 }),
35122 )
36123 .handle("relayConfig", ({ payload }) =>
37124 requireEnvironmentScope(AuthRelayWriteScope).pipe(
38 Effect.andThen(cloudLink.applyRelayConfig(payload)),
125 Effect.andThen(toHttpErrorOrUnavailable(cloudLink.applyRelayConfig(payload))),
39126 ),
40127 )
41128 .handle("linkState", () =>
42 requireEnvironmentScope(AuthRelayReadScope).pipe(Effect.andThen(cloudLink.linkState())),
129 requireEnvironmentScope(AuthRelayReadScope).pipe(
130 Effect.andThen(toHttpError(cloudLink.linkState())),
131 ),
43132 )
44133 .handle("unlink", () =>
45 requireEnvironmentScope(AuthRelayWriteScope).pipe(Effect.andThen(cloudLink.unlink())),
134 requireEnvironmentScope(AuthRelayWriteScope).pipe(
135 Effect.andThen(toHttpError(cloudLink.unlink())),
136 ),
46137 )
47138 .handle("preferences", ({ payload }) =>
48139 requireEnvironmentScope(AuthRelayWriteScope).pipe(
49 Effect.andThen(cloudLink.updatePreferences(payload)),
140 Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))),
50141 ),
51142 )
52143 .handle("health", ({ payload }) =>
53 cloudLink
54 .answerHealthRequest(payload)
55 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),
144 toHttpError(cloudLink.answerHealthRequest(payload)).pipe(
145 Effect.tap(() => appendCloudCredentialResponseHeaders),
146 ),
56147 )
57148 .handle("mintCredential", ({ payload }) =>
58 cloudLink
59 .mintCredential(payload)
60 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),
149 toHttpError(cloudLink.mintCredential(payload)).pipe(
150 Effect.tap(() => appendCloudCredentialResponseHeaders),
151 ),
61152 )
62153 .handle("t3MintCredential", ({ payload }) =>
63154 traceRelayRequest(
64 cloudLink
65 .mintCredential(payload)
66 .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)),
155 toHttpError(cloudLink.mintCredential(payload)).pipe(
156 Effect.tap(() => appendCloudCredentialResponseHeaders),
157 ),
67158 ),
68159 );
69160 }),
apps/server/src/cloud/relayResponse.test.ts+13-9
......@@ -3,7 +3,11 @@ import * as Effect from "effect/Effect";
33import { HttpClientRequest, HttpClientResponse } from "effect/http";
44import * 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
812const response = (
913 status: number,
......@@ -28,12 +32,12 @@ it("reports a transport failure category without exposing request or cause detai
2832 }),
2933 );
3034
31 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
35 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
3236 expect(error.message).toContain("TransportError");
3337 expect(error.message).toContain("network connection");
3438 expect(error.message).not.toContain("relay.example.test");
3539 expect(error.message).not.toContain("private");
36 expect(shouldRetryCloudLink(error)).toBe(true);
40 expect(shouldRetryRelayRequest(error)).toBe(true);
3741});
3842
3943it.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", (
4751 }),
4852 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
4953
50 expect(error._tag).toBe("EnvironmentHttpForbiddenError");
54 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
5155 expect(error.message).toContain("at most 3 tunnels");
5256 expect(error.message).toContain("Unlink an unused environment");
5357 expect(error.message).toContain("Trace ID: trace-limit");
......@@ -65,7 +69,7 @@ it.effect("makes revoked authorization actionable and non-retryable", () =>
6569 }),
6670 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
6771
68 expect(error._tag).toBe("EnvironmentHttpUnauthorizedError");
72 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" });
6973 expect(error.message).toContain("invalid_bearer");
7074 expect(error.message).toContain("t3 connect login");
7175 expect(error.message).toContain("Trace ID: trace-auth");
......@@ -81,7 +85,7 @@ it.effect("reports an unrecognized access denial without printing its response b
8185 }),
8286 ).pipe(Effect.flip);
8387
84 expect(error._tag).toBe("EnvironmentHttpForbiddenError");
88 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
8589 expect(error.message).toContain("HTTP 403");
8690 expect(error.message).toContain("proxy or firewall");
8791 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");
......@@ -94,7 +98,7 @@ it.effect.each([408, 429, 500, 502, 503, 504])(
9498 (status) =>
9599 Effect.gen(function* () {
96100 const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip);
97 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
101 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
98102 expect(error.message).toContain(`HTTP ${status}`);
99103 }),
100104);
......@@ -112,7 +116,7 @@ it.effect.each([
112116 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));
113117 }).pipe(
114118 Effect.mapError(relayRequestError),
115 Effect.retry({ while: shouldRetryCloudLink, times: 1 }),
119 Effect.retry({ while: shouldRetryRelayRequest, times: 1 }),
116120 Effect.result,
117121 );
118122 expect(requests).toBe(attempts);
......@@ -131,7 +135,7 @@ it.effect("keeps the relay failure reason and trace when tunnel cleanup fails",
131135 }),
132136 ).pipe(Effect.flip);
133137
134 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
138 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
135139 expect(error.message).toContain("upstream_unavailable");
136140 expect(error.message).toContain("Trace ID: trace-cleanup");
137141 }),
apps/server/src/cloud/relayResponse.ts+35-34
......@@ -1,10 +1,3 @@
1import {
2 EnvironmentHttpBadRequestError,
3 EnvironmentHttpConflictError,
4 EnvironmentHttpForbiddenError,
5 EnvironmentHttpInternalServerError,
6 EnvironmentHttpUnauthorizedError,
7} from "@t3tools/contracts";
81import { RelayProtectedError } from "@t3tools/contracts/relay";
92import * as Effect from "effect/Effect";
103import * as Option from "effect/Option";
......@@ -12,33 +5,37 @@ import * as Schema from "effect/Schema";
125import * as HttpClientResponse from "effect/http/HttpClientResponse";
136import { isHttpClientError } from "effect/http/HttpClientError";
147
15const isRelayResponseError = Schema.is(
16 Schema.Union([
17 EnvironmentHttpBadRequestError,
18 EnvironmentHttpForbiddenError,
19 EnvironmentHttpInternalServerError,
20 EnvironmentHttpUnauthorizedError,
21 ]),
22);
8/**
9 * A relay request that did not succeed. `unavailable` means it may succeed on
10 * retry; the relay refused the other kinds. The description is safe to show:
11 * it carries the relay's own explanation and trace ID, never request details.
12 */
13export class RelayRequestError extends Schema.TaggedError<RelayRequestError>()(
14 "RelayRequestError",
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) {
25 return isRelayResponseError(cause)
27export function relayRequestError(cause: unknown): RelayRequestError {
28 return isRelayRequestError(cause)
2629 ? cause
27 : new EnvironmentHttpInternalServerError({
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.`,
30 : new RelayRequestError({
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.`,
2933 });
3034}
3135
32const isPermanentCloudLinkError = Schema.is(
33 Schema.Union([
34 EnvironmentHttpBadRequestError,
35 EnvironmentHttpForbiddenError,
36 EnvironmentHttpUnauthorizedError,
37 EnvironmentHttpConflictError,
38 ]),
39);
40
41export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error);
36/** Whether a failure may succeed on retry: anything but a relay refusal. */
37export const shouldRetryRelayRequest = (error: unknown): boolean =>
38 !isRelayRequestError(error) || error.rejection === "unavailable";
4239
4340function recoveryHint(error: RelayProtectedError): string {
4441 switch (error._tag) {
......@@ -64,19 +61,23 @@ export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(func
6461 );
6562 const ray = response.headers["cf-ray"];
6663 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)
6865 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`
6966 : `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 });
72 if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message });
68 if (response.status === 401) {
69 return yield* new RelayRequestError({ rejection: "unauthorized", description });
70 }
71 if (response.status === 403) {
72 return yield* new RelayRequestError({ rejection: "forbidden", description });
73 }
7374 if (
7475 response.status >= 400 &&
7576 response.status < 500 &&
7677 response.status !== 408 &&
7778 response.status !== 429
7879 ) {
79 return yield* new EnvironmentHttpBadRequestError({ message });
80 return yield* new RelayRequestError({ rejection: "rejected", description });
8081 }
81 return yield* new EnvironmentHttpInternalServerError({ message });
82 return yield* new RelayRequestError({ rejection: "unavailable", description });
8283});
apps/server/src/server.ts+5-6
......@@ -126,7 +126,6 @@ import * as CloudHttp from "./cloud/http.ts";
126126import * as CloudLink from "./cloud/CloudLink.ts";
127127import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";
128128import * as RelayTracing from "./cloud/relayTracing.ts";
129import { shouldRetryCloudLink } from "./cloud/relayResponse.ts";
130129import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
131130import {
132131 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,
......@@ -845,8 +844,8 @@ const layerMakeServer = Layer.unwrap(
845844 ),
846845 Effect.retry({
847846 while: (error) =>
848 shouldRetryCloudLink(error) &&
849 error._tag !== "EnvironmentCloudEndpointUnavailableError",
847 CloudLink.shouldRetryCloudLink(error) &&
848 error._tag !== "CloudLinkEndpointUnavailableError",
850849 schedule: Schedule.exponential("1 second").pipe(
851850 Schedule.modifyDelay(({ duration }) =>
852851 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
......@@ -931,8 +930,8 @@ const layerMakeServer = Layer.unwrap(
931930 retryRuntimeFailures: true,
932931 }),
933932 (error) =>
934 shouldRetryCloudLink(error) &&
935 error._tag !== "EnvironmentCloudEndpointUnavailableError",
933 CloudLink.shouldRetryCloudLink(error) &&
934 error._tag !== "CloudLinkEndpointUnavailableError",
936935 startedConfirmed ? Effect.void : startStoredManagedTunnel,
937936 ).pipe(
938937 Effect.tap((result) =>
......@@ -977,7 +976,7 @@ const layerMakeServer = Layer.unwrap(
977976 .reconcileDesiredLinkIfStillDesired(localOrigin)
978977 .pipe(
979978 Effect.retry({
980 while: shouldRetryCloudLink,
979 while: CloudLink.shouldRetryCloudLink,
981980 schedule: Schedule.exponential("1 second").pipe(
982981 Schedule.modifyDelay(({ duration }) =>
983982 Effect.succeed(Duration.min(duration, Duration.seconds(30))),