| 1 | import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | |
| 11 | import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; |
| 12 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; |
| 13 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 14 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 15 | import { |
| 16 | BearerConnectionCredential, |
| 17 | BearerConnectionProfile, |
| 18 | BearerConnectionRegistration, |
| 19 | type ConnectionCatalogEntry, |
| 20 | type ConnectionCredential, |
| 21 | SshConnectionProfile, |
| 22 | SshConnectionRegistration, |
| 23 | } from "./catalog.ts"; |
| 24 | import * as ConnectionCredentialStore from "./credentialStore.ts"; |
| 25 | import { mapRemoteEnvironmentError } from "./errors.ts"; |
| 26 | import { |
| 27 | BearerConnectionTarget, |
| 28 | ConnectionBlockedError, |
| 29 | SshConnectionTarget, |
| 30 | type ConnectionAttemptError, |
| 31 | } from "./model.ts"; |
| 32 | import * as Persistence from "../platform/persistence.ts"; |
| 33 | import * as EnvironmentRegistry from "./registry.ts"; |
| 34 | import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; |
| 35 | import { connectionRoutes, routeEntry, sshTargetKey } from "./routes.ts"; |
| 36 | |
| 37 | export 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 | |
| 48 | export 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 | |
| 55 | export interface BearerConnectionUpdateInput { |
| 56 | readonly environmentId: EnvironmentId; |
| 57 | readonly label: string; |
| 58 | readonly httpBaseUrl: string; |
| 59 | } |
| 60 | |
| 61 | export 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 | |
| 82 | const 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 | */ |
| 100 | function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string { |
| 101 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); |
| 102 | return `bearer:${environmentId}:${url.origin}${url.pathname.replace(/\/+$/, "")}`; |
| 103 | } |
| 104 | |
| 105 | function 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 | |
| 112 | export 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 | |
| 158 | const 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 | |
| 177 | const isBearerCredential = Schema.is(BearerConnectionCredential); |
| 178 | const isBearerProfile = Schema.is(BearerConnectionProfile); |
| 179 | |
| 180 | const 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 | |
| 206 | export 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 | |
| 268 | export 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 | |
| 293 | const 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. */ |
| 309 | export 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 | |
| 336 | export const layer = Layer.effect(ConnectionOnboarding, make); |