/** * Credentials for the Device panel's media requests. * * The panel reaches simulator streams through `/api/device-hub/*` on the * environment origin. `<img>`, `EventSource`, and `WebSocket` cannot set * bearer or DPoP headers, so bearer and DPoP connections mint a * short-lived WebSocket ticket and pass it as `wsTicket`, the same way the * app's own `/ws` upgrade authenticates. Cookie sessions send the cookie. * * A ticket lives five minutes server-side and is bound to the session, not * to one request, so one ticket covers everything a panel opens at once. * Callers fetch a fresh one each time they (re)connect a stream. */import * as Effect from "effect/Effect";import type { HttpClient } from "effect/unstable/http";import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";import type { PreparedConnection } from "../connection/model.ts";import { environmentEndpointUrl } from "../environment/endpoint.ts";import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";const TICKET_TIMEOUT_MS = 8_000;export interface DeviceHubAccess { /** Absolute origin-relative base, e.g. `https://env.example/api/device-hub`. */ readonly httpBase: string; /** Same base with the `ws(s)` scheme. */ readonly wsBase: string; /** Query parameters to append to every hub request; empty for cookie sessions. */ readonly query: Readonly<Record<string, string>>; /** Whether requests must include cookies (same-origin session). */ readonly credentials: boolean;}export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")( function* (input: { readonly prepared: PreparedConnection; readonly hubBasePath: string; }): Effect.fn.Return<DeviceHubAccess, RemoteEnvironmentRequestError, HttpClient.HttpClient> { const httpBase = environmentEndpointUrl(input.prepared.httpBaseUrl, input.hubBasePath); const wsBase = httpBase.replace(/^http/, "ws"); if (input.prepared.httpAuthorization === null) { return { httpBase, wsBase, query: {}, credentials: true }; } const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); const ticket = yield* executeAuthenticatedEnvironmentHttpRequest({ prepared: input.prepared, signer, remoteAuthorization, method: "POST", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"), timeoutMs: TICKET_TIMEOUT_MS, request: ({ client, headers }) => client.auth.webSocketTicket({ headers }), }); return { httpBase, wsBase, query: { wsTicket: ticket.ticket }, credentials: false, }; },);export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => { const entries = Object.entries(access.query); if (entries.length === 0) return url; const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}${new URLSearchParams(entries).toString()}`;};
/**
* Credentials for the Device panel's media requests.
*
* The panel reaches simulator streams through `/api/device-hub/*` on the
* environment origin. `<img>`, `EventSource`, and `WebSocket` cannot set
* bearer or DPoP headers, so bearer and DPoP connections mint a
* short-lived WebSocket ticket and pass it as `wsTicket`, the same way the
* app's own `/ws` upgrade authenticates. Cookie sessions send the cookie.
*
* A ticket lives five minutes server-side and is bound to the session, not
* to one request, so one ticket covers everything a panel opens at once.
* Callers fetch a fresh one each time they (re)connect a stream.
*/
import * as Effect from "effect/Effect";
import type { HttpClient } from "effect/unstable/http";
import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
import type { PreparedConnection } from "../connection/model.ts";
import { environmentEndpointUrl } from "../environment/endpoint.ts";
import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
const TICKET_TIMEOUT_MS = 8_000;
export interface DeviceHubAccess {
/** Absolute origin-relative base, e.g. `https://env.example/api/device-hub`. */
readonly httpBase: string;
/** Same base with the `ws(s)` scheme. */
readonly wsBase: string;
/** Query parameters to append to every hub request; empty for cookie sessions. */
readonly query: Readonly<Record<string, string>>;
/** Whether requests must include cookies (same-origin session). */
readonly credentials: boolean;
}
export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")(
function* (input: {
readonly prepared: PreparedConnection;
readonly hubBasePath: string;
}): Effect.fn.Return<DeviceHubAccess, RemoteEnvironmentRequestError, HttpClient.HttpClient> {
const httpBase = environmentEndpointUrl(input.prepared.httpBaseUrl, input.hubBasePath);
const wsBase = httpBase.replace(/^http/, "ws");
if (input.prepared.httpAuthorization === null) {
return { httpBase, wsBase, query: {}, credentials: true };
}
const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
const ticket = yield* executeAuthenticatedEnvironmentHttpRequest({
prepared: input.prepared,
signer,
remoteAuthorization,
method: "POST",
url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"),
timeoutMs: TICKET_TIMEOUT_MS,
request: ({ client, headers }) => client.auth.webSocketTicket({ headers }),
});
return {
httpBase,
wsBase,
query: { wsTicket: ticket.ticket },
credentials: false,
};
},
);
export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => {
const entries = Object.entries(access.query);
if (entries.length === 0) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}${new URLSearchParams(entries).toString()}`;
};