1import {
2 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,
4 type DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Ref from "effect/Ref";
11import * as Tracer from "effect/Tracer";
12
13import * as ConnectionResolver from "./resolver.ts";
14import * as ClientCapabilities from "../platform/capabilities.ts";
15import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
16import {
17 BearerConnectionCredential,
18 BearerConnectionProfile,
19 HubConnectionCredential,
20 type ConnectionCatalogEntry,
21 SshConnectionProfile,
22 type ConnectionCredential,
23 type ConnectionProfile,
24} from "./catalog.ts";
25import * as ConnectionCredentialStore from "./credentialStore.ts";
26import {
27 BearerConnectionTarget,
28 ConnectionTransientError,
29 PrimaryConnectionTarget,
30 RelayConnectionTarget,
31 SshConnectionTarget,
32 type ConnectionTarget,
33} from "./model.ts";
34import * as ConnectionProfileStore from "./profileStore.ts";
35import * as RpcHttp from "../rpc/http.ts";
36import {
37 GitHubRoutingPermissions,
38 gitHubRoutingConnectionKey,
39 makeGitHubRoutingPermissions,
40} from "./githubRoutingPermissions.ts";
41
42const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
43const ENDPOINT = {
44 httpBaseUrl: "https://environment.example.test",
45 wsBaseUrl: "wss://environment.example.test",
46};
47const SSH_TARGET: DesktopSshEnvironmentTarget = {
48 alias: "development",
49 hostname: "development.example.test",
50 username: "developer",
51 port: 22,
52};
53
54function catalogEntry(
55 target: ConnectionTarget,
56 profile: Option.Option<ConnectionProfile> = Option.none(),
57): ConnectionCatalogEntry {
58 return { target, profile, enabled: true };
59}
60
61function collectingTracer(spans: Array<string>): Tracer.Tracer {
62 return Tracer.make({
63 span: (options) => {
64 const span = new Tracer.NativeSpan(options);
65 const end = span.end.bind(span);
66 span.end = (endTime, exit) => {
67 end(endTime, exit);
68 spans.push(span.name);
69 };
70 return span;
71 },
72 });
73}
74
75const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
76 readonly profiles?: ReadonlyArray<ConnectionProfile>;
77 readonly profileStore?: ConnectionProfileStore.ConnectionProfileStore["Service"];
78 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
79 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
80 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
81 readonly primaryBearerToken?: string;
82 readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"];
83 readonly descriptorProtocolVersion?: number | null | undefined;
84}) => {
85 const profiles = new Map(
86 (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]),
87 );
88 const credentials = new Map(options?.credentials ?? []);
89
90 const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({
91 get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))),
92 put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)),
93 remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)),
94 });
95 const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({
96 get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))),
97 put: (connectionId, credential) =>
98 Effect.sync(() => void credentials.set(connectionId, credential)),
99 remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)),
100 });
101 const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
102 authorizeBearer:
103 options?.authorizeBearer ??
104 ((input) =>
105 Effect.succeed({
106 environmentId: input.expectedEnvironmentId,
107 label: "Authorized bearer environment",
108 httpBaseUrl: input.httpBaseUrl,
109 socketUrl: "wss://authorized.example.test/ws?wsTicket=bearer",
110 httpAuthorization: {
111 _tag: "Bearer" as const,
112 token: input.bearerToken,
113 },
114 })),
115 authorizeDpop:
116 options?.authorizeDpop ??
117 ((input) =>
118 Effect.succeed({
119 environmentId: input.expectedEnvironmentId,
120 label: "Authorized relay environment",
121 httpBaseUrl: ENDPOINT.httpBaseUrl,
122 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
123 httpAuthorization: {
124 _tag: "Dpop" as const,
125 accessToken: "dpop-access-token",
126 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
127 },
128 })),
129 authorizeDpopHttp: () => Effect.die("unused"),
130 });
131 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
132 provision: () => Effect.die("unused"),
133 prepare:
134 options?.prepareSsh ??
135 (() =>
136 Effect.succeed({
137 bootstrap: {
138 target: SSH_TARGET,
139 httpBaseUrl: "http://127.0.0.1:4010",
140 wsBaseUrl: "ws://127.0.0.1:4010",
141 pairingToken: null,
142 },
143 bearerToken: "ssh-bearer",
144 })),
145 disconnect: () => Effect.void,
146 });
147
148 const layerDependencies = Layer.mergeAll(
149 RpcHttp.layerRemoteHttpClient((() =>
150 Promise.resolve(
151 Response.json({
152 environmentId: ENVIRONMENT_ID,
153 label: "Compatible environment",
154 platform: { os: "linux", arch: "x64" },
155 serverVersion: "0.0.0-test",
156 ...(options?.descriptorProtocolVersion === undefined
157 ? { orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION }
158 : options.descriptorProtocolVersion === null
159 ? {}
160 : { orchestrationProtocolVersion: options.descriptorProtocolVersion }),
161 capabilities: { repositoryIdentity: true },
162 }),
163 )) satisfies typeof fetch),
164 Layer.succeed(
165 ConnectionProfileStore.ConnectionProfileStore,
166 options?.profileStore ?? profileStore,
167 ),
168 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
169 Layer.succeed(
170 ClientCapabilities.PrimaryEnvironmentAuth,
171 ClientCapabilities.PrimaryEnvironmentAuth.of({
172 bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)),
173 }),
174 ),
175 Layer.succeed(
176 ClientCapabilities.ClientPresentation,
177 ClientCapabilities.ClientPresentation.of({
178 metadata: { label: "Test Client", deviceType: "desktop", surface: "web" },
179 }),
180 ),
181 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),
182 Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh),
183 );
184
185 return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(layerDependencies)));
186});
187
188describe("ConnectionResolver", () => {
189 it.effect("resolves hub routes through one privately stored credential", () =>
190 Effect.gen(function* () {
191 const connectionId = "hub:hub:environment-1";
192 const profile = new BearerConnectionProfile({
193 connectionId,
194 environmentId: ENVIRONMENT_ID,
195 label: "Machine",
196 httpBaseUrl: "https://hub.example/environments/environment-1/",
197 wsBaseUrl: "wss://hub.example/environments/environment-1/",
198 });
199 const dependencies = yield* makeDependencies({
200 credentials: [
201 [connectionId, new HubConnectionCredential({ connectionId: "hub-session:hub" })],
202 ["hub-session:hub", new BearerConnectionCredential({ token: "single-hub-secret" })],
203 ],
204 });
205 const resolver = yield* ConnectionResolver.ConnectionResolver.pipe(
206 Effect.provide(dependencies),
207 );
208 const target = new BearerConnectionTarget({
209 connectionId,
210 environmentId: ENVIRONMENT_ID,
211 label: "Machine",
212 });
213 const prepared = yield* resolver.prepare(catalogEntry(target, Option.some(profile)));
214 expect(prepared.httpAuthorization).toEqual({ _tag: "Bearer", token: "single-hub-secret" });
215 expect(prepared.httpBaseUrl).toBe(profile.httpBaseUrl);
216 }),
217 );
218 it.effect("blocks an old host during discovery before opening orchestration RPC", () =>
219 Effect.gen(function* () {
220 const layerBroker = yield* makeDependencies({ descriptorProtocolVersion: null });
221 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
222 const target = new PrimaryConnectionTarget({
223 environmentId: ENVIRONMENT_ID,
224 label: "Primary",
225 httpBaseUrl: "http://127.0.0.1:3777",
226 wsBaseUrl: "ws://127.0.0.1:3777",
227 });
228
229 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
230
231 expect(error).toMatchObject({ reason: "unsupported" });
232 expect(error.message).toContain("Update the server on Compatible environment");
233 }),
234 );
235
236 it.effect("blocks an incompatible host during discovery before opening orchestration RPC", () =>
237 Effect.gen(function* () {
238 const layerBroker = yield* makeDependencies({
239 descriptorProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1,
240 });
241 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
242 const target = new PrimaryConnectionTarget({
243 environmentId: ENVIRONMENT_ID,
244 label: "Primary",
245 httpBaseUrl: "http://127.0.0.1:3777",
246 wsBaseUrl: "ws://127.0.0.1:3777",
247 });
248
249 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
250
251 expect(error).toMatchObject({ reason: "unsupported" });
252 expect(error.message).toContain("This client is not supported");
253 }),
254 );
255
256 it.effect("prepares a primary environment without remote capabilities", () =>
257 Effect.gen(function* () {
258 const layerBroker = yield* makeDependencies();
259 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
260 const target = new PrimaryConnectionTarget({
261 environmentId: ENVIRONMENT_ID,
262 label: "Primary",
263 httpBaseUrl: "http://127.0.0.1:3777",
264 wsBaseUrl: "ws://127.0.0.1:3777",
265 });
266
267 expect(yield* broker.prepare(catalogEntry(target))).toEqual({
268 environmentId: ENVIRONMENT_ID,
269 label: "Primary",
270 httpBaseUrl: "http://127.0.0.1:3777",
271 socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web&orchestrationProtocol=2",
272 httpAuthorization: null,
273 target,
274 });
275 }),
276 );
277
278 it.effect("authorizes a desktop primary environment with its platform bearer token", () =>
279 Effect.gen(function* () {
280 const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]);
281 const layerBroker = yield* makeDependencies({
282 primaryBearerToken: "desktop-bearer",
283 authorizeBearer: (input) =>
284 Ref.update(bearerInputs, (values) => [
285 ...values,
286 { token: input.bearerToken, method: input.connectionMethod },
287 ]).pipe(
288 Effect.as({
289 environmentId: input.expectedEnvironmentId,
290 label: "Primary",
291 httpBaseUrl: input.httpBaseUrl,
292 socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop",
293 httpAuthorization: {
294 _tag: "Bearer" as const,
295 token: input.bearerToken,
296 },
297 }),
298 ),
299 });
300 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
301 const target = new PrimaryConnectionTarget({
302 environmentId: ENVIRONMENT_ID,
303 label: "Primary",
304 httpBaseUrl: "http://127.0.0.1:3777",
305 wsBaseUrl: "ws://127.0.0.1:3777",
306 });
307
308 expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({
309 socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=2",
310 httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" },
311 target,
312 });
313 expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "desktop-bearer", method: "direct" }]);
314 }),
315 );
316
317 it.effect("uses the registered bearer profile without re-reading the profile store", () =>
318 Effect.gen(function* () {
319 const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]);
320 const target = new BearerConnectionTarget({
321 environmentId: ENVIRONMENT_ID,
322 label: "Saved",
323 connectionId: "saved-1",
324 });
325 const profile = new BearerConnectionProfile({
326 connectionId: "saved-1",
327 environmentId: ENVIRONMENT_ID,
328 label: "Saved",
329 httpBaseUrl: ENDPOINT.httpBaseUrl,
330 wsBaseUrl: ENDPOINT.wsBaseUrl,
331 });
332 const layerBroker = yield* makeDependencies({
333 credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]],
334 authorizeBearer: (input) =>
335 Ref.update(bearerInputs, (values) => [
336 ...values,
337 { token: input.bearerToken, method: input.connectionMethod },
338 ]).pipe(
339 Effect.as({
340 environmentId: input.expectedEnvironmentId,
341 label: "Saved",
342 httpBaseUrl: input.httpBaseUrl,
343 socketUrl: "wss://environment.example.test/ws?wsTicket=ticket",
344 httpAuthorization: {
345 _tag: "Bearer" as const,
346 token: input.bearerToken,
347 },
348 }),
349 ),
350 });
351 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
352
353 expect(
354 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
355 ).toContain("wsTicket=ticket");
356 expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "secret-bearer", method: "direct" }]);
357 }),
358 );
359
360 it.effect("prepares relay connections with the authorized endpoint and credentials", () =>
361 Effect.gen(function* () {
362 const target = new RelayConnectionTarget({
363 environmentId: ENVIRONMENT_ID,
364 label: "Cloud",
365 });
366 const layerBroker = yield* makeDependencies();
367 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
368
369 expect(yield* broker.prepare(catalogEntry(target))).toEqual({
370 environmentId: ENVIRONMENT_ID,
371 label: "Authorized relay environment",
372 httpBaseUrl: ENDPOINT.httpBaseUrl,
373 socketUrl: `wss://authorized.example.test/ws?wsTicket=dpop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`,
374 httpAuthorization: {
375 _tag: "Dpop",
376 accessToken: "dpop-access-token",
377 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
378 },
379 target,
380 });
381 }),
382 );
383
384 it.effect("records the complete relay authorization flow in local tracing", () =>
385 Effect.gen(function* () {
386 const userSpans: Array<string> = [];
387 const target = new RelayConnectionTarget({
388 environmentId: ENVIRONMENT_ID,
389 label: "Cloud",
390 });
391 const layerBroker = yield* makeDependencies({
392 authorizeDpop: (input) =>
393 Effect.succeed({
394 environmentId: input.expectedEnvironmentId,
395 label: "Cloud",
396 httpBaseUrl: ENDPOINT.httpBaseUrl,
397 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",
398 httpAuthorization: {
399 _tag: "Dpop" as const,
400 accessToken: "dpop-access-token",
401 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
402 },
403 }).pipe(Effect.withSpan("test.remote.authorizeDpop")),
404 });
405 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
406
407 yield* broker
408 .prepare(catalogEntry(target))
409 .pipe(Effect.withTracer(collectingTracer(userSpans)));
410
411 expect(userSpans).toContain("clientRuntime.connection.broker.relay");
412 expect(userSpans).toContain("test.remote.authorizeDpop");
413 expect(userSpans).toContain("clientRuntime.connection.broker.prepare");
414 }),
415 );
416
417 it.effect("delegates SSH launch to the platform gateway before remote authorization", () =>
418 Effect.gen(function* () {
419 const preparedTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);
420 const connectionMethods = yield* Ref.make<ReadonlyArray<string>>([]);
421 const target = new SshConnectionTarget({
422 environmentId: ENVIRONMENT_ID,
423 label: "SSH",
424 connectionId: "ssh-1",
425 });
426 const profile = new SshConnectionProfile({
427 connectionId: "ssh-1",
428 environmentId: ENVIRONMENT_ID,
429 label: "SSH",
430 target: SSH_TARGET,
431 });
432 const layerBroker = yield* makeDependencies({
433 prepareSsh: (input) =>
434 Ref.update(preparedTargets, (values) => [...values, input.target]).pipe(
435 Effect.as({
436 bootstrap: {
437 target: input.target,
438 httpBaseUrl: "http://127.0.0.1:4010",
439 wsBaseUrl: "ws://127.0.0.1:4010",
440 pairingToken: null,
441 },
442 bearerToken: "ssh-bearer",
443 }),
444 ),
445 authorizeBearer: (input) =>
446 Ref.update(connectionMethods, (methods) => [...methods, input.connectionMethod]).pipe(
447 Effect.as({
448 environmentId: input.expectedEnvironmentId,
449 label: "SSH",
450 httpBaseUrl: input.httpBaseUrl,
451 socketUrl: "wss://environment.example.test/ws?wsTicket=bearer",
452 httpAuthorization: {
453 _tag: "Bearer" as const,
454 token: input.bearerToken,
455 },
456 }),
457 ),
458 });
459 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
460
461 expect(
462 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
463 ).toContain("wsTicket=bearer");
464 expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]);
465 expect(yield* Ref.get(connectionMethods)).toEqual(["ssh"]);
466 }),
467 );
468
469 it.effect.each(["unchanged", "changed", "revocation-failed"] as const)(
470 "handles %s SSH routing consent before saving or authorizing",
471 (scenario) =>
472 Effect.gen(function* () {
473 const calls: string[] = [];
474 const target = new SshConnectionTarget({
475 environmentId: ENVIRONMENT_ID,
476 label: "SSH",
477 connectionId: "ssh-1",
478 });
479 const profile = new SshConnectionProfile({
480 connectionId: target.connectionId,
481 environmentId: ENVIRONMENT_ID,
482 label: "SSH",
483 target: SSH_TARGET,
484 });
485 const entry = catalogEntry(target, Option.some(profile));
486 const preparedTarget =
487 scenario === "unchanged"
488 ? SSH_TARGET
489 : { ...SSH_TARGET, hostname: "replacement.example.test" };
490 const failure = new ConnectionTransientError({
491 reason: "remote-unavailable",
492 detail: "Could not persist routing consent.",
493 });
494 const permissions = yield* makeGitHubRoutingPermissions({
495 read: Effect.succeed([
496 {
497 environmentId: ENVIRONMENT_ID,
498 connectionKey: gitHubRoutingConnectionKey(entry)!,
499 permission: "read-write",
500 },
501 ]),
502 write: () =>
503 Effect.sync(() => {
504 calls.push("revoke");
505 }).pipe(
506 Effect.andThen(scenario === "revocation-failed" ? Effect.fail(failure) : Effect.void),
507 ),
508 });
509 let savedProfile: ConnectionProfile = profile;
510 const layerBroker = yield* makeDependencies({
511 profileStore: {
512 get: () => Effect.sync(() => Option.some(savedProfile)),
513 put: (value) =>
514 Effect.sync(() => {
515 calls.push("profile");
516 savedProfile = value;
517 }),
518 remove: () => Effect.die("unused"),
519 },
520 prepareSsh: () =>
521 Effect.succeed({
522 bootstrap: {
523 target: preparedTarget,
524 httpBaseUrl: "http://127.0.0.1:4010",
525 wsBaseUrl: "ws://127.0.0.1:4010",
526 pairingToken: null,
527 },
528 bearerToken: "ssh-bearer",
529 }),
530 authorizeBearer: (input) =>
531 Effect.sync(() => {
532 calls.push("authorize");
533 return {
534 environmentId: input.expectedEnvironmentId,
535 label: "SSH",
536 httpBaseUrl: input.httpBaseUrl,
537 socketUrl: "ws://127.0.0.1:4010/ws?wsTicket=ssh",
538 httpAuthorization: { _tag: "Bearer" as const, token: input.bearerToken },
539 };
540 }),
541 });
542 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(
543 Effect.provide(layerBroker),
544 );
545 const prepare = broker
546 .prepare(entry)
547 .pipe(Effect.provideService(GitHubRoutingPermissions, permissions));
548 if (scenario === "revocation-failed") {
549 expect(yield* Effect.flip(prepare)).toBe(failure);
550 expect(savedProfile).toBe(profile);
551 expect(calls).toEqual(["revoke"]);
552 } else {
553 expect((yield* prepare).socketUrl).toContain("wsTicket=ssh");
554 expect(savedProfile).toMatchObject({ target: preparedTarget });
555 expect(calls).toEqual(
556 scenario === "unchanged"
557 ? ["profile", "authorize"]
558 : ["revoke", "profile", "authorize"],
559 );
560 }
561 expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write");
562 }),
563 );
564
565 it.effect("preserves relay authorization failure classification and trace details", () =>
566 Effect.gen(function* () {
567 const target = new RelayConnectionTarget({
568 environmentId: ENVIRONMENT_ID,
569 label: "Cloud",
570 });
571 const authorizationError = new ConnectionTransientError({
572 reason: "timeout",
573 detail: "Relay environment connection timed out.",
574 traceId: "relay-trace",
575 });
576 const layerBroker = yield* makeDependencies({
577 authorizeDpop: () => Effect.fail(authorizationError),
578 });
579 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker));
580 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
581
582 expect(error).toBe(authorizationError);
583 }),
584 );
585});