1import type { DpopFailureReason } from "@t3tools/contracts";
2import type { RelayEnvironmentStatusResponse, RelayProtectedError } from "@t3tools/contracts/relay";
3
4export const DPOP_CLOCK_HINT =
5 "Hint: Check that automatic date and time is enabled on both devices, then try again.";
6
7/** Older servers omit the DPoP category, but newer servers can also omit it for
8 * a credential failure that happens after proof verification. */
9export const DPOP_UNKNOWN_HINT =
10 "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices.";
11
12export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID.";
13
14function dpopFailureHint(reason: DpopFailureReason | undefined): string {
15 if (reason === "time_window") return DPOP_CLOCK_HINT;
16 if (reason === undefined) return DPOP_UNKNOWN_HINT;
17 return DPOP_RETRY_HINT;
18}
19
20export function dpopFailureMessage(message: string, reason: DpopFailureReason | undefined): string {
21 return `${message} ${dpopFailureHint(reason)}`;
22}
23
24export function relayProtectedErrorMessage(error: RelayProtectedError): string {
25 switch (error._tag) {
26 case "RelayAuthInvalidError":
27 switch (error.reason) {
28 case "missing_bearer":
29 case "invalid_bearer":
30 return "Relay rejected the cloud session token.";
31 case "invalid_dpop":
32 return dpopFailureMessage("Relay rejected the DPoP proof.", error.dpopFailureReason);
33 case "not_authorized":
34 return "Relay rejected the authenticated request.";
35 }
36 case "RelayEnvironmentLinkProofExpiredError":
37 return "Relay rejected an expired environment link proof.";
38 case "RelayEnvironmentLinkProofInvalidError":
39 return `Relay rejected the environment link proof (${error.reason}).`;
40 case "RelayEnvironmentConnectNotAuthorizedError":
41 // "Not authorized" covers non-auth causes too; surface the reason so a
42 // missing link does not read as a credential problem.
43 if (error.reason === "environment_link_not_found") {
44 return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine.";
45 }
46 if (error.reason === "managed_endpoint_allocation_not_ready") {
47 return "The environment's T3 Connect tunnel is still being set up.";
48 }
49 if (error.reason === "endpoint_provider_not_managed") {
50 return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn T3 Connect on.";
51 }
52 return error.reason
53 ? `Relay rejected the environment connection request (${error.reason}).`
54 : "Relay rejected the environment connection request.";
55 case "RelayEnvironmentEndpointUnavailableError":
56 return `Relay could not reach the environment endpoint (${error.reason}).`;
57 case "RelayEnvironmentEndpointTimedOutError":
58 return "Relay timed out while contacting the environment endpoint.";
59 case "RelayEnvironmentLinkFailedError":
60 return `Relay could not link the environment (${error.reason}).`;
61 case "RelayEnvironmentLinkUnavailableError":
62 return `Relay cannot provision the managed endpoint (${error.reason}).`;
63 case "RelayEnvironmentLinkLimitExceededError":
64 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
65 case "RelayAgentActivityPublishProofExpiredError":
66 return "Relay rejected an expired agent activity publish proof.";
67 case "RelayAgentActivityPublishProofInvalidError":
68 return `Relay rejected the agent activity publish proof (${error.reason}).`;
69 case "RelayInternalError":
70 return `Relay encountered an internal error (${error.reason}).`;
71 }
72}
73
74// The relay cuts off a request it cannot finish in time with a 504 that no
75// endpoint declares, so the client sees only the status.
76export const RELAY_GATEWAY_TIMEOUT_MESSAGE =
77 "Relay timed out before it finished the request (HTTP 504). Try again in a few minutes. If it keeps failing, copy the trace ID when you report it.";
78
79/**
80 * User-facing detail for a failed relay request: the relay's typed error, or
81 * the HTTP status when the relay sent a response the contract does not declare.
82 * Null when the request got no response.
83 */
84export function relayRequestFailureDetail(error: {
85 readonly relayError?: RelayProtectedError | undefined;
86 readonly status?: number | undefined;
87}): string | null {
88 if (error.relayError) return relayProtectedErrorMessage(error.relayError);
89 if (error.status === 504) return RELAY_GATEWAY_TIMEOUT_MESSAGE;
90 if (error.status !== undefined) return `Relay returned an unexpected HTTP ${error.status}.`;
91 return null;
92}
93
94// A host with a current build gets a new tunnel on its own within minutes of
95// coming back, which clears this reason. While it is still reported, the host
96// is either still off or running a build too old to do that.
97export const RELAY_TUNNEL_RELEASED_MESSAGE =
98 "Offline for a while, so its T3 Connect tunnel was removed. Start the server on that computer and update it to the latest version to reconnect.";
99
100/** User-facing text for an offline status, or null when the relay gave no known reason. */
101export function relayOfflineReasonMessage(
102 status: Pick<RelayEnvironmentStatusResponse, "offlineReason">,
103): string | null {
104 switch (status.offlineReason) {
105 case "tunnel_released":
106 return RELAY_TUNNEL_RELEASED_MESSAGE;
107 case undefined:
108 return null;
109 }
110}