From e1df7ae41a2f3720e359481619759b0173abb8c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:56:21 -0700 Subject: [PATCH 1/4] Structure environment cloud HTTP errors Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 5 +- apps/server/src/cloud/http.test.ts | 19 ++ apps/server/src/cloud/http.ts | 274 ++++++++---------- packages/contracts/src/environmentHttp.ts | 214 +++++++++++++- 4 files changed, 350 insertions(+), 162 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..2e3476cdbf4 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -607,7 +607,8 @@ describe("mobile cloud link environment client", () => { Response.json( { _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", + reason: "cloud_cli_authorization_required", + message: "Run `t3 connect link` to authorize this environment.", }, { status: 401 }, ), @@ -623,7 +624,7 @@ describe("mobile cloud link environment client", () => { ).pipe(Effect.flip); expect(error._tag).toBe("CloudEnvironmentLinkError"); expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", + "Could not obtain environment link proof: Run `t3 connect link` to authorize this environment.", ); expect(fetchMock).toHaveBeenCalledTimes(2); }), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 7cd828a0041..82cb18f3e8f 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -225,6 +225,21 @@ it("preserves internal causes without encoding them into HTTP error bodies", () }); }); +it("keeps internal causes out of encoded HTTP error bodies", () => { + const cause = new Error("private upstream detail"); + const error = new EnvironmentHttpInternalServerError({ + operation: "generate_link_proof", + cause, + }); + + expect(error.cause).toBe(cause); + expect(Schema.encodeUnknownSync(EnvironmentHttpInternalServerError)(error)).toEqual({ + _tag: "EnvironmentHttpInternalServerError", + operation: "generate_link_proof", + message: "Could not generate environment link proof.", + }); +}); + describe("relay request tracing", () => { it.effect("does not accept an unauthenticated request trace parent", () => Effect.gen(function* () { @@ -298,6 +313,7 @@ describe("reconcileDesiredCloudLink", () => { expect(error).toMatchObject({ _tag: "EnvironmentHttpUnauthorizedError", + reason: "cloud_cli_authorization_required", message: "Run `t3 connect link` to authorize this environment.", }); }), @@ -334,6 +350,9 @@ describe("reconcileDesiredCloudLink", () => { expect(error).toMatchObject({ _tag: "EnvironmentHttpInternalServerError", + operation: "relay_request", + relayOperation: "create-link-challenge", + relayPhase: "send-request", message: "T3 Connect relay create-link-challenge failed during send-request.", cause: { _tag: "CloudRelayRequestError", diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 3a5df351895..abeb5df327a 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -9,7 +9,10 @@ import { EnvironmentHttpApi, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, + type EnvironmentHttpInternalOperation, EnvironmentHttpInternalServerError, + EnvironmentHttpRelayOperation, + EnvironmentHttpRelayPhase, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; import { @@ -91,18 +94,6 @@ const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { pragma: "no-cache", } as const; -const CloudRelayRequestOperation = Schema.Literals([ - "create-link-challenge", - "create-environment-link", -]); - -const CloudRelayRequestPhase = Schema.Literals([ - "encode-request", - "send-request", - "check-response-status", - "decode-response", -]); - export class CloudRelayConfigurationError extends Schema.TaggedErrorClass()( "CloudRelayConfigurationError", { @@ -118,8 +109,8 @@ export class CloudRelayConfigurationError extends Schema.TaggedErrorClass()( "CloudRelayRequestError", { - operation: CloudRelayRequestOperation, - phase: CloudRelayRequestPhase, + operation: EnvironmentHttpRelayOperation, + phase: EnvironmentHttpRelayPhase, method: Schema.Literal("POST"), requestUrlInputLength: Schema.Number, requestUrlProtocol: Schema.optionalKey(Schema.String), @@ -211,12 +202,30 @@ function errorDiagnosticTag(cause: unknown): string { return typeof cause; } +type EnvironmentCloudInternalErrorContext = + | Exclude + | { + readonly operation: "relay_request"; + readonly relayOperation: CloudRelayRequestError["operation"]; + readonly relayPhase: CloudRelayRequestError["phase"]; + readonly responseStatus?: number; + }; + const failEnvironmentCloudInternalError = - (message: string) => - (cause: unknown): Effect.Effect => - Effect.logError(message, { causeTag: errorDiagnosticTag(cause) }).pipe( - Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message, cause }))), + (context: EnvironmentCloudInternalErrorContext) => + (cause: unknown): Effect.Effect => { + const error = + typeof context === "string" + ? new EnvironmentHttpInternalServerError({ operation: context, cause }) + : new EnvironmentHttpInternalServerError({ ...context, cause }); + const logContext = + typeof context === "string" + ? { operation: context, causeTag: errorDiagnosticTag(cause) } + : { ...context, causeTag: errorDiagnosticTag(cause) }; + return Effect.logError(error.message, logContext).pipe( + Effect.flatMap(() => Effect.fail(error)), ); + }; const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( @@ -273,9 +282,10 @@ function validateCloudMintPublicKey( ): Effect.Effect { return Effect.try({ try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), - catch: () => + catch: (cause) => new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", + reason: "invalid_cloud_mint_public_key", + cause, }), }).pipe( Effect.flatMap((key) => @@ -283,7 +293,7 @@ function validateCloudMintPublicKey( ? Effect.void : Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", + reason: "invalid_cloud_mint_public_key", }), ), ), @@ -296,28 +306,28 @@ function validateRelayConfigPayload( if (!isSecureRelayUrl(payload.relayUrl)) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay URL must be a secure absolute HTTPS URL.", + reason: "invalid_relay_url", }), ); } if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay issuer must be a secure absolute HTTPS URL.", + reason: "invalid_relay_issuer", }), ); } if (payload.environmentCredential.trim().length === 0) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay environment credential is required.", + reason: "missing_relay_environment_credential", }), ); } if (payload.cloudUserId.trim().length === 0) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Cloud user id is required.", + reason: "missing_cloud_user_id", }), ); } @@ -344,8 +354,7 @@ function validateLinkedCloudUser(input: { ? Effect.void : Effect.fail( new EnvironmentHttpConflictError({ - message: - "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + reason: "linked_to_different_cloud_account", }), ); }), @@ -477,7 +486,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }) ) { return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", + reason: "invalid_managed_endpoint_origin", }); } const now = yield* DateTime.now; @@ -520,7 +529,7 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( const requestUrl = requestAbsoluteUrl(httpRequest); if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", + reason: "invalid_managed_endpoint_origin", }); } const proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); @@ -529,23 +538,13 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( }, Effect.catchTags({ ServerAuthCloudLinkJwtSigningError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - SecretStoreDecodeError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - SecretStoreEncodeError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), + failEnvironmentCloudInternalError("sign_cloud_link_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStorePersistError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError("generate_link_proof"), + PlatformError: failEnvironmentCloudInternalError("generate_link_proof"), }), ); @@ -566,7 +565,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; if (!ok) { return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", + reason: "runtime_start_failed", endpointRuntimeStatus, }); } @@ -601,19 +600,13 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( }, Effect.catchTags({ ServerAuthLinkedCloudAccountVerificationError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("verify_linked_cloud_account")(error), SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreRemoveError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", + "persist_relay_configuration", ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_relay_configuration"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("persist_relay_configuration"), + SchemaError: failEnvironmentCloudInternalError("persist_relay_configuration"), }), ); @@ -678,14 +671,15 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi function* (dependencies: CloudHttpDependencies, localOrigin: string) { const localUrl = yield* Effect.try({ try: () => new URL(localOrigin), - catch: () => + catch: (cause) => new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", + reason: "invalid_local_environment_origin", + cause, }), }); if (localUrl.origin !== localOrigin) { return yield* new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", + reason: "invalid_local_environment_origin", }); } const localWsOrigin = localOrigin.replace(/^http/u, "ws"); @@ -695,7 +689,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi onNone: () => Effect.fail( new EnvironmentHttpUnauthorizedError({ - message: "Run `t3 connect link` to authorize this environment.", + reason: "cloud_cli_authorization_required", }), ), onSome: Effect.succeed, @@ -755,47 +749,39 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, Effect.catchTags({ ServerAuthLinkedCloudAccountVerificationError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("verify_linked_cloud_account")(error), ServerAuthCloudLinkJwtSigningError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), + failEnvironmentCloudInternalError("sign_cloud_link_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("persist_desired_link_state"), SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SecretStoreRemoveError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SecretStoreDecodeError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SecretStoreEncodeError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - SchemaError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), - PlatformError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", + "persist_desired_link_state", ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SchemaError: failEnvironmentCloudInternalError("persist_desired_link_state"), + PlatformError: failEnvironmentCloudInternalError("persist_desired_link_state"), CloudRelayConfigurationError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - CloudRelayRequestError: (error) => failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_relay_url_configuration")(error), + CloudRelayRequestError: (error) => + failEnvironmentCloudInternalError({ + operation: "relay_request", + relayOperation: error.operation, + relayPhase: error.phase, + ...(error.responseStatus === undefined ? {} : { responseStatus: error.responseStatus }), + })(error), CloudCliCredentialRemovalError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("remove_cloud_cli_credential")(error), CloudCliCredentialRefreshError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - CloudCliCredentialReadError: (error) => failEnvironmentCloudInternalError(error.message)(error), - CloudCliAuthorizationError: (error) => failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("refresh_cloud_cli_credential")(error), + CloudCliCredentialReadError: (error) => + failEnvironmentCloudInternalError("read_cloud_cli_credential")(error), + CloudCliAuthorizationError: (error) => + failEnvironmentCloudInternalError("authorize_cloud_cli")(error), CloudCliAuthorizationTimeoutError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("await_cloud_cli_authorization")(error), }), ); @@ -834,9 +820,7 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( return yield* readCloudLinkState(dependencies); }, Effect.catchTags({ - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not read environment relay configuration.", - ), + SecretStoreReadError: failEnvironmentCloudInternalError("read_relay_configuration"), }), ); @@ -861,14 +845,10 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( }, Effect.catchTags({ SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( - "Could not remove environment relay configuration.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not remove environment relay configuration.", - ), - SecretStoreRemoveError: failEnvironmentCloudInternalError( - "Could not remove environment relay configuration.", + "remove_relay_configuration", ), + SecretStorePersistError: failEnvironmentCloudInternalError("remove_relay_configuration"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("remove_relay_configuration"), }), ); @@ -885,15 +865,11 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( return yield* readCloudLinkState(dependencies); }, Effect.catchTags({ - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not persist environment cloud preferences.", - ), + SecretStoreReadError: failEnvironmentCloudInternalError("persist_cloud_preferences"), SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( - "Could not persist environment cloud preferences.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not persist environment cloud preferences.", + "persist_cloud_preferences", ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_cloud_preferences"), }), ); @@ -945,7 +921,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) ) { return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud health request.", + reason: "invalid_cloud_health_request", }); } const proof = proofOption.value; @@ -959,7 +935,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }); if (!consumedReplayGuards) { return yield* new EnvironmentHttpConflictError({ - message: "Cloud health request was already consumed.", + reason: "cloud_health_request_replayed", }); } @@ -1004,31 +980,23 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }, Effect.catchTags({ ServerAuthLinkedCloudAccountReadError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_linked_cloud_account")(error), ServerAuthLinkedCloudAccountMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("require_linked_cloud_account")(error), ServerAuthCloudMintPublicKeyMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_cloud_mint_public_key")(error), ServerAuthCloudRelayIssuerMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_cloud_relay_issuer")(error), ServerAuthCloudHealthJwtSigningError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not answer cloud health request.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not answer cloud health request.", - ), - SecretStoreDecodeError: failEnvironmentCloudInternalError( - "Could not answer cloud health request.", - ), - SecretStoreEncodeError: failEnvironmentCloudInternalError( - "Could not answer cloud health request.", - ), + failEnvironmentCloudInternalError("sign_cloud_health_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStorePersistError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("answer_cloud_health_request"), SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( - "Could not answer cloud health request.", + "answer_cloud_health_request", ), - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), + PlatformError: failEnvironmentCloudInternalError("answer_cloud_health_request"), }), ); @@ -1081,7 +1049,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) ) { return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud mint request.", + reason: "invalid_cloud_mint_request", }); } const proof = proofOption.value; @@ -1095,7 +1063,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }); if (!consumedReplayGuards) { return yield* new EnvironmentHttpConflictError({ - message: "Cloud mint request was already consumed.", + reason: "cloud_mint_request_replayed", }); } @@ -1142,35 +1110,25 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }, Effect.catchTags({ ServerAuthLinkedCloudAccountReadError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_linked_cloud_account")(error), ServerAuthLinkedCloudAccountMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("require_linked_cloud_account")(error), ServerAuthCloudMintPublicKeyMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_cloud_mint_public_key")(error), ServerAuthCloudRelayIssuerMissingError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("read_cloud_relay_issuer")(error), ServerAuthPairingLinkCreationError: (error) => - failEnvironmentCloudInternalError(error.message)(error), + failEnvironmentCloudInternalError("create_cloud_pairing_link")(error), ServerAuthCloudMintJwtSigningError: (error) => - failEnvironmentCloudInternalError(error.message)(error), - SecretStoreReadError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStorePersistError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreDecodeError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreEncodeError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), + failEnvironmentCloudInternalError("sign_cloud_mint_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStorePersistError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", + "issue_cloud_connection_credential", ), + PlatformError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), }), ); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e2e9284c6a1..80dc6690a2a 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -169,13 +169,146 @@ const EnvironmentAuthenticationErrors = [ EnvironmentInternalError, ] as const; +export const EnvironmentHttpBadRequestReason = Schema.Literals([ + "invalid_cloud_mint_public_key", + "invalid_relay_url", + "invalid_relay_issuer", + "missing_relay_environment_credential", + "missing_cloud_user_id", + "invalid_managed_endpoint_origin", + "invalid_local_environment_origin", +]); +export type EnvironmentHttpBadRequestReason = typeof EnvironmentHttpBadRequestReason.Type; + +const environmentHttpBadRequestMessages = { + invalid_cloud_mint_public_key: "Cloud mint public key must be a valid Ed25519 public key.", + invalid_relay_url: "Relay URL must be a secure absolute HTTPS URL.", + invalid_relay_issuer: "Relay issuer must be a secure absolute HTTPS URL.", + missing_relay_environment_credential: "Relay environment credential is required.", + missing_cloud_user_id: "Cloud user id is required.", + invalid_managed_endpoint_origin: "Invalid managed endpoint origin.", + invalid_local_environment_origin: "Could not resolve local environment origin.", +} satisfies Record; + +export const EnvironmentHttpUnauthorizedReason = Schema.Literals([ + "cloud_cli_authorization_required", + "invalid_cloud_health_request", + "invalid_cloud_mint_request", +]); +export type EnvironmentHttpUnauthorizedReason = typeof EnvironmentHttpUnauthorizedReason.Type; + +const environmentHttpUnauthorizedMessages = { + cloud_cli_authorization_required: "Run `t3 connect link` to authorize this environment.", + invalid_cloud_health_request: "Invalid cloud health request.", + invalid_cloud_mint_request: "Invalid cloud mint request.", +} satisfies Record; + +export const EnvironmentHttpForbiddenReason = Schema.Literal("cloud_operation_forbidden"); +export type EnvironmentHttpForbiddenReason = typeof EnvironmentHttpForbiddenReason.Type; + +export const EnvironmentHttpInternalOperation = Schema.Literals([ + "generate_link_proof", + "verify_linked_cloud_account", + "persist_relay_configuration", + "persist_desired_link_state", + "read_relay_configuration", + "remove_relay_configuration", + "persist_cloud_preferences", + "answer_cloud_health_request", + "issue_cloud_connection_credential", + "read_linked_cloud_account", + "require_linked_cloud_account", + "sign_cloud_link_jwt", + "read_cloud_mint_public_key", + "read_cloud_relay_issuer", + "sign_cloud_health_jwt", + "sign_cloud_mint_jwt", + "create_cloud_pairing_link", + "read_relay_url_configuration", + "relay_request", + "remove_cloud_cli_credential", + "refresh_cloud_cli_credential", + "read_cloud_cli_credential", + "authorize_cloud_cli", + "await_cloud_cli_authorization", +]); +export type EnvironmentHttpInternalOperation = typeof EnvironmentHttpInternalOperation.Type; + +export const EnvironmentHttpRelayOperation = Schema.Literals([ + "create-link-challenge", + "create-environment-link", +]); +export type EnvironmentHttpRelayOperation = typeof EnvironmentHttpRelayOperation.Type; + +export const EnvironmentHttpRelayPhase = Schema.Literals([ + "encode-request", + "send-request", + "check-response-status", + "decode-response", +]); +export type EnvironmentHttpRelayPhase = typeof EnvironmentHttpRelayPhase.Type; + +const environmentHttpInternalMessages = { + generate_link_proof: "Could not generate environment link proof.", + verify_linked_cloud_account: "Could not verify the linked cloud account.", + persist_relay_configuration: "Could not persist environment relay configuration.", + persist_desired_link_state: "Could not persist desired T3 Connect link state.", + read_relay_configuration: "Could not read environment relay configuration.", + remove_relay_configuration: "Could not remove environment relay configuration.", + persist_cloud_preferences: "Could not persist environment cloud preferences.", + answer_cloud_health_request: "Could not answer cloud health request.", + issue_cloud_connection_credential: "Could not issue cloud connection credential.", + read_linked_cloud_account: "Could not read the linked cloud account.", + require_linked_cloud_account: "Cloud linked user is not installed for this environment.", + sign_cloud_link_jwt: "Failed to sign cloud link JWT.", + read_cloud_mint_public_key: "Cloud mint public key is not installed for this environment.", + read_cloud_relay_issuer: "Cloud relay issuer is not installed for this environment.", + sign_cloud_health_jwt: "Failed to sign cloud health JWT.", + sign_cloud_mint_jwt: "Failed to sign cloud mint JWT.", + create_cloud_pairing_link: "Failed to create pairing link.", + read_relay_url_configuration: + "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + remove_cloud_cli_credential: "Could not remove the stored T3 Connect CLI credential.", + refresh_cloud_cli_credential: "Could not refresh the T3 Connect CLI credential.", + read_cloud_cli_credential: "Could not read the stored T3 Connect CLI credential.", + authorize_cloud_cli: "Could not authorize the T3 Connect CLI.", + await_cloud_cli_authorization: "Timed out waiting for T3 Connect authorization.", +} satisfies Record, string>; + +export const EnvironmentHttpConflictReason = Schema.Literals([ + "linked_to_different_cloud_account", + "cloud_health_request_replayed", + "cloud_mint_request_replayed", +]); +export type EnvironmentHttpConflictReason = typeof EnvironmentHttpConflictReason.Type; + +const environmentHttpConflictMessages = { + linked_to_different_cloud_account: + "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + cloud_health_request_replayed: "Cloud health request was already consumed.", + cloud_mint_request_replayed: "Cloud mint request was already consumed.", +} satisfies Record; + export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpBadRequestError", { + reason: EnvironmentHttpBadRequestReason, message: Schema.String, }, { httpApiStatus: 400 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: EnvironmentHttpBadRequestReason; + readonly cause?: unknown; + }) { + super({ + reason: props.reason, + message: environmentHttpBadRequestMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpBadRequestError)(this, { status: 400 }); } @@ -184,10 +317,23 @@ export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpUnauthorizedError", { + reason: EnvironmentHttpUnauthorizedReason, message: Schema.String, }, { httpApiStatus: 401 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: EnvironmentHttpUnauthorizedReason; + readonly cause?: unknown; + }) { + super({ + reason: props.reason, + message: environmentHttpUnauthorizedMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpUnauthorizedError)(this, { status: 401 }); } @@ -196,10 +342,23 @@ export class EnvironmentHttpUnauthorizedError extends Schema.TaggedErrorClass()( "EnvironmentHttpForbiddenError", { + reason: EnvironmentHttpForbiddenReason, message: Schema.String, }, { httpApiStatus: 403 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: EnvironmentHttpForbiddenReason; + readonly cause?: unknown; + }) { + super({ + reason: props.reason, + message: "Cloud operation is forbidden.", + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpForbiddenError)(this, { status: 403 }); } @@ -208,13 +367,41 @@ export class EnvironmentHttpForbiddenError extends Schema.TaggedErrorClass()( "EnvironmentHttpInternalServerError", { + operation: EnvironmentHttpInternalOperation, + relayOperation: Schema.optional(EnvironmentHttpRelayOperation), + relayPhase: Schema.optional(EnvironmentHttpRelayPhase), + responseStatus: Schema.optional(Schema.Number), message: Schema.String, }, { httpApiStatus: 500 }, ) { // @effect-diagnostics-next-line overriddenSchemaConstructor:off - constructor(props: { readonly message: string; readonly cause?: unknown }) { - super(props as any); + constructor( + props: + | { + readonly operation: Exclude; + readonly cause?: unknown; + } + | { + readonly operation: "relay_request"; + readonly relayOperation: EnvironmentHttpRelayOperation; + readonly relayPhase: EnvironmentHttpRelayPhase; + readonly responseStatus?: number; + readonly cause?: unknown; + }, + ) { + const message = + props.operation === "relay_request" + ? `T3 Connect relay ${props.relayOperation} failed during ${props.relayPhase}${ + props.responseStatus === undefined + ? "" + : ` with response status ${props.responseStatus}` + }.` + : environmentHttpInternalMessages[props.operation]; + super({ + ...props, + message, + } as any); } [HttpServerRespondable.symbol]() { @@ -225,10 +412,20 @@ export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass< export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentHttpConflictError", { + reason: EnvironmentHttpConflictReason, message: Schema.String, }, { httpApiStatus: 409 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { readonly reason: EnvironmentHttpConflictReason; readonly cause?: unknown }) { + super({ + reason: props.reason, + message: environmentHttpConflictMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpConflictError)(this, { status: 409 }); } @@ -237,11 +434,24 @@ export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentCloudEndpointUnavailableError", { + reason: Schema.Literal("runtime_start_failed"), message: Schema.String, endpointRuntimeStatus: Schema.Unknown, }, { httpApiStatus: 503 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: "runtime_start_failed"; + readonly endpointRuntimeStatus: unknown; + readonly cause?: unknown; + }) { + super({ + ...props, + message: "Managed endpoint runtime could not be started.", + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentCloudEndpointUnavailableError)(this, { status: 503, From 732d12dba188e4b4566f191cffdeedc76394c3ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:02:17 -0700 Subject: [PATCH 2/4] Preserve environment HTTP rollout compatibility Co-authored-by: codex --- apps/server/src/cloud/http.test.ts | 19 +++++++- .../environments/EnvironmentConnector.test.ts | 1 + packages/contracts/src/environmentHttp.ts | 43 +++++++++++++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 82cb18f3e8f..117ec2d0e4d 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -28,6 +28,13 @@ import { import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; +const encodeEnvironmentHttpInternalServerError = Schema.encodeUnknownSync( + EnvironmentHttpInternalServerError, +); +const decodeEnvironmentHttpInternalServerError = Schema.decodeUnknownSync( + EnvironmentHttpInternalServerError, +); + const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStorePersistError({ operation: "create", @@ -233,13 +240,23 @@ it("keeps internal causes out of encoded HTTP error bodies", () => { }); expect(error.cause).toBe(cause); - expect(Schema.encodeUnknownSync(EnvironmentHttpInternalServerError)(error)).toEqual({ + expect(encodeEnvironmentHttpInternalServerError(error)).toEqual({ _tag: "EnvironmentHttpInternalServerError", operation: "generate_link_proof", message: "Could not generate environment link proof.", }); }); +it("decodes legacy message-only HTTP errors during rolling deployments", () => { + const error = decodeEnvironmentHttpInternalServerError({ + _tag: "EnvironmentHttpInternalServerError", + message: "Legacy environment server failure.", + }); + + expect(error.operation).toBeUndefined(); + expect(error.message).toBe("Legacy environment server failure."); +}); + describe("relay request tracing", () => { it.effect("does not accept an unauthenticated request trace parent", () => Effect.gen(function* () { diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 63f12379870..0fd82a138af 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -517,6 +517,7 @@ describe("EnvironmentConnector", () => { Response.json( { _tag: "EnvironmentHttpInternalServerError", + operation: "answer_cloud_health_request", message: "Environment is unavailable.", }, { status: 500 }, diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 80dc6690a2a..e937abab10a 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -289,10 +289,19 @@ const environmentHttpConflictMessages = { cloud_mint_request_replayed: "Cloud mint request was already consumed.", } satisfies Record; +// These HTTP errors cross independently deployed clients, relays, and environment servers. Keep +// newly added diagnostics optional while decoding so a newer peer can still consume the legacy +// message-only payload. The constructors below continue to require structured context from new +// application code and only preserve `message` when Schema is decoding an older wire payload. +function decodedEnvironmentHttpErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpBadRequestError", { - reason: EnvironmentHttpBadRequestReason, + reason: Schema.optional(EnvironmentHttpBadRequestReason), message: Schema.String, }, { httpApiStatus: 400 }, @@ -304,7 +313,9 @@ export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpUnauthorizedError", { - reason: EnvironmentHttpUnauthorizedReason, + reason: Schema.optional(EnvironmentHttpUnauthorizedReason), message: Schema.String, }, { httpApiStatus: 401 }, @@ -329,7 +340,9 @@ export class EnvironmentHttpUnauthorizedError extends Schema.TaggedErrorClass()( "EnvironmentHttpForbiddenError", { - reason: EnvironmentHttpForbiddenReason, + reason: Schema.optional(EnvironmentHttpForbiddenReason), message: Schema.String, }, { httpApiStatus: 403 }, @@ -354,7 +367,7 @@ export class EnvironmentHttpForbiddenError extends Schema.TaggedErrorClass()( "EnvironmentHttpInternalServerError", { - operation: EnvironmentHttpInternalOperation, + operation: Schema.optional(EnvironmentHttpInternalOperation), relayOperation: Schema.optional(EnvironmentHttpRelayOperation), relayPhase: Schema.optional(EnvironmentHttpRelayPhase), responseStatus: Schema.optional(Schema.Number), @@ -391,13 +404,14 @@ export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass< }, ) { const message = - props.operation === "relay_request" + decodedEnvironmentHttpErrorMessage(props) ?? + (props.operation === "relay_request" ? `T3 Connect relay ${props.relayOperation} failed during ${props.relayPhase}${ props.responseStatus === undefined ? "" : ` with response status ${props.responseStatus}` }.` - : environmentHttpInternalMessages[props.operation]; + : environmentHttpInternalMessages[props.operation]); super({ ...props, message, @@ -412,7 +426,7 @@ export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass< export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentHttpConflictError", { - reason: EnvironmentHttpConflictReason, + reason: Schema.optional(EnvironmentHttpConflictReason), message: Schema.String, }, { httpApiStatus: 409 }, @@ -421,7 +435,8 @@ export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentCloudEndpointUnavailableError", { - reason: Schema.Literal("runtime_start_failed"), + reason: Schema.optional(Schema.Literal("runtime_start_failed")), message: Schema.String, endpointRuntimeStatus: Schema.Unknown, }, @@ -448,7 +463,9 @@ export class EnvironmentCloudEndpointUnavailableError extends Schema.TaggedError }) { super({ ...props, - message: "Managed endpoint runtime could not be started.", + message: + decodedEnvironmentHttpErrorMessage(props) ?? + "Managed endpoint runtime could not be started.", } as any); } From 22020bd458ffa82d3d02089437d4c6ba51f38f56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:06:42 -0700 Subject: [PATCH 3/4] Remove redundant environment HTTP reasons Co-authored-by: codex --- apps/server/src/cloud/http.ts | 1 - packages/contracts/src/environmentHttp.ts | 17 ++--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index abeb5df327a..874ea8e2dc1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -565,7 +565,6 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; if (!ok) { return yield* new EnvironmentCloudEndpointUnavailableError({ - reason: "runtime_start_failed", endpointRuntimeStatus, }); } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e937abab10a..015c0289d93 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -203,9 +203,6 @@ const environmentHttpUnauthorizedMessages = { invalid_cloud_mint_request: "Invalid cloud mint request.", } satisfies Record; -export const EnvironmentHttpForbiddenReason = Schema.Literal("cloud_operation_forbidden"); -export type EnvironmentHttpForbiddenReason = typeof EnvironmentHttpForbiddenReason.Type; - export const EnvironmentHttpInternalOperation = Schema.Literals([ "generate_link_proof", "verify_linked_cloud_account", @@ -355,18 +352,13 @@ export class EnvironmentHttpUnauthorizedError extends Schema.TaggedErrorClass()( "EnvironmentHttpForbiddenError", { - reason: Schema.optional(EnvironmentHttpForbiddenReason), message: Schema.String, }, { httpApiStatus: 403 }, ) { // @effect-diagnostics-next-line overriddenSchemaConstructor:off - constructor(props: { - readonly reason: EnvironmentHttpForbiddenReason; - readonly cause?: unknown; - }) { + constructor(props: { readonly cause?: unknown } = {}) { super({ - reason: props.reason, message: decodedEnvironmentHttpErrorMessage(props) ?? "Cloud operation is forbidden.", ...(props.cause === undefined ? {} : { cause: props.cause }), } as any); @@ -449,18 +441,13 @@ export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentCloudEndpointUnavailableError", { - reason: Schema.optional(Schema.Literal("runtime_start_failed")), message: Schema.String, endpointRuntimeStatus: Schema.Unknown, }, { httpApiStatus: 503 }, ) { // @effect-diagnostics-next-line overriddenSchemaConstructor:off - constructor(props: { - readonly reason: "runtime_start_failed"; - readonly endpointRuntimeStatus: unknown; - readonly cause?: unknown; - }) { + constructor(props: { readonly endpointRuntimeStatus: unknown; readonly cause?: unknown }) { super({ ...props, message: From 7d11df309089d7a64e8ef5558329a92207783292 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:03:50 -0700 Subject: [PATCH 4/4] Redact environment relay request targets Co-authored-by: codex --- apps/server/src/cloud/http.test.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 117ec2d0e4d..c623d79e550 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -217,21 +217,6 @@ describe("CloudRelayRequestError", () => { }); }); -it("preserves internal causes without encoding them into HTTP error bodies", () => { - const cause = new Error("private upstream detail"); - const error = new EnvironmentHttpInternalServerError({ - message: "Stable public failure.", - cause, - }); - const encodeError = Schema.encodeUnknownSync(EnvironmentHttpInternalServerError); - - expect(error.cause).toBe(cause); - expect(encodeError(error)).toEqual({ - _tag: "EnvironmentHttpInternalServerError", - message: "Stable public failure.", - }); -}); - it("keeps internal causes out of encoded HTTP error bodies", () => { const cause = new Error("private upstream detail"); const error = new EnvironmentHttpInternalServerError({ @@ -382,7 +367,12 @@ describe("reconcileDesiredCloudLink", () => { const logFields = capturedLogs[0]?.find( (value): value is Record => typeof value === "object" && value !== null, ); - expect(logFields).toMatchObject({ causeTag: "CloudRelayRequestError" }); + expect(logFields).toMatchObject({ + operation: "relay_request", + relayOperation: "create-link-challenge", + relayPhase: "send-request", + causeTag: "CloudRelayRequestError", + }); expect(logFields).not.toHaveProperty("cause"); expect(capturedLogs[0]?.filter((value) => typeof value === "string").join(" ")).not.toContain( transportCause.message,