1import type {
2 AuthClientPresentationMetadata,
3 ExecutionEnvironmentDescriptor,
4} from "@t3tools/contracts";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import * as HttpClient from "effect/http/HttpClient";
11
12import { appendClientConnectionParams } from "../authorization/remote.ts";
13import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
14import * as ClientCapabilities from "../platform/capabilities.ts";
15import {
16 BearerConnectionCredential,
17 BearerConnectionProfile,
18 type ConnectionCatalogEntry,
19 SshConnectionProfile,
20} from "./catalog.ts";
21import * as ConnectionCredentialStore from "./credentialStore.ts";
22import {
23 credentialMissingError,
24 environmentMismatchError,
25 mapRemoteEnvironmentError,
26 profileMissingError,
27} from "./errors.ts";
28import {
29 GitHubRoutingPermissions,
30 gitHubRoutingConnectionKey,
31} from "./githubRoutingPermissions.ts";
32import type {
33 BearerConnectionTarget,
34 ConnectionTarget,
35 PreparedConnection,
36 PrimaryConnectionTarget,
37 RelayConnectionTarget,
38 SshConnectionTarget,
39} from "./model.ts";
40import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
41import * as ConnectionProfileStore from "./profileStore.ts";
42import {
43 appendOrchestrationProtocol,
44 orchestrationProtocolCompatibilityError,
45} from "./compatibility.ts";
46import { credentialConnectionId } from "./routes.ts";
47import { deriveWsBaseUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
48import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
49
50export class ConnectionResolver extends Context.Service<
51 ConnectionResolver,
52 {
53 readonly prepare: (
54 entry: ConnectionCatalogEntry,
55 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;
56 /**
57 * Authorizes a socket without the orchestration protocol gate, for hosts
58 * too old to connect normally. Only update RPCs may run over it.
59 */
60 readonly prepareForUpdate: (entry: ConnectionCatalogEntry) => Effect.Effect<
61 {
62 readonly prepared: PreparedConnection;
63 readonly descriptor: ExecutionEnvironmentDescriptor;
64 },
65 ConnectionAttemptError
66 >;
67 }
68>()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {}
69
70const isBearerProfile = Schema.is(BearerConnectionProfile);
71const isSshProfile = Schema.is(SshConnectionProfile);
72const isBearerCredential = Schema.is(BearerConnectionCredential);
73
74function primarySocketUrl(
75 target: { readonly wsBaseUrl: string },
76 clientMetadata: AuthClientPresentationMetadata | undefined,
77): string {
78 const url = new URL(environmentWebSocketUrl(target.wsBaseUrl));
79 appendClientConnectionParams(url, clientMetadata);
80 return url.toString();
81}
82
83const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () {
84 const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth;
85 const presentation = yield* ClientCapabilities.ClientPresentation;
86 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
87
88 return Effect.fn("clientRuntime.connection.broker.primary")(function* (
89 target: PrimaryConnectionTarget,
90 ) {
91 const bearerToken = yield* auth.bearerToken;
92 if (Option.isNone(bearerToken)) {
93 return {
94 environmentId: target.environmentId,
95 label: target.label,
96 httpBaseUrl: target.httpBaseUrl,
97 socketUrl: primarySocketUrl(target, presentation.metadata),
98 httpAuthorization: null,
99 target,
100 } satisfies PreparedConnection;
101 }
102
103 const authorized = yield* remote.authorizeBearer({
104 expectedEnvironmentId: target.environmentId,
105 httpBaseUrl: target.httpBaseUrl,
106 wsBaseUrl: target.wsBaseUrl,
107 bearerToken: bearerToken.value,
108 connectionMethod: "direct",
109 });
110 return {
111 ...authorized,
112 target,
113 } satisfies PreparedConnection;
114 });
115});
116
117const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () {
118 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
119 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
120 const presentation = yield* ClientCapabilities.ClientPresentation;
121
122 return Effect.fn("clientRuntime.connection.broker.bearer")(function* (
123 entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget },
124 ) {
125 const target = entry.target;
126 const profile = yield* Effect.fromOption(entry.profile, () =>
127 profileMissingError(target.connectionId),
128 );
129 if (!isBearerProfile(profile)) {
130 return yield* new ConnectionBlockedError({
131 reason: "configuration",
132 detail: `Connection profile ${target.connectionId} is not a bearer connection.`,
133 });
134 }
135 if (profile.environmentId !== target.environmentId) {
136 return yield* environmentMismatchError({
137 expected: target.environmentId,
138 actual: profile.environmentId,
139 });
140 }
141 if (profile.authorization === "t3-connect") {
142 const authorized = yield* remote.authorizeDpop({
143 expectedEnvironmentId: target.environmentId,
144 directEndpoint: { httpBaseUrl: profile.httpBaseUrl, wsBaseUrl: profile.wsBaseUrl },
145 });
146 return { ...authorized, target } satisfies PreparedConnection;
147 }
148 // A learned route borrows the credential of the route it was learned from.
149 const savedCredential = yield* credentials
150 .get(credentialConnectionId(target.connectionId))
151 .pipe(
152 Effect.flatMap(
153 Option.match({
154 onNone: () => Effect.fail(credentialMissingError(target.connectionId)),
155 onSome: Effect.succeed,
156 }),
157 ),
158 );
159 const credential =
160 savedCredential._tag === "HubConnectionCredential"
161 ? Option.getOrNull(yield* credentials.get(savedCredential.connectionId))
162 : savedCredential;
163 if (credential?._tag === "HostedConnectionCredential") {
164 const service = yield* Effect.serviceOption(ClientCapabilities.HostedAuthAccess);
165 if (Option.isNone(service)) return yield* credentialMissingError(target.connectionId);
166 const destination = new URL(profile.httpBaseUrl);
167 const server = new URL(credential.serverUrl);
168 const prefix = server.pathname.replace(/\/+$/, "");
169 if (
170 destination.origin !== server.origin ||
171 (destination.pathname !== prefix && !destination.pathname.startsWith(`${prefix}/`)) ||
172 profile.wsBaseUrl !== deriveWsBaseUrl(profile.httpBaseUrl)
173 ) {
174 return yield* new ConnectionBlockedError({
175 reason: "configuration",
176 detail: "This connection belongs to a different server. Sign in again.",
177 });
178 }
179 const headers = yield* service.value.headers(credential.serverUrl);
180 const bearer = headers.authorization?.replace(/^Bearer /, "");
181 if (bearer) {
182 return {
183 ...(yield* remote.authorizeBearer({
184 expectedEnvironmentId: target.environmentId,
185 httpBaseUrl: profile.httpBaseUrl,
186 wsBaseUrl: profile.wsBaseUrl,
187 bearerToken: bearer,
188 connectionMethod: "direct",
189 })),
190 target,
191 } satisfies PreparedConnection;
192 }
193 return {
194 environmentId: target.environmentId,
195 label: profile.label,
196 httpBaseUrl: profile.httpBaseUrl,
197 socketUrl: primarySocketUrl(profile, presentation.metadata),
198 httpAuthorization: null,
199 target,
200 } satisfies PreparedConnection;
201 }
202 if (credential === null || !isBearerCredential(credential)) {
203 return yield* credentialMissingError(target.connectionId);
204 }
205 const authorized = yield* remote.authorizeBearer({
206 expectedEnvironmentId: target.environmentId,
207 httpBaseUrl: profile.httpBaseUrl,
208 wsBaseUrl: profile.wsBaseUrl,
209 bearerToken: credential.token,
210 connectionMethod: "direct",
211 });
212 return {
213 environmentId: authorized.environmentId,
214 label: authorized.label,
215 httpBaseUrl: authorized.httpBaseUrl,
216 socketUrl: authorized.socketUrl,
217 httpAuthorization: authorized.httpAuthorization,
218 target,
219 } satisfies PreparedConnection;
220 });
221});
222
223const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {
224 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
225
226 return Effect.fnUntraced(function* (target: RelayConnectionTarget) {
227 const authorized = yield* remote.authorizeDpop({
228 expectedEnvironmentId: target.environmentId,
229 });
230 return {
231 environmentId: authorized.environmentId,
232 label: authorized.label,
233 httpBaseUrl: authorized.httpBaseUrl,
234 socketUrl: authorized.socketUrl,
235 httpAuthorization: authorized.httpAuthorization,
236 target,
237 } satisfies PreparedConnection;
238 }, Effect.withSpan("clientRuntime.connection.broker.relay"));
239});
240
241const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () {
242 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
243 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
244 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
245
246 return Effect.fn("clientRuntime.connection.broker.ssh")(function* (
247 entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget },
248 ) {
249 const target = entry.target;
250 const profile = yield* Effect.fromOption(entry.profile, () =>
251 profileMissingError(target.connectionId),
252 );
253 if (!isSshProfile(profile)) {
254 return yield* new ConnectionBlockedError({
255 reason: "configuration",
256 detail: `Connection profile ${target.connectionId} is not an SSH connection.`,
257 });
258 }
259 if (profile.environmentId !== target.environmentId) {
260 return yield* environmentMismatchError({
261 expected: target.environmentId,
262 actual: profile.environmentId,
263 });
264 }
265 const prepared = yield* ssh.prepare({
266 connectionId: target.connectionId,
267 expectedEnvironmentId: target.environmentId,
268 target: profile.target,
269 });
270 const preparedProfile = new SshConnectionProfile({
271 connectionId: profile.connectionId,
272 environmentId: profile.environmentId,
273 label: profile.label,
274 target: prepared.bootstrap.target,
275 });
276 if (
277 gitHubRoutingConnectionKey(entry) !==
278 gitHubRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) })
279 ) {
280 const permissions = yield* GitHubRoutingPermissions;
281 yield* permissions.forget(target.environmentId);
282 }
283 yield* profiles.put(preparedProfile);
284 const authorized = yield* remote.authorizeBearer({
285 expectedEnvironmentId: target.environmentId,
286 httpBaseUrl: prepared.bootstrap.httpBaseUrl,
287 wsBaseUrl: prepared.bootstrap.wsBaseUrl,
288 bearerToken: prepared.bearerToken,
289 connectionMethod: "ssh",
290 });
291 return {
292 environmentId: authorized.environmentId,
293 label: authorized.label,
294 httpBaseUrl: authorized.httpBaseUrl,
295 socketUrl: authorized.socketUrl,
296 httpAuthorization: authorized.httpAuthorization,
297 target,
298 } satisfies PreparedConnection;
299 });
300});
301
302/** @public Service construction is part of the canonical Effect module API. */
303export const make = Effect.gen(function* () {
304 const primary = yield* makePrimaryBroker();
305 const bearer = yield* makeBearerBroker();
306 const relay = yield* makeRelayBroker();
307 const ssh = yield* makeSshBroker();
308 const httpClient = yield* HttpClient.HttpClient;
309
310 const authorize = Effect.fn("clientRuntime.connection.broker.authorize")(function* (
311 entry: ConnectionCatalogEntry,
312 ) {
313 const target: ConnectionTarget = entry.target;
314 yield* Effect.annotateCurrentSpan({
315 "connection.environment.id": target.environmentId,
316 "connection.target.kind": target._tag,
317 });
318 const prepared = yield* (() => {
319 switch (target._tag) {
320 case "PrimaryConnectionTarget":
321 return primary(target);
322 case "BearerConnectionTarget":
323 return bearer({ ...entry, target });
324 case "RelayConnectionTarget":
325 return relay(target);
326 case "SshConnectionTarget":
327 return ssh({ ...entry, target });
328 }
329 })();
330 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
331 httpBaseUrl: prepared.httpBaseUrl,
332 }).pipe(
333 Effect.mapError(mapRemoteEnvironmentError),
334 Effect.provideService(HttpClient.HttpClient, httpClient),
335 );
336 if (descriptor.environmentId !== target.environmentId) {
337 return yield* environmentMismatchError({
338 expected: target.environmentId,
339 actual: descriptor.environmentId,
340 });
341 }
342 return { prepared, descriptor };
343 });
344
345 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
346 entry: ConnectionCatalogEntry,
347 ) {
348 const { prepared, descriptor } = yield* authorize(entry);
349 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
350 if (compatibilityError !== null) {
351 return yield* compatibilityError;
352 }
353 return {
354 ...prepared,
355 socketUrl: appendOrchestrationProtocol(prepared.socketUrl),
356 };
357 });
358
359 return ConnectionResolver.of({ prepare, prepareForUpdate: authorize });
360});
361
362export const layer = Layer.effect(ConnectionResolver, make);