diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 8fb5e65e4529..329371dc07ab 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -45,7 +45,6 @@ import { managedTunnelStartupAction, retryManagedTunnelRegistration, } from "./managedTunnelStartup.ts"; -import { shouldRetryCloudLink } from "./relayResponse.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); @@ -109,10 +108,7 @@ describe("reconcileDesiredCloudLink", () => { const link = yield* CloudLink.CloudLink; const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774")); - expect(error).toMatchObject({ - _tag: "EnvironmentHttpUnauthorizedError", - message: "Run `t3 connect link` to authorize this environment.", - }); + expect(error._tag).toBe("CloudLinkAuthorizationMissingError"); }).pipe( Effect.provide(CloudLink.layer), Effect.provideService(HttpServer.HttpServer, idleHttpServer), @@ -689,7 +685,7 @@ describe("releaseManagedTunnelOnShutdown", () => { link .registerManagedTunnelRecovery(localOrigin) .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))), - shouldRetryCloudLink, + CloudLink.shouldRetryCloudLink, link .startManagedTunnelIfOriginConfirmed(localOrigin, { requireConfirmedOrigin: false, @@ -1051,10 +1047,10 @@ describe("releaseManagedTunnelOnShutdown", () => { }); it.effect.each([ - { status: 401, errorTag: "EnvironmentHttpUnauthorizedError" }, - { status: 403, errorTag: "EnvironmentHttpForbiddenError" }, - { status: 409, errorTag: "EnvironmentHttpBadRequestError" }, - ])("preserves a permanent $status relay recovery failure", ({ status, errorTag }) => { + { status: 401, rejection: "unauthorized" }, + { status: 403, rejection: "forbidden" }, + { status: 409, rejection: "rejected" }, + ])("preserves a permanent $status relay recovery failure", ({ status, rejection }) => { const { store } = makeMemorySecretStore([ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], [RELAY_URL_SECRET, "https://relay.example.test"], @@ -1068,7 +1064,8 @@ describe("releaseManagedTunnelOnShutdown", () => { const link = yield* CloudLink.CloudLink; const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773")); - expect(error._tag).toBe(errorTag); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection }); + expect(CloudLink.shouldRetryCloudLink(error)).toBe(false); expect(requests).toHaveLength(1); expect(applyConfigCalls).toEqual([]); }).pipe( diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index c2a90f1930f8..fd196a1b33ae 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -137,7 +137,10 @@ it.effect("puts the relay back when the local save fails", () => const error = yield* preferences .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true }) .pipe(Effect.flip); - assert.equal(error._tag, "EnvironmentHttpInternalServerError"); + assert.deepInclude(error, { + _tag: "CloudLinkInternalError", + operation: "persist-preferences", + }); assert.deepEqual(relayCalls, [true, false]); assert.equal( 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", const error = yield* preferences .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true }) .pipe(Effect.flip); - assert.equal(error._tag, "EnvironmentHttpInternalServerError"); + assert.deepInclude(error, { + _tag: "CloudLinkInternalError", + operation: "persist-preferences", + }); assert.deepEqual(relayCalls, []); assert.equal( 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", () const error = yield* preferences .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true }) .pipe(Effect.flip); - assert.equal(error._tag, "EnvironmentHttpInternalServerError"); + assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" }); assert.deepEqual(relayCalls, []); assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false"); }), @@ -231,7 +237,7 @@ it.effect("changes nothing when the current hold setting can't be read", () => const error = yield* preferences .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false }) .pipe(Effect.flip); - assert.equal(error._tag, "EnvironmentHttpInternalServerError"); + assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" }); assert.deepEqual(relayCalls, []); assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false"); }), diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index fa8fd0a85075..35d6ac724987 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -7,15 +7,9 @@ import * as NodeCrypto from "node:crypto"; import { AuthStandardClientScopes, - EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, type EnvironmentCloudPreferencesRequest, type EnvironmentCloudRelayConfigResult, - EnvironmentHttpBadRequestError, - EnvironmentHttpConflictError, - type EnvironmentHttpForbiddenError, - EnvironmentHttpInternalServerError, - EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; import { RelayCloudEnvironmentHealthProofPayload, @@ -106,7 +100,12 @@ import { import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; +import { + filterRelayResponse, + relayRequestError, + type RelayRequestError, + shouldRetryRelayRequest, +} from "./relayResponse.ts"; import { CLOUD_HEALTH_JTI_PREFIX, CLOUD_HEALTH_NONCE_PREFIX, @@ -126,15 +125,187 @@ import { import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); -const failEnvironmentCloudInternalError = - (message: string) => - (cause: unknown): Effect.Effect => - Effect.logError(message, { cause }).pipe( - Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), - ); +const RELAY_CONFIG_FIELD_MESSAGES = { + relayUrl: "Relay URL must be a secure absolute HTTPS URL.", + relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.", + environmentCredential: "Relay environment credential is required.", + cloudUserId: "Cloud user id is required.", + cloudMintPublicKey: "Cloud mint public key must be a valid Ed25519 public key.", +} as const; + +/** The relay sent a link configuration this environment cannot install. */ +export class CloudLinkRelayConfigInvalidError extends Schema.TaggedError()( + "CloudLinkRelayConfigInvalidError", + { + field: Schema.Literals([ + "relayUrl", + "relayIssuer", + "environmentCredential", + "cloudUserId", + "cloudMintPublicKey", + ]), + }, +) { + override get message(): string { + return RELAY_CONFIG_FIELD_MESSAGES[this.field]; + } +} + +/** + * A link was asked to point at an origin it may not serve: a link proof request + * that did not reach this server directly on loopback (`endpoint`), or a local + * origin that is not a bare http(s) origin (`local`). + */ +export class CloudLinkOriginInvalidError extends Schema.TaggedError()( + "CloudLinkOriginInvalidError", + { origin: Schema.Literals(["endpoint", "local"]) }, +) { + override get message(): string { + return this.origin === "endpoint" + ? "Invalid managed endpoint origin." + : "Could not resolve local environment origin."; + } +} + +export class CloudLinkNotLinkedError extends Schema.TaggedError()( + "CloudLinkNotLinkedError", + {}, +) { + override get message(): string { + return "Link this environment to T3 Connect first."; + } +} + +export class CloudLinkAccountMismatchError extends Schema.TaggedError()( + "CloudLinkAccountMismatchError", + {}, +) { + override get message(): string { + return "This environment is already linked to a different cloud account. Unlink it before switching accounts."; + } +} + +/** Linking from the CLI needs the authorization `t3 connect link` stores. */ +export class CloudLinkAuthorizationMissingError extends Schema.TaggedError()( + "CloudLinkAuthorizationMissingError", + {}, +) { + override get message(): string { + return "Run `t3 connect link` to authorize this environment."; + } +} + +const SignedRelayRequest = Schema.Literals(["health", "mint"]); -const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error); +/** A signed relay request whose proof does not verify for this environment. */ +export class CloudLinkProofRejectedError extends Schema.TaggedError()( + "CloudLinkProofRejectedError", + { request: SignedRelayRequest }, +) { + override get message(): string { + return `Invalid cloud ${this.request} request.`; + } +} + +/** A signed relay request whose proof was already used once. */ +export class CloudLinkProofReplayedError extends Schema.TaggedError()( + "CloudLinkProofReplayedError", + { request: SignedRelayRequest }, +) { + override get message(): string { + return `Cloud ${this.request} request was already consumed.`; + } +} + +/** The stored tunnel changed while its registration was in flight. */ +export class CloudLinkTunnelSupersededError extends Schema.TaggedError()( + "CloudLinkTunnelSupersededError", + {}, +) { + override get message(): string { + return "The managed tunnel configuration changed during registration."; + } +} + +/** + * The managed tunnel is not serving: its connector did not start + * (`runtime-not-started`, with the runtime's status), or the relay never + * confirmed this server's origin (`origin-unconfirmed`). + */ +export class CloudLinkEndpointUnavailableError extends Schema.TaggedError()( + "CloudLinkEndpointUnavailableError", + { + reason: Schema.Literals(["runtime-not-started", "origin-unconfirmed"]), + endpointRuntimeStatus: Schema.Unknown, + }, +) { + override get message(): string { + return this.reason === "runtime-not-started" + ? "Managed endpoint runtime could not be started." + : "Managed endpoint origin could not be confirmed."; + } +} + +const INTERNAL_OPERATION_MESSAGES = { + "relay-url-unconfigured": + "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + "generate-link-proof": "Could not generate environment link proof.", + "persist-relay-config": "Could not persist environment relay configuration.", + "register-endpoint-origin": "Could not register the managed endpoint origin.", + "resolve-server-origin": "Could not resolve the local server origin.", + "persist-desired-link": "Could not persist desired T3 Connect link state.", + "sign-recovery-proof": "Could not sign the managed tunnel recovery request.", + "unsupported-recovered-tunnel": + "T3 Connect returned an unsupported managed tunnel configuration.", + "persist-recovered-tunnel": "Could not persist the recovered managed tunnel configuration.", + "read-relay-config": "Could not read environment relay configuration.", + "remove-relay-config": "Could not remove environment relay configuration.", + "update-webhook-settings": "Could not update T3 Connect webhook settings.", + "read-preferences": "Could not read environment cloud preferences.", + "persist-preferences": "Could not persist environment cloud preferences.", + "answer-health": "Could not answer cloud health request.", + "issue-credential": "Could not issue cloud connection credential.", +} as const; + +/** A link step failed on this machine: storage, signing, or an unexpected relay answer. */ +export class CloudLinkInternalError extends Schema.TaggedError()( + "CloudLinkInternalError", + { + operation: Schema.Literals( + Object.keys(INTERNAL_OPERATION_MESSAGES) as [ + keyof typeof INTERNAL_OPERATION_MESSAGES, + ...Array, + ], + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return INTERNAL_OPERATION_MESSAGES[this.operation]; + } +} + +const internalError = + (operation: CloudLinkInternalError["operation"]) => + (cause: unknown): Effect.Effect => + Effect.fail(new CloudLinkInternalError({ operation, cause })); + +const isPermanentLinkError = Schema.is( + Schema.Union([ + CloudLinkRelayConfigInvalidError, + CloudLinkOriginInvalidError, + CloudLinkNotLinkedError, + CloudLinkAccountMismatchError, + CloudLinkAuthorizationMissingError, + CloudLinkProofRejectedError, + CloudLinkProofReplayedError, + CloudLinkTunnelSupersededError, + ]), +); + +/** Whether a failed link step may succeed on retry: not when the relay or this server refused it. */ +export const shouldRetryCloudLink = (error: unknown): boolean => + shouldRetryRelayRequest(error) && !isPermanentLinkError(error); /** A failed rollback leaves a setting changed; it is logged, not hidden. */ const rollbackFailed = (cause: unknown) => @@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) => const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( - () => - new EnvironmentHttpInternalServerError({ - message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", - }), + (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }), ), ); @@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array { function validateCloudMintPublicKey( publicKey: string, -): Effect.Effect { +): Effect.Effect { + const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" }); return Effect.try({ try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), - catch: () => - new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", - }), + catch: () => invalid, }).pipe( Effect.flatMap((key) => - key.asymmetricKeyType === "ed25519" - ? Effect.void - : Effect.fail( - new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", - }), - ), + key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid), ), ); } -function validateRelayConfigPayload( +function invalidRelayConfigField( payload: RelayEnvironmentConfigRequest, -): Effect.Effect { - if (!isSecureRelayUrl(payload.relayUrl)) { - return Effect.fail( - new EnvironmentHttpBadRequestError({ - message: "Relay URL must be a secure absolute HTTPS URL.", - }), - ); - } +): CloudLinkRelayConfigInvalidError["field"] | null { + if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl"; if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { - return Effect.fail( - new EnvironmentHttpBadRequestError({ - message: "Relay issuer must be a secure absolute HTTPS URL.", - }), - ); - } - if (payload.environmentCredential.trim().length === 0) { - return Effect.fail( - new EnvironmentHttpBadRequestError({ - message: "Relay environment credential is required.", - }), - ); + return "relayIssuer"; } - if (payload.cloudUserId.trim().length === 0) { - return Effect.fail( - new EnvironmentHttpBadRequestError({ - message: "Cloud user id is required.", - }), - ); - } - return Effect.void; + if (payload.environmentCredential.trim().length === 0) return "environmentCredential"; + if (payload.cloudUserId.trim().length === 0) return "cloudUserId"; + return null; +} + +function validateRelayConfigPayload( + payload: RelayEnvironmentConfigRequest, +): Effect.Effect { + const field = invalidRelayConfigField(payload); + return field === null + ? Effect.void + : Effect.fail(new CloudLinkRelayConfigInvalidError({ field })); } function normalizePemForSignedPayload(value: string): string { @@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = { | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } ); -/** Failures of the link work that runs outside a request: startup, recovery and shutdown. */ +/** Every failure CloudLink constructs itself. */ +export type CloudLinkError = + | CloudLinkAccountMismatchError + | CloudLinkAuthorizationMissingError + | CloudLinkEndpointUnavailableError + | CloudLinkInternalError + | CloudLinkNotLinkedError + | CloudLinkOriginInvalidError + | CloudLinkProofRejectedError + | CloudLinkProofReplayedError + | CloudLinkRelayConfigInvalidError + | CloudLinkTunnelSupersededError; + +/** Failures of the link work that talks to the relay: linking, recovery and shutdown. */ type CloudLinkBackgroundError = - | EnvironmentCloudEndpointUnavailableError - | EnvironmentHttpBadRequestError - | EnvironmentHttpForbiddenError - | EnvironmentHttpInternalServerError - | EnvironmentHttpUnauthorizedError + | CloudLinkEndpointUnavailableError + | CloudLinkInternalError + | CloudLinkOriginInvalidError + | RelayRequestError | ServerSecretStore.SecretStoreError | Schema.SchemaError | PlatformError.PlatformError; @@ -264,29 +423,28 @@ export class CloudLink extends Context.Service< httpRequest: HttpServerRequest.HttpServerRequest, ) => Effect.Effect< RelayEnvironmentLinkProof, - EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError + CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError >; /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */ readonly applyRelayConfig: ( payload: RelayEnvironmentConfigRequest, ) => Effect.Effect< EnvironmentCloudRelayConfigResult, - | EnvironmentCloudEndpointUnavailableError - | EnvironmentHttpBadRequestError - | EnvironmentHttpConflictError - | EnvironmentHttpForbiddenError - | EnvironmentHttpInternalServerError - | EnvironmentHttpUnauthorizedError + | CloudLinkAccountMismatchError + | CloudLinkEndpointUnavailableError + | CloudLinkInternalError + | CloudLinkOriginInvalidError + | CloudLinkRelayConfigInvalidError + | CloudLinkTunnelSupersededError + | EnvironmentAuth.ServerAuthInternalError + | RelayRequestError >; readonly linkState: () => Effect.Effect< EnvironmentCloudLinkStateResult, - EnvironmentHttpInternalServerError + CloudLinkInternalError >; /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */ - readonly unlink: () => Effect.Effect< - EnvironmentCloudRelayConfigResult, - EnvironmentHttpInternalServerError - >; + readonly unlink: () => Effect.Effect; /** * Saves this environment's T3 Connect preferences, all or nothing, and * returns the link state. The activity setting is saved first. Holding @@ -298,25 +456,27 @@ export class CloudLink extends Context.Service< input: EnvironmentCloudPreferencesRequest, ) => Effect.Effect< EnvironmentCloudLinkStateResult, - EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError + CloudLinkNotLinkedError | CloudLinkInternalError >; /** Answers the relay's signed health check once per proof. */ readonly answerHealthRequest: ( request: RelayCloudEnvironmentHealthRequest, ) => Effect.Effect< RelayEnvironmentHealthResponse, - | EnvironmentHttpConflictError - | EnvironmentHttpInternalServerError - | EnvironmentHttpUnauthorizedError + | CloudLinkProofRejectedError + | CloudLinkProofReplayedError + | CloudLinkInternalError + | EnvironmentAuth.ServerAuthInternalError >; /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */ readonly mintCredential: ( request: RelayCloudMintCredentialRequest, ) => Effect.Effect< RelayEnvironmentMintResponse, - | EnvironmentHttpConflictError - | EnvironmentHttpInternalServerError - | EnvironmentHttpUnauthorizedError + | CloudLinkProofRejectedError + | CloudLinkProofReplayedError + | CloudLinkInternalError + | EnvironmentAuth.ServerAuthInternalError >; /** Links this environment with the stored CLI authorization and records the CLI's wish. */ readonly reconcileDesiredLink: ( @@ -324,9 +484,11 @@ export class CloudLink extends Context.Service< ) => Effect.Effect< CliDesiredLinkMode, | CloudLinkBackgroundError - | EnvironmentHttpConflictError + | CloudLinkAccountMismatchError + | CloudLinkAuthorizationMissingError + | CloudLinkRelayConfigInvalidError | EnvironmentAuth.ServerAuthInternalError - | CliTokenManager.CloudCliAuthorizationDeniedError + | CliTokenManager.CloudCliTokenManagerError >; /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */ readonly reconcileDesiredLinkIfStillDesired: ( @@ -334,9 +496,11 @@ export class CloudLink extends Context.Service< ) => Effect.Effect< CliDesiredLinkMode | null, | CloudLinkBackgroundError - | EnvironmentHttpConflictError + | CloudLinkAccountMismatchError + | CloudLinkAuthorizationMissingError + | CloudLinkRelayConfigInvalidError | EnvironmentAuth.ServerAuthInternalError - | CliTokenManager.CloudCliAuthorizationDeniedError + | CliTokenManager.CloudCliTokenManagerError >; /** Tells the relay which local origin the stored tunnel serves, then starts it. */ readonly registerManagedTunnelRecovery: ( @@ -359,8 +523,8 @@ export class CloudLink extends Context.Service< options?: { readonly requireConfirmedOrigin?: boolean }, ) => Effect.Effect< boolean, - | EnvironmentCloudEndpointUnavailableError - | EnvironmentHttpBadRequestError + | CloudLinkEndpointUnavailableError + | CloudLinkOriginInvalidError | ServerSecretStore.SecretStoreError >; /** Deletes a CLI-managed tunnel when this server goes offline for good. */ @@ -399,7 +563,7 @@ const make = Effect.gen(function* () { const validateLinkedCloudUser = ( cloudUserId: string, - ): Effect.Effect => + ): Effect.Effect => secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => @@ -414,12 +578,7 @@ const make = Effect.gen(function* () { const existingCloudUserId = bytesToString(existing.value); return existingCloudUserId === cloudUserId ? Effect.void - : Effect.fail( - new EnvironmentHttpConflictError({ - message: - "This environment is already linked to a different cloud account. Unlink it before switching accounts.", - }), - ); + : Effect.fail(new CloudLinkAccountMismatchError({})); }), ); @@ -450,9 +609,7 @@ const make = Effect.gen(function* () { requestUrl, }) ) { - return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", - }); + return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); } const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); @@ -491,24 +648,13 @@ const make = Effect.gen(function* () { function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { const requestUrl = requestAbsoluteUrl(httpRequest); if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { - return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", - }); + return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); } const proof = yield* makeCloudLinkProof(request, requestUrl); return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")), + Effect.catchTag("PlatformError", internalError("generate-link-proof")), ); const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( @@ -528,8 +674,8 @@ const make = Effect.gen(function* () { } const status = yield* endpointRuntime.applyConfig(input.config); if (status.status !== "running") { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", endpointRuntimeStatus: status, }); } @@ -557,7 +703,7 @@ const make = Effect.gen(function* () { ? activate.pipe( Effect.retry({ while: (error) => - error._tag === "EnvironmentCloudEndpointUnavailableError" && + error._tag === "CloudLinkEndpointUnavailableError" && ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( error.endpointRuntimeStatus, ), @@ -578,10 +724,7 @@ const make = Effect.gen(function* () { const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; const parsedOrigin = yield* Effect.try({ try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => - new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), }); return yield* endpointRuntime.withLinkStateLock( Effect.gen(function* () { @@ -610,8 +753,8 @@ const make = Effect.gen(function* () { } const status = yield* endpointRuntime.applyConfig(config); if (status.status !== "running") { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", endpointRuntimeStatus: status, }); } @@ -637,8 +780,8 @@ const make = Effect.gen(function* () { payload.endpointRuntime !== null && payload.endpointRuntime.providerKind !== "cloudflare_tunnel" ) { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", endpointRuntimeStatus: { status: "unsupported", providerKind: payload.endpointRuntime.providerKind, @@ -674,8 +817,8 @@ const make = Effect.gen(function* () { } const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime); if (endpointRuntimeStatus.status !== "running") { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", endpointRuntimeStatus, }); } @@ -711,20 +854,12 @@ const make = Effect.gen(function* () { function* (localOrigin: string) { const parsedOrigin = yield* Effect.try({ try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => - new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), }); const token = yield* cliTokenManager.getExisting.pipe( Effect.flatMap( Option.match({ - onNone: () => - Effect.fail( - new EnvironmentHttpUnauthorizedError({ - message: "Run `t3 connect link` to authorize this environment.", - }), - ), + onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})), onSome: Effect.succeed, }), ), @@ -786,17 +921,7 @@ const make = Effect.gen(function* () { // actually used, not from a value read before the relay round trip. return mode; }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), - ), - Effect.catchTags({ - CloudCliCredentialRemovalError: failCloudCliTokenManagerError, - CloudCliCredentialRefreshError: failCloudCliTokenManagerError, - CloudCliCredentialReadError: failCloudCliTokenManagerError, - CloudCliAuthorizationError: failCloudCliTokenManagerError, - CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, - }), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")), ); const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* ( @@ -853,10 +978,7 @@ const make = Effect.gen(function* () { payload, }).pipe( Effect.mapError( - () => - new EnvironmentHttpInternalServerError({ - message: "Could not sign the managed tunnel recovery request.", - }), + (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }), ), ); }); @@ -886,10 +1008,7 @@ const make = Effect.gen(function* () { const parsedOrigin = yield* Effect.try({ try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => - new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), }); if (config.tunnelId === undefined) { return { status: "recovery_required" as const, config }; @@ -967,10 +1086,7 @@ const make = Effect.gen(function* () { const parsedOrigin = yield* Effect.try({ try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => - new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), }); const environmentId = yield* environment.getEnvironmentId; @@ -996,17 +1112,12 @@ const make = Effect.gen(function* () { timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, }); if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { - return yield* new EnvironmentHttpInternalServerError({ - message: "T3 Connect returned an unsupported managed tunnel configuration.", - }); + return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); } const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( Effect.mapError( - () => - new EnvironmentHttpInternalServerError({ - message: "Could not persist the recovered managed tunnel configuration.", - }), + (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }), ), ); const stored = yield* endpointRuntime.withLinkStateLock( @@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () { if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { const address = httpServer.address; if (typeof address === "string" || !("port" in address)) { - return yield* new EnvironmentHttpInternalServerError({ - message: "Could not resolve the local server origin.", - }); + return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" }); } const registration = yield* registerManagedTunnelRecovery( `http://127.0.0.1:${address.port}`, @@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () { Effect.retry({ times: 2, while: (error) => - shouldRetryCloudLink(error) && - error._tag !== "EnvironmentCloudEndpointUnavailableError", + shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError", }), ); if (registration.status === "superseded") { - return yield* new EnvironmentHttpConflictError({ - message: "The managed tunnel configuration changed during registration.", - }); + return yield* new CloudLinkTunnelSupersededError({}); } if (registration.status === "recovery_required") { yield* endpointRuntime.requestRecovery(registration.config); } if (registration.status !== "ready") { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint origin could not be confirmed.", + return yield* new CloudLinkEndpointUnavailableError({ + reason: "origin-unconfirmed", endpointRuntimeStatus: { status: "disabled" }, }); } @@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () { } return result; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")), Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - PlatformError: failEnvironmentCloudInternalError( - "Could not register the managed endpoint origin.", - ), + SchemaError: internalError("persist-relay-config"), + PlatformError: internalError("register-endpoint-origin"), }), ); @@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () { function* () { return yield* readCloudLinkState(); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not read environment relay configuration."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")), ); const unlink = Effect.fn("environment.cloud.unlink")( @@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () { }), ); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not remove environment relay configuration."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")), ); const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")( function* (holdWebhooksWhileOffline: boolean) { const connection = yield* readRelayConnection.pipe(withSecrets); if (connection === null) { - return yield* new EnvironmentHttpBadRequestError({ - message: "Link this environment to T3 Connect first.", - }); + return yield* new CloudLinkNotLinkedError({}); } const environmentId = yield* environment.getEnvironmentId; const client = yield* makeRelayEnvironmentClient(connection); @@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () { params: { environmentId }, payload: { holdWebhooksWhileOffline }, }) - .pipe( - Effect.timeout("10 seconds"), - Effect.catch( - failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."), - ), - ); + .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings"))); }, ); const savePreference = (name: string, value: boolean) => secrets .set(name, stringToBytes(String(value))) - .pipe( - Effect.catch( - failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), - ), - ); + .pipe(Effect.catch(internalError("persist-preferences"))); // One update at a time, so two requests can't each leave one setting behind. const preferencesLock = yield* Semaphore.make(1); @@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () { // current values are read up front; a failed read stops here, before // anything changes, because a guessed value would be the rollback target. const readCurrent = (name: string) => - secrets - .get(name) - .pipe( - Effect.catch( - failEnvironmentCloudInternalError("Could not read environment cloud preferences."), - ), - ); + secrets.get(name).pipe(Effect.catch(internalError("read-preferences"))); const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET); const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET); yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity); @@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () { yield* savePreferences(input); return yield* readCloudLinkState(); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not read environment cloud preferences."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")), ); const answerHealthRequest = Effect.fn("environment.cloud.health")( @@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () { !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) ) { - return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud health request.", - }); + return yield* new CloudLinkProofRejectedError({ request: "health" }); } const proof = proofOption.value; @@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () { value: stringToBytes(DateTime.formatIso(now)), }); if (!consumedReplayGuards) { - return yield* new EnvironmentHttpConflictError({ - message: "Cloud health request was already consumed.", - }); + return yield* new CloudLinkProofReplayedError({ request: "health" }); } const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); @@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () { proof: responseProof, } satisfies RelayEnvironmentHealthResponse; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")), + Effect.catchTag("PlatformError", internalError("answer-health")), ); const mintCredential = Effect.fn("environment.cloud.mintCredential")( @@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () { !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) ) { - return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud mint request.", - }); + return yield* new CloudLinkProofRejectedError({ request: "mint" }); } const proof = proofOption.value; @@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () { value: stringToBytes(DateTime.formatIso(now)), }); if (!consumedReplayGuards) { - return yield* new EnvironmentHttpConflictError({ - message: "Cloud mint request was already consumed.", - }); + return yield* new CloudLinkProofReplayedError({ request: "mint" }); } const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); @@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () { proof: responseProof, } satisfies RelayEnvironmentMintResponse; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")), + Effect.catchTag("PlatformError", internalError("issue-credential")), ); return CloudLink.of({ diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts new file mode 100644 index 000000000000..a49a88277eb0 --- /dev/null +++ b/apps/server/src/cloud/http.test.ts @@ -0,0 +1,98 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentHttpApi } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Etag from "effect/http/Etag"; +import * as HttpPlatform from "effect/http/HttpPlatform"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as CloudLink from "./CloudLink.ts"; +import * as ConnectHttp from "./http.ts"; + +class ConnectTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.connect) {} + +// The signed relay routes need no session, so a CloudLink that fails each +// request shows how the transport answers that failure. +type HealthFailure = Effect.Error< + ReturnType +>; + +const answerHealthWith = async (failure: HealthFailure) => { + const layerRoutes = HttpApiBuilder.layer(ConnectTestApi).pipe( + Layer.provide(ConnectHttp.layer), + Layer.provide( + Layer.mock(CloudLink.CloudLink)({ + answerHealthRequest: () => Effect.fail(failure), + }), + ), + // The session-gated routes are declared too; this request never reaches them. + Layer.provide(AuthHttp.layerAuthenticatedAuth), + Layer.provide(Layer.mock(EnvironmentAuth.EnvironmentAuth)({})), + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), + ); + const { handler, dispose } = HttpRouter.toWebHandler(layerRoutes, { + disableLogger: true, + }); + try { + const response = await handler( + new Request("http://127.0.0.1/api/t3-connect/health", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ proof: "proof" }), + }), + ); + return { status: response.status, body: (await response.json()) as unknown }; + } finally { + await dispose(); + } +}; + +describe("connect routes", () => { + it.each([ + { + failure: new CloudLink.CloudLinkProofRejectedError({ request: "health" }), + status: 401, + body: { _tag: "EnvironmentHttpUnauthorizedError", message: "Invalid cloud health request." }, + }, + { + failure: new CloudLink.CloudLinkProofReplayedError({ request: "health" }), + status: 409, + body: { + _tag: "EnvironmentHttpConflictError", + message: "Cloud health request was already consumed.", + }, + }, + { + failure: new CloudLink.CloudLinkInternalError({ + operation: "answer-health", + cause: new Error("disk full"), + }), + status: 500, + body: { + _tag: "EnvironmentHttpInternalServerError", + message: "Could not answer cloud health request.", + }, + }, + { + failure: new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}), + status: 500, + body: { + _tag: "EnvironmentHttpInternalServerError", + message: "Cloud mint public key is not installed for this environment.", + }, + }, + ])("answers $failure._tag with HTTP $status", async ({ failure, status, body }) => { + const response = await answerHealthWith(failure); + expect(response).toEqual({ status, body }); + }); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 7c57ae5fb764..51f0dda5f2e1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,11 +1,23 @@ -import { AuthRelayReadScope, AuthRelayWriteScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import { + AuthRelayReadScope, + AuthRelayWriteScope, + EnvironmentCloudEndpointUnavailableError, + EnvironmentHttpApi, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as HttpEffect from "effect/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { requireEnvironmentScope } from "../auth/http.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as CloudLink from "./CloudLink.ts"; +import type { RelayRequestError } from "./relayResponse.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { @@ -18,6 +30,81 @@ const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), ); +const internalServerError = (error: { readonly message: string }, cause: unknown) => + Effect.logError(error.message, { cause }).pipe( + Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message: error.message }))), + ); + +const relayFailure = (error: RelayRequestError) => { + const message = error.message; + switch (error.rejection) { + case "unauthorized": + return Effect.fail(new EnvironmentHttpUnauthorizedError({ message })); + case "forbidden": + return Effect.fail(new EnvironmentHttpForbiddenError({ message })); + case "rejected": + return Effect.fail(new EnvironmentHttpBadRequestError({ message })); + case "unavailable": + return Effect.fail(new EnvironmentHttpInternalServerError({ message })); + } +}; + +const badRequest = (error: { readonly message: string }) => + Effect.fail(new EnvironmentHttpBadRequestError({ message: error.message })); +const unauthorized = (error: { readonly message: string }) => + Effect.fail(new EnvironmentHttpUnauthorizedError({ message: error.message })); +const conflict = (error: { readonly message: string }) => + Effect.fail(new EnvironmentHttpConflictError({ message: error.message })); + +/** How a connect route answers each CloudLink failure. Messages carry through unchanged. */ +const connectErrorCases = { + CloudLinkRelayConfigInvalidError: badRequest, + CloudLinkOriginInvalidError: badRequest, + CloudLinkNotLinkedError: badRequest, + CloudLinkAccountMismatchError: conflict, + CloudLinkAuthorizationMissingError: unauthorized, + CloudLinkProofRejectedError: unauthorized, + CloudLinkProofReplayedError: conflict, + CloudLinkTunnelSupersededError: conflict, + CloudLinkInternalError: (error: CloudLink.CloudLinkInternalError) => + internalServerError(error, error.cause), + RelayRequestError: relayFailure, +} as const; + +type ConnectFailure = + | Exclude + | EnvironmentAuth.ServerAuthInternalError + | RelayRequestError; + +/** Internal failures are logged with their cause before the route answers 500. */ +const toHttpError = (effect: Effect.Effect) => + effect.pipe( + Effect.catchTags(connectErrorCases), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + internalServerError(error, error), + ), + ); + +/** Relay configuration is the one route that answers 503 when the tunnel cannot serve. */ +const toHttpErrorOrUnavailable = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchTags({ + ...connectErrorCases, + CloudLinkEndpointUnavailableError: (error) => + Effect.fail( + new EnvironmentCloudEndpointUnavailableError({ + message: error.message, + endpointRuntimeStatus: error.endpointRuntimeStatus, + }), + ), + }), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + internalServerError(error, error), + ), + ); + export const layer = HttpApiBuilder.group( EnvironmentHttpApi, "connect", @@ -28,42 +115,46 @@ export const layer = HttpApiBuilder.group( Effect.gen(function* () { yield* requireEnvironmentScope(AuthRelayWriteScope); const request = yield* HttpServerRequest.HttpServerRequest; - const proof = yield* cloudLink.linkProof(payload, request); + const proof = yield* toHttpError(cloudLink.linkProof(payload, request)); yield* appendCloudCredentialResponseHeaders; return proof; }), ) .handle("relayConfig", ({ payload }) => requireEnvironmentScope(AuthRelayWriteScope).pipe( - Effect.andThen(cloudLink.applyRelayConfig(payload)), + Effect.andThen(toHttpErrorOrUnavailable(cloudLink.applyRelayConfig(payload))), ), ) .handle("linkState", () => - requireEnvironmentScope(AuthRelayReadScope).pipe(Effect.andThen(cloudLink.linkState())), + requireEnvironmentScope(AuthRelayReadScope).pipe( + Effect.andThen(toHttpError(cloudLink.linkState())), + ), ) .handle("unlink", () => - requireEnvironmentScope(AuthRelayWriteScope).pipe(Effect.andThen(cloudLink.unlink())), + requireEnvironmentScope(AuthRelayWriteScope).pipe( + Effect.andThen(toHttpError(cloudLink.unlink())), + ), ) .handle("preferences", ({ payload }) => requireEnvironmentScope(AuthRelayWriteScope).pipe( - Effect.andThen(cloudLink.updatePreferences(payload)), + Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))), ), ) .handle("health", ({ payload }) => - cloudLink - .answerHealthRequest(payload) - .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)), + toHttpError(cloudLink.answerHealthRequest(payload)).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), + ), ) .handle("mintCredential", ({ payload }) => - cloudLink - .mintCredential(payload) - .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)), + toHttpError(cloudLink.mintCredential(payload)).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), + ), ) .handle("t3MintCredential", ({ payload }) => traceRelayRequest( - cloudLink - .mintCredential(payload) - .pipe(Effect.tap(() => appendCloudCredentialResponseHeaders)), + toHttpError(cloudLink.mintCredential(payload)).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), + ), ), ); }), diff --git a/apps/server/src/cloud/relayResponse.test.ts b/apps/server/src/cloud/relayResponse.test.ts index 7bbe2ec7005a..8221899ca3a6 100644 --- a/apps/server/src/cloud/relayResponse.test.ts +++ b/apps/server/src/cloud/relayResponse.test.ts @@ -3,7 +3,11 @@ import * as Effect from "effect/Effect"; import { HttpClientRequest, HttpClientResponse } from "effect/http"; import * as HttpClientError from "effect/http/HttpClientError"; -import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; +import { + filterRelayResponse, + relayRequestError, + shouldRetryRelayRequest, +} from "./relayResponse.ts"; const response = ( status: number, @@ -28,12 +32,12 @@ it("reports a transport failure category without exposing request or cause detai }), ); - expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" }); expect(error.message).toContain("TransportError"); expect(error.message).toContain("network connection"); expect(error.message).not.toContain("relay.example.test"); expect(error.message).not.toContain("private"); - expect(shouldRetryCloudLink(error)).toBe(true); + expect(shouldRetryRelayRequest(error)).toBe(true); }); 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", ( }), ).pipe(Effect.mapError(relayRequestError), Effect.flip); - expect(error._tag).toBe("EnvironmentHttpForbiddenError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" }); expect(error.message).toContain("at most 3 tunnels"); expect(error.message).toContain("Unlink an unused environment"); expect(error.message).toContain("Trace ID: trace-limit"); @@ -65,7 +69,7 @@ it.effect("makes revoked authorization actionable and non-retryable", () => }), ).pipe(Effect.mapError(relayRequestError), Effect.flip); - expect(error._tag).toBe("EnvironmentHttpUnauthorizedError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" }); expect(error.message).toContain("invalid_bearer"); expect(error.message).toContain("t3 connect login"); expect(error.message).toContain("Trace ID: trace-auth"); @@ -81,7 +85,7 @@ it.effect("reports an unrecognized access denial without printing its response b }), ).pipe(Effect.flip); - expect(error._tag).toBe("EnvironmentHttpForbiddenError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" }); expect(error.message).toContain("HTTP 403"); expect(error.message).toContain("proxy or firewall"); expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD"); @@ -94,7 +98,7 @@ it.effect.each([408, 429, 500, 502, 503, 504])( (status) => Effect.gen(function* () { const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip); - expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" }); expect(error.message).toContain(`HTTP ${status}`); }), ); @@ -112,7 +116,7 @@ it.effect.each([ return filterRelayResponse(response(requests === 1 ? status : 200, "{}")); }).pipe( Effect.mapError(relayRequestError), - Effect.retry({ while: shouldRetryCloudLink, times: 1 }), + Effect.retry({ while: shouldRetryRelayRequest, times: 1 }), Effect.result, ); expect(requests).toBe(attempts); @@ -131,7 +135,7 @@ it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", }), ).pipe(Effect.flip); - expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" }); expect(error.message).toContain("upstream_unavailable"); expect(error.message).toContain("Trace ID: trace-cleanup"); }), diff --git a/apps/server/src/cloud/relayResponse.ts b/apps/server/src/cloud/relayResponse.ts index eeb33f84ab6e..db8a6b88702f 100644 --- a/apps/server/src/cloud/relayResponse.ts +++ b/apps/server/src/cloud/relayResponse.ts @@ -1,10 +1,3 @@ -import { - EnvironmentHttpBadRequestError, - EnvironmentHttpConflictError, - EnvironmentHttpForbiddenError, - EnvironmentHttpInternalServerError, - EnvironmentHttpUnauthorizedError, -} from "@t3tools/contracts"; import { RelayProtectedError } from "@t3tools/contracts/relay"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -12,33 +5,37 @@ import * as Schema from "effect/Schema"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { isHttpClientError } from "effect/http/HttpClientError"; -const isRelayResponseError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpForbiddenError, - EnvironmentHttpInternalServerError, - EnvironmentHttpUnauthorizedError, - ]), -); +/** + * A relay request that did not succeed. `unavailable` means it may succeed on + * retry; the relay refused the other kinds. The description is safe to show: + * it carries the relay's own explanation and trace ID, never request details. + */ +export class RelayRequestError extends Schema.TaggedError()( + "RelayRequestError", + { + rejection: Schema.Literals(["unauthorized", "forbidden", "rejected", "unavailable"]), + description: Schema.String, + }, +) { + override get message(): string { + return this.description; + } +} + +const isRelayRequestError = Schema.is(RelayRequestError); -export function relayRequestError(cause: unknown) { - return isRelayResponseError(cause) +export function relayRequestError(cause: unknown): RelayRequestError { + return isRelayRequestError(cause) ? cause - : new EnvironmentHttpInternalServerError({ - 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.`, + : new RelayRequestError({ + rejection: "unavailable", + 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.`, }); } -const isPermanentCloudLinkError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpForbiddenError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpConflictError, - ]), -); - -export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error); +/** Whether a failure may succeed on retry: anything but a relay refusal. */ +export const shouldRetryRelayRequest = (error: unknown): boolean => + !isRelayRequestError(error) || error.rejection === "unavailable"; function recoveryHint(error: RelayProtectedError): string { switch (error._tag) { @@ -64,19 +61,23 @@ export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(func ); const ray = response.headers["cf-ray"]; const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : ""; - const message = Option.isSome(decoded) + const description = Option.isSome(decoded) ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.` : `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}`; - if (response.status === 401) return yield* new EnvironmentHttpUnauthorizedError({ message }); - if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message }); + if (response.status === 401) { + return yield* new RelayRequestError({ rejection: "unauthorized", description }); + } + if (response.status === 403) { + return yield* new RelayRequestError({ rejection: "forbidden", description }); + } if ( response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 ) { - return yield* new EnvironmentHttpBadRequestError({ message }); + return yield* new RelayRequestError({ rejection: "rejected", description }); } - return yield* new EnvironmentHttpInternalServerError({ message }); + return yield* new RelayRequestError({ rejection: "unavailable", description }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9b5cad833700..ca2eaf2dc9d7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -127,7 +127,6 @@ import * as CloudHttp from "./cloud/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; import * as RelayTracing from "./cloud/relayTracing.ts"; -import { shouldRetryCloudLink } from "./cloud/relayResponse.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import { MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, @@ -846,8 +845,8 @@ const layerMakeServer = Layer.unwrap( ), Effect.retry({ while: (error) => - shouldRetryCloudLink(error) && - error._tag !== "EnvironmentCloudEndpointUnavailableError", + CloudLink.shouldRetryCloudLink(error) && + error._tag !== "CloudLinkEndpointUnavailableError", schedule: Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -932,8 +931,8 @@ const layerMakeServer = Layer.unwrap( retryRuntimeFailures: true, }), (error) => - shouldRetryCloudLink(error) && - error._tag !== "EnvironmentCloudEndpointUnavailableError", + CloudLink.shouldRetryCloudLink(error) && + error._tag !== "CloudLinkEndpointUnavailableError", startedConfirmed ? Effect.void : startStoredManagedTunnel, ).pipe( Effect.tap((result) => @@ -978,7 +977,7 @@ const layerMakeServer = Layer.unwrap( .reconcileDesiredLinkIfStillDesired(localOrigin) .pipe( Effect.retry({ - while: shouldRetryCloudLink, + while: CloudLink.shouldRetryCloudLink, schedule: Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))),