1import type { ClientConnectionMethod, EnvironmentId } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";
3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
4import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
5import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
6import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
7import {
8 ConnectionBlockedError,
9 type ConnectionAttemptError,
10 ConnectionTransientError,
11} from "./model.ts";
12
13export function profileMissingError(connectionId: string): ConnectionBlockedError {
14 return new ConnectionBlockedError({
15 reason: "configuration",
16 detail: `Connection profile ${connectionId} is unavailable.`,
17 });
18}
19
20export function credentialMissingError(connectionId: string): ConnectionBlockedError {
21 return new ConnectionBlockedError({
22 reason: "authentication",
23 detail: `Connection credential ${connectionId} is unavailable.`,
24 });
25}
26
27export function environmentMismatchError(input: {
28 readonly expected: EnvironmentId;
29 readonly actual: EnvironmentId;
30}): ConnectionBlockedError {
31 return new ConnectionBlockedError({
32 reason: "configuration",
33 detail: `Connected environment ${input.actual} does not match ${input.expected}.`,
34 });
35}
36
37function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError {
38 switch (error._tag) {
39 case "RelayAuthInvalidError":
40 case "RelayEnvironmentLinkProofExpiredError":
41 case "RelayAgentActivityPublishProofExpiredError":
42 case "RelayAgentActivityPublishProofInvalidError":
43 return new ConnectionBlockedError({
44 reason: "authentication",
45 detail: relayProtectedErrorMessage(error),
46 traceId: error.traceId,
47 });
48 case "RelayEnvironmentConnectNotAuthorizedError":
49 // A host swapping in a new tunnel leaves its allocation unready until
50 // the tunnel is up, and nothing on the client changes when it is.
51 if (error.reason === "managed_endpoint_allocation_not_ready") {
52 return new ConnectionTransientError({
53 reason: "endpoint-unavailable",
54 detail: relayProtectedErrorMessage(error),
55 traceId: error.traceId,
56 });
57 }
58 return new ConnectionBlockedError({
59 reason: "permission",
60 detail: relayProtectedErrorMessage(error),
61 traceId: error.traceId,
62 });
63 case "RelayEnvironmentLinkProofInvalidError":
64 case "RelayEnvironmentLinkLimitExceededError":
65 return new ConnectionBlockedError({
66 reason: "permission",
67 detail: relayProtectedErrorMessage(error),
68 traceId: error.traceId,
69 });
70 case "RelayEnvironmentEndpointTimedOutError":
71 return new ConnectionTransientError({
72 reason: "timeout",
73 detail: relayProtectedErrorMessage(error),
74 traceId: error.traceId,
75 });
76 case "RelayEnvironmentEndpointUnavailableError":
77 case "RelayEnvironmentLinkUnavailableError":
78 return new ConnectionTransientError({
79 reason: "endpoint-unavailable",
80 detail: relayProtectedErrorMessage(error),
81 traceId: error.traceId,
82 });
83 case "RelayEnvironmentLinkFailedError":
84 case "RelayInternalError":
85 return new ConnectionTransientError({
86 reason: "relay-unavailable",
87 detail: relayProtectedErrorMessage(error),
88 traceId: error.traceId,
89 });
90 }
91}
92
93export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError {
94 switch (error._tag) {
95 case "ManagedRelayRequestFailedError":
96 if (error.relayError) {
97 return relayProtectedError(error.relayError);
98 }
99 return new ConnectionTransientError({
100 reason: "relay-unavailable",
101 detail: error.message,
102 ...(error.traceId ? { traceId: error.traceId } : {}),
103 });
104 case "ManagedRelayRequestTimeoutError":
105 return new ConnectionTransientError({
106 reason: "timeout",
107 detail: error.message,
108 });
109 case "ManagedRelayUrlInvalidError":
110 return new ConnectionBlockedError({
111 reason: "configuration",
112 detail: error.message,
113 });
114 case "ManagedRelayAccessTokenScopesUnexpectedError":
115 return new ConnectionBlockedError({
116 reason: "permission",
117 detail: error.message,
118 });
119 case "ManagedRelayDpopKeyLoadError":
120 case "ManagedRelayTokenProofCreationError":
121 case "ManagedRelayRequestProofCreationError":
122 return new ConnectionBlockedError({
123 reason: "authentication",
124 detail: error.message,
125 });
126 }
127}
128
129export function mapRemoteEnvironmentError(
130 error: RemoteEnvironmentAuthError,
131 connectionMethod: ClientConnectionMethod = "direct",
132): ConnectionAttemptError {
133 const networkHint = connectionMethod === "relay" ? ` ${NETWORK_BLOCKING_HINT}` : "";
134 switch (error._tag) {
135 case "EnvironmentAuthInvalidError":
136 return new ConnectionBlockedError({
137 reason: "authentication",
138 detail: "The environment credential is invalid.",
139 traceId: error.traceId,
140 });
141 case "EnvironmentScopeRequiredError":
142 case "EnvironmentOperationForbiddenError":
143 return new ConnectionBlockedError({
144 reason: "permission",
145 detail: "The environment credential does not grant the required access.",
146 traceId: error.traceId,
147 });
148 case "EnvironmentRequestInvalidError":
149 return new ConnectionBlockedError({
150 reason: "configuration",
151 detail: "The environment rejected the authentication request.",
152 traceId: error.traceId,
153 });
154 case "EnvironmentResourceNotFoundError":
155 // Not expected during connection authorization, but the shared request
156 // error type now includes it (used by resource fetches like the thread
157 // snapshot). Treat it as a configuration issue with the endpoint.
158 return new ConnectionBlockedError({
159 reason: "configuration",
160 detail: "The environment endpoint could not be found.",
161 traceId: error.traceId,
162 });
163 case "RemoteEnvironmentAuthTimeoutError":
164 return new ConnectionTransientError({
165 reason: "timeout",
166 detail: `${error.message}${networkHint}`,
167 });
168 case "RemoteEnvironmentAuthFetchError":
169 return new ConnectionTransientError({
170 reason: "network",
171 detail: `${error.message}${networkHint}`,
172 });
173 case "EnvironmentInternalError":
174 return new ConnectionTransientError({
175 reason: "remote-unavailable",
176 detail: "The environment could not authorize the connection.",
177 traceId: error.traceId,
178 });
179 case "RemoteEnvironmentAuthInvalidJsonError":
180 case "RemoteEnvironmentAuthUndeclaredStatusError":
181 return new ConnectionTransientError({
182 reason: "remote-unavailable",
183 detail: error.message,
184 });
185 }
186}
187
188/**
189 * Map an environment error from a request that used DPoP authentication. An
190 * older environment server reports a DPoP clock failure as the same generic
191 * invalid-credential response as other failures, so keep the compatibility
192 * hint cautious when the server omits the category. Newer servers can identify
193 * clock and non-clock proof failures precisely.
194 */
195export function mapRemoteDpopEnvironmentError(
196 error: RemoteEnvironmentAuthError,
197): ConnectionAttemptError {
198 if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") {
199 return new ConnectionBlockedError({
200 reason: "authentication",
201 detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason),
202 traceId: error.traceId,
203 });
204 }
205 return mapRemoteEnvironmentError(error, "relay");
206}