| 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 | */ |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import type { HttpClient } from "effect/http"; |
| 16 | |
| 17 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 18 | import type { PreparedConnection } from "../connection/model.ts"; |
| 19 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 20 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 21 | import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; |
| 22 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; |
| 23 | import type { DeviceHubAccess } from "../device/hubAccess.ts"; |
| 24 | |
| 25 | export { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts"; |
| 26 | |
| 27 | const TICKET_TIMEOUT_MS = 8_000; |
| 28 | |
| 29 | export 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 | ); |