1/**
2 * Credentials for the Device panel's media requests.
3 *
4 * The panel reaches simulator streams through `/api/device-hub/*` on the
5 * environment origin. `<img>`, `EventSource`, and `WebSocket` cannot set
6 * bearer or DPoP headers, so bearer and DPoP connections mint a
7 * short-lived WebSocket ticket and pass it as `wsTicket`, the same way the
8 * app's own `/ws` upgrade authenticates. Cookie sessions send the cookie.
9 *
10 * A ticket lives five minutes server-side and is bound to the session, not
11 * to one request, so one ticket covers everything a panel opens at once.
12 * Callers fetch a fresh one each time they (re)connect a stream.
13 */
14import * as Effect from "effect/Effect";
15import type { HttpClient } from "effect/http";
16
17import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
18import type { PreparedConnection } from "../connection/model.ts";
19import { environmentEndpointUrl } from "../environment/endpoint.ts";
20import * as ManagedRelay from "../relay/managedRelay.ts";
21import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
22import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
23import type { DeviceHubAccess } from "../device/hubAccess.ts";
24
25export { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts";
26
27const TICKET_TIMEOUT_MS = 8_000;
28
29export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")(
30 function* (input: {
31 readonly prepared: PreparedConnection;
32 readonly hubBasePath: string;
33 }): Effect.fn.Return<DeviceHubAccess, RemoteEnvironmentRequestError, HttpClient.HttpClient> {
34 const httpBase = environmentEndpointUrl(input.prepared.httpBaseUrl, input.hubBasePath);
35 const wsBase = httpBase.replace(/^http/, "ws");
36 if (input.prepared.httpAuthorization === null) {
37 return { httpBase, wsBase, query: {}, credentials: true };
38 }
39 const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
40 const remoteAuthorization = yield* Effect.serviceOption(
41 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
42 );
43 const ticket = yield* executeAuthenticatedEnvironmentHttpRequest({
44 prepared: input.prepared,
45 signer,
46 remoteAuthorization,
47 group: "auth",
48 method: "POST",
49 url: (urls) => urls.webSocketTicket(),
50 timeoutMs: TICKET_TIMEOUT_MS,
51 request: ({ client, headers }) => client.webSocketTicket({ headers }),
52 });
53 return {
54 httpBase,
55 wsBase,
56 query: { wsTicket: ticket.ticket },
57 credentials: false,
58 };
59 },
60);