| author | |
| committer | |
| log | 1877237c7ae99ecf2163b4c5bb7d3e14bebcca4d |
| tree | 5bb28c9ce2f991cc90ab17aded77e4a7e3c6f515 |
| parent | e0513d29839f934df1d5a13551e6ccd76d11006b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 63 insertions(+), 21 deletions(-)
apps/mobile/src/features/cloud/linkEnvironment.ts+8-1| ... | ... | @@ -134,7 +134,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { |
| 134 | 134 | case "RelayEnvironmentLinkProofInvalidError": |
| 135 | 135 | return `Relay rejected the environment link proof (${error.reason}).`; |
| 136 | 136 | case "RelayEnvironmentConnectNotAuthorizedError": |
| 137 | return "Relay rejected the environment connection request."; | |
| 137 | // "Not authorized" covers non-auth causes too; surface the reason so a | |
| 138 | // missing link doesn't read as a credential problem. | |
| 139 | if (error.reason === "environment_link_not_found") { | |
| 140 | return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; | |
| 141 | } | |
| 142 | return error.reason | |
| 143 | ? `Relay rejected the environment connection request (${error.reason}).` | |
| 144 | : "Relay rejected the environment connection request."; | |
| 138 | 145 | case "RelayEnvironmentEndpointUnavailableError": |
| 139 | 146 | return `Relay could not reach the environment endpoint (${error.reason}).`; |
| 140 | 147 | case "RelayEnvironmentEndpointTimedOutError": |
apps/server/src/server.ts+21-1| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { EnvironmentHttpApi } from "@t3tools/contracts"; |
| 2 | import * as Duration from "effect/Duration"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schedule from "effect/Schedule"; | |
| 4 | 6 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | 7 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 6 | 8 | |
| ... | ... | @@ -535,7 +537,25 @@ export const makeServerLayer = Layer.unwrap( |
| 535 | 537 | yield* Effect.forkScoped( |
| 536 | 538 | Effect.sleep("250 millis").pipe( |
| 537 | 539 | Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), |
| 538 | Effect.retry({ times: 4 }), | |
| 540 | // On reboot this races NIC/DNS bring-up, so back off exponentially | |
| 541 | // (capped at 30s) instead of burning all retries in a second. | |
| 542 | // Bounded overall so a permanently broken setup still surfaces the | |
| 543 | // warning below. Bad-request/unauthorized/conflict are | |
| 544 | // deterministic failures (malformed origin, not linked yet, linked | |
| 545 | // to a different cloud account) that no amount of retrying | |
| 546 | // converges. | |
| 547 | Effect.retry({ | |
| 548 | while: (error) => | |
| 549 | error._tag !== "EnvironmentHttpBadRequestError" && | |
| 550 | error._tag !== "EnvironmentHttpUnauthorizedError" && | |
| 551 | error._tag !== "EnvironmentHttpConflictError", | |
| 552 | schedule: Schedule.exponential("1 second").pipe( | |
| 553 | Schedule.modifyDelay(({ duration }) => | |
| 554 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), | |
| 555 | ), | |
| 556 | Schedule.upTo({ duration: "10 minutes" }), | |
| 557 | ), | |
| 558 | }), | |
| 539 | 559 | Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), |
| 540 | 560 | Effect.catch((cause) => |
| 541 | 561 | Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { |
apps/web/src/cloud/linkEnvironment.ts+8-1| ... | ... | @@ -145,7 +145,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { |
| 145 | 145 | case "RelayEnvironmentLinkProofInvalidError": |
| 146 | 146 | return `Relay rejected the environment link proof (${error.reason}).`; |
| 147 | 147 | case "RelayEnvironmentConnectNotAuthorizedError": |
| 148 | return "Relay rejected the environment connection request."; | |
| 148 | // "Not authorized" covers non-auth causes too; surface the reason so a | |
| 149 | // missing link doesn't read as a credential problem. | |
| 150 | if (error.reason === "environment_link_not_found") { | |
| 151 | return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; | |
| 152 | } | |
| 153 | return error.reason | |
| 154 | ? `Relay rejected the environment connection request (${error.reason}).` | |
| 155 | : "Relay rejected the environment connection request."; | |
| 149 | 156 | case "RelayEnvironmentEndpointUnavailableError": |
| 150 | 157 | return `Relay could not reach the environment endpoint (${error.reason}).`; |
| 151 | 158 | case "RelayEnvironmentEndpointTimedOutError": |
infra/relay/src/environments/EnvironmentConnector.ts+3-15| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | RelayEnvironmentMintResponse, |
| 14 | 14 | RelayEnvironmentMintResponseProofPayload, |
| 15 | 15 | RelayCloudMintCredentialProofPayload, |
| 16 | RelayEnvironmentConnectNotAuthorizedReason, | |
| 16 | 17 | type RelayEnvironmentConnectResponse, |
| 17 | 18 | type RelayEnvironmentStatusResponse, |
| 18 | 19 | } from "@t3tools/contracts/relay"; |
| ... | ... | @@ -44,21 +45,8 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; |
| 44 | 45 | import * as RelayConfiguration from "../Config.ts"; |
| 45 | 46 | import { isManagedEndpointHostname } from "../deploymentConfig.ts"; |
| 46 | 47 | |
| 47 | export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([ | |
| 48 | "client_proof_key_thumbprint_missing", | |
| 49 | "environment_link_not_found", | |
| 50 | "endpoint_provider_not_managed", | |
| 51 | "managed_endpoint_allocation_not_found", | |
| 52 | "managed_endpoint_base_domain_not_configured", | |
| 53 | "managed_endpoint_allocation_not_ready", | |
| 54 | "managed_endpoint_hostname_invalid", | |
| 55 | "managed_endpoint_mismatch", | |
| 56 | ]); | |
| 57 | export type EnvironmentConnectNotAuthorizedReason = | |
| 58 | typeof EnvironmentConnectNotAuthorizedReason.Type; | |
| 59 | ||
| 60 | 48 | function environmentConnectNotAuthorizedReasonMessage( |
| 61 | reason: EnvironmentConnectNotAuthorizedReason, | |
| 49 | reason: RelayEnvironmentConnectNotAuthorizedReason, | |
| 62 | 50 | ): string { |
| 63 | 51 | switch (reason) { |
| 64 | 52 | case "client_proof_key_thumbprint_missing": |
| ... | ... | @@ -85,7 +73,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass<Env |
| 85 | 73 | { |
| 86 | 74 | environmentId: Schema.String, |
| 87 | 75 | operation: Schema.Literals(["connect", "status"]), |
| 88 | reason: EnvironmentConnectNotAuthorizedReason, | |
| 76 | reason: RelayEnvironmentConnectNotAuthorizedReason, | |
| 89 | 77 | }, |
| 90 | 78 | ) { |
| 91 | 79 | override get message(): string { |
infra/relay/src/http/Api.ts+4-2| ... | ... | @@ -778,9 +778,10 @@ export const dpopClientApi = HttpApiBuilder.group( |
| 778 | 778 | }, |
| 779 | 779 | mapRelayCommonApiErrors("invalid_dpop"), |
| 780 | 780 | mapErrorTags({ |
| 781 | EnvironmentConnectNotAuthorized: (_error, traceId) => | |
| 781 | EnvironmentConnectNotAuthorized: (error, traceId) => | |
| 782 | 782 | new RelayEnvironmentConnectNotAuthorizedError({ |
| 783 | 783 | code: "environment_connect_not_authorized", |
| 784 | reason: error.reason, | |
| 784 | 785 | traceId, |
| 785 | 786 | }), |
| 786 | 787 | EnvironmentMintRequestFailed: (_error, traceId) => |
| ... | ... | @@ -820,9 +821,10 @@ export const dpopClientApi = HttpApiBuilder.group( |
| 820 | 821 | }, |
| 821 | 822 | mapRelayCommonApiErrors("invalid_dpop"), |
| 822 | 823 | mapErrorTags({ |
| 823 | EnvironmentConnectNotAuthorized: (_error, traceId) => | |
| 824 | EnvironmentConnectNotAuthorized: (error, traceId) => | |
| 824 | 825 | new RelayEnvironmentConnectNotAuthorizedError({ |
| 825 | 826 | code: "environment_connect_not_authorized", |
| 827 | reason: error.reason, | |
| 826 | 828 | traceId, |
| 827 | 829 | }), |
| 828 | 830 | EnvironmentMintRequestFailed: (_error, traceId) => |
packages/contracts/src/relay.ts+19-1| ... | ... | @@ -371,16 +371,34 @@ export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorCla |
| 371 | 371 | } |
| 372 | 372 | } |
| 373 | 373 | |
| 374 | export const RelayEnvironmentConnectNotAuthorizedReason = Schema.Literals([ | |
| 375 | "client_proof_key_thumbprint_missing", | |
| 376 | "environment_link_not_found", | |
| 377 | "endpoint_provider_not_managed", | |
| 378 | "managed_endpoint_allocation_not_found", | |
| 379 | "managed_endpoint_base_domain_not_configured", | |
| 380 | "managed_endpoint_allocation_not_ready", | |
| 381 | "managed_endpoint_hostname_invalid", | |
| 382 | "managed_endpoint_mismatch", | |
| 383 | ]); | |
| 384 | export type RelayEnvironmentConnectNotAuthorizedReason = | |
| 385 | typeof RelayEnvironmentConnectNotAuthorizedReason.Type; | |
| 386 | ||
| 374 | 387 | export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass<RelayEnvironmentConnectNotAuthorizedError>()( |
| 375 | 388 | "RelayEnvironmentConnectNotAuthorizedError", |
| 376 | 389 | { |
| 377 | 390 | code: Schema.Literal("environment_connect_not_authorized"), |
| 391 | // Optional so responses from relays deployed before the reason was | |
| 392 | // threaded through still decode. | |
| 393 | reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason), | |
| 378 | 394 | traceId: TrimmedNonEmptyString, |
| 379 | 395 | }, |
| 380 | 396 | { httpApiStatus: 403 }, |
| 381 | 397 | ) { |
| 382 | 398 | override get message(): string { |
| 383 | return "Relay environment connection is not authorized"; | |
| 399 | return this.reason | |
| 400 | ? `Relay environment connection is not authorized: ${this.reason}` | |
| 401 | : "Relay environment connection is not authorized"; | |
| 384 | 402 | } |
| 385 | 403 | } |
| 386 | 404 |