1import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts";
2import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import * as HttpClient from "effect/http/HttpClient";
10
11import { bootstrapRemoteBearerSession } from "../authorization/remote.ts";
12import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
13import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
14import * as ClientCapabilities from "../platform/capabilities.ts";
15import {
16 BearerConnectionCredential,
17 BearerConnectionProfile,
18 BearerConnectionRegistration,
19 type ConnectionCatalogEntry,
20 type ConnectionCredential,
21 SshConnectionProfile,
22 SshConnectionRegistration,
23} from "./catalog.ts";
24import * as ConnectionCredentialStore from "./credentialStore.ts";
25import { mapRemoteEnvironmentError } from "./errors.ts";
26import {
27 BearerConnectionTarget,
28 ConnectionBlockedError,
29 SshConnectionTarget,
30 type ConnectionAttemptError,
31} from "./model.ts";
32import * as Persistence from "../platform/persistence.ts";
33import * as EnvironmentRegistry from "./registry.ts";
34import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
35import { connectionRoutes, routeEntry, sshTargetKey } from "./routes.ts";
36
37export interface PairingConnectionInput {
38 readonly pairingUrl?: string;
39 readonly host?: string;
40 readonly pairingCode?: string;
41 /**
42 * Set when adding a route to a saved machine: the pairing must reach this
43 * environment, or nothing is saved.
44 */
45 readonly expectedEnvironmentId?: EnvironmentId;
46}
47
48export interface SshConnectionInput {
49 readonly target: DesktopSshEnvironmentTarget;
50 readonly label?: string;
51 /** Set when adding a route to a saved machine; see `PairingConnectionInput`. */
52 readonly expectedEnvironmentId?: EnvironmentId;
53}
54
55export interface BearerConnectionUpdateInput {
56 readonly environmentId: EnvironmentId;
57 readonly label: string;
58 readonly httpBaseUrl: string;
59}
60
61export class ConnectionOnboarding extends Context.Service<
62 ConnectionOnboarding,
63 {
64 readonly registerPairing: (
65 input: PairingConnectionInput,
66 ) => Effect.Effect<
67 EnvironmentId,
68 ConnectionAttemptError | Persistence.ConnectionPersistenceError
69 >;
70 readonly registerSsh: (
71 input: SshConnectionInput,
72 ) => Effect.Effect<
73 EnvironmentId,
74 ConnectionAttemptError | Persistence.ConnectionPersistenceError
75 >;
76 readonly updateBearer: (
77 input: BearerConnectionUpdateInput,
78 ) => Effect.Effect<void, ConnectionAttemptError | Persistence.ConnectionPersistenceError>;
79 }
80>()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {}
81
82const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")(
83 function* (input: PairingConnectionInput) {
84 return yield* Effect.try({
85 try: () => resolveRemotePairingTarget(input),
86 catch: (cause) =>
87 new ConnectionBlockedError({
88 reason: "configuration",
89 detail: cause instanceof Error ? cause.message : "The pairing details are invalid.",
90 }),
91 });
92 },
93);
94
95/**
96 * One bearer route per address, so pairing over Tailscale adds a route next
97 * to the LAN one instead of replacing it. Pairing the same address again
98 * reuses the id and replaces that route.
99 */
100function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string {
101 const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
102 return `bearer:${environmentId}:${url.origin}${url.pathname.replace(/\/+$/, "")}`;
103}
104
105function differentMachineError(label: string) {
106 return new ConnectionBlockedError({
107 reason: "configuration",
108 detail: `That address reaches ${label}, a different machine. Add it as its own environment instead.`,
109 });
110}
111
112export const preparePairingRegistration = Effect.fn(
113 "clientRuntime.connection.onboarding.preparePairingRegistration",
114)(function* (input: PairingConnectionInput) {
115 const target = yield* resolvePairingTarget(input);
116 const presentation = yield* ClientCapabilities.ClientPresentation;
117 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
118 httpBaseUrl: target.httpBaseUrl,
119 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
120 // Checked before redeeming the one-time code, so a wrong link is not spent.
121 if (
122 input.expectedEnvironmentId !== undefined &&
123 descriptor.environmentId !== input.expectedEnvironmentId
124 ) {
125 return yield* differentMachineError(descriptor.label);
126 }
127 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
128 // An outdated server is still saved so it can be updated from this client.
129 if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {
130 return yield* compatibilityError;
131 }
132 const access = yield* bootstrapRemoteBearerSession({
133 httpBaseUrl: target.httpBaseUrl,
134 credential: target.credential,
135 clientMetadata: presentation.metadata,
136 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
137 const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl);
138
139 return new BearerConnectionRegistration({
140 target: new BearerConnectionTarget({
141 environmentId: descriptor.environmentId,
142 label: descriptor.label,
143 connectionId,
144 }),
145 profile: new BearerConnectionProfile({
146 connectionId,
147 environmentId: descriptor.environmentId,
148 label: descriptor.label,
149 httpBaseUrl: target.httpBaseUrl,
150 wsBaseUrl: target.wsBaseUrl,
151 }),
152 credential: new BearerConnectionCredential({
153 token: access.access_token,
154 }),
155 });
156});
157
158const registerPairingConnection = Effect.fn(
159 "clientRuntime.connection.onboarding.registerPairingConnection",
160)(function* (input: PairingConnectionInput) {
161 const registration = yield* preparePairingRegistration(input);
162 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
163 // A platform-managed environment belongs to the host, so pairing leaves it alone.
164 if (!(yield* registry.register(registration))) return registration.target.environmentId;
165 // Registering keeps a previous entry's switched-off flag and unsupported
166 // reason, which is right for label or URL edits. A pairing is an explicit
167 // request to connect, so the stale reason is cleared and the environment
168 // turned on after the pair succeeded; a server that is still incompatible,
169 // such as an outdated one awaiting an update, is flagged again on connect.
170 yield* registry.setCompatibility(registration.target.environmentId, null);
171 yield* registry
172 .setEnabled(registration.target.environmentId, true)
173 .pipe(Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }));
174 return registration.target.environmentId;
175});
176
177const isBearerCredential = Schema.is(BearerConnectionCredential);
178const isBearerProfile = Schema.is(BearerConnectionProfile);
179
180const updateBearerConnection = Effect.fn(
181 "clientRuntime.connection.onboarding.updateBearerConnection",
182)(function* (input: BearerConnectionUpdateInput) {
183 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
184 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
185 const saved = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId);
186 // Editing changes the environment's first direct route; others stay as saved.
187 const route =
188 saved === undefined
189 ? undefined
190 : connectionRoutes(saved).find(
191 (candidate) => candidate.target._tag === "BearerConnectionTarget",
192 );
193 const entry = saved === undefined || route === undefined ? saved : routeEntry(saved, route);
194 const credential =
195 entry?.target._tag === "BearerConnectionTarget"
196 ? yield* credentials.get(entry.target.connectionId)
197 : Option.none();
198 const registration = yield* prepareBearerConnectionUpdate({
199 input,
200 entry: Option.fromUndefinedOr(entry),
201 credential,
202 });
203 yield* registry.register(registration);
204});
205
206export const prepareBearerConnectionUpdate = Effect.fn(
207 "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate",
208)(function* (options: {
209 readonly input: BearerConnectionUpdateInput;
210 readonly entry: Option.Option<ConnectionCatalogEntry>;
211 readonly credential: Option.Option<ConnectionCredential>;
212}) {
213 const entry = Option.getOrNull(options.entry);
214 if (
215 entry === undefined ||
216 entry === null ||
217 entry.target._tag !== "BearerConnectionTarget" ||
218 Option.isNone(entry.profile) ||
219 !isBearerProfile(entry.profile.value)
220 ) {
221 return yield* new ConnectionBlockedError({
222 reason: "configuration",
223 detail: "Only saved bearer environments can be edited.",
224 });
225 }
226
227 const credential = options.credential;
228 if (Option.isNone(credential) || !isBearerCredential(credential.value)) {
229 return yield* new ConnectionBlockedError({
230 reason: "authentication",
231 detail: "The saved bearer credential is unavailable.",
232 });
233 }
234
235 const label = options.input.label.trim();
236 if (label === "") {
237 return yield* new ConnectionBlockedError({
238 reason: "configuration",
239 detail: "Environment label cannot be empty.",
240 });
241 }
242 const httpBaseUrl = yield* Effect.try({
243 try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl),
244 catch: (cause) =>
245 new ConnectionBlockedError({
246 reason: "configuration",
247 detail: cause instanceof Error ? cause.message : "The environment URL is invalid.",
248 }),
249 });
250 const connectionId = entry.target.connectionId;
251 return new BearerConnectionRegistration({
252 target: new BearerConnectionTarget({
253 environmentId: options.input.environmentId,
254 label,
255 connectionId,
256 }),
257 profile: new BearerConnectionProfile({
258 connectionId,
259 environmentId: options.input.environmentId,
260 label,
261 httpBaseUrl,
262 wsBaseUrl: deriveWsBaseUrl(httpBaseUrl),
263 }),
264 credential: credential.value,
265 });
266});
267
268export const prepareSshRegistration = Effect.fn(
269 "clientRuntime.connection.onboarding.prepareSshRegistration",
270)(function* (input: SshConnectionInput) {
271 const gateway = yield* ClientCapabilities.SshEnvironmentGateway;
272 const provisioned = yield* gateway.provision(input.target, input.expectedEnvironmentId);
273 // One id per SSH target, so a second host or alias for the same machine
274 // adds a route instead of replacing the first.
275 const connectionId = `ssh:${provisioned.environmentId}:${sshTargetKey(provisioned.bootstrap.target)}`;
276 const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias;
277
278 return new SshConnectionRegistration({
279 target: new SshConnectionTarget({
280 environmentId: provisioned.environmentId,
281 label,
282 connectionId,
283 }),
284 profile: new SshConnectionProfile({
285 connectionId,
286 environmentId: provisioned.environmentId,
287 label,
288 target: provisioned.bootstrap.target,
289 }),
290 });
291});
292
293const registerSshConnection = Effect.fn(
294 "clientRuntime.connection.onboarding.registerSshConnection",
295)(function* (input: SshConnectionInput) {
296 const registration = yield* prepareSshRegistration(input);
297 if (
298 input.expectedEnvironmentId !== undefined &&
299 registration.target.environmentId !== input.expectedEnvironmentId
300 ) {
301 return yield* differentMachineError(registration.target.label);
302 }
303 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
304 yield* registry.register(registration);
305 return registration.target.environmentId;
306});
307
308/** @public Service construction is part of the canonical Effect module API. */
309export const make = Effect.gen(function* () {
310 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
311 const presentation = yield* ClientCapabilities.ClientPresentation;
312 const httpClient = yield* HttpClient.HttpClient;
313 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
314 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
315
316 return ConnectionOnboarding.of({
317 registerPairing: (input) =>
318 registerPairingConnection(input).pipe(
319 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
320 Effect.provideService(ClientCapabilities.ClientPresentation, presentation),
321 Effect.provideService(HttpClient.HttpClient, httpClient),
322 ),
323 registerSsh: (input) =>
324 registerSshConnection(input).pipe(
325 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
326 Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh),
327 ),
328 updateBearer: (input) =>
329 updateBearerConnection(input).pipe(
330 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
331 Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials),
332 ),
333 });
334});
335
336export const layer = Layer.effect(ConnectionOnboarding, make);