Pairing needs a third service we never deployed. Hosts run the ordinary local vibe-kanban server (the `server` crate, shipped as `npx vibe-kanban`); it registers with a relay over a websocket control channel, and the browser reaches the host's local API through that relay over WebRTC. The remote server we deploy contains none of it -- its hosts.rs is a single read-only GET /hosts -- so the UI could list hosts and never pair one. Mount it on a path of the existing name (https://kanban.internal/relay-api/) rather than giving it a domain. Every relay URL on both sides is composed as {base}/v1/..., the host turns the base into wss:// by stripping only the scheme, and RelayServerConfig is just database_url/listen_addr/jwt_secret with no notion of its own public address and no redirects -- so a prefix is invisible to it. That buys same-origin (no CORS), one less certificate and one less renewal timer, and when kanban.l4ir.net lands it inherits the relay by copying one nginx location block. For that second hostname to work from the SAME build, the SPA's relay base is origin-relative ("/relay-api") rather than an absolute URL baked at build time. Two consequences: - The previous empty value was not "relay disabled", as the comment inherited from lair/containers claimed. Empty makes Bootstrap.tsx fall back to window.location.origin, aiming relay calls at the remote API, which does not serve them. Comment corrected. - fetch() paths take a relative base fine (plain concatenation), but relayHostApi built its websocket by string-replacing http->ws, which silently leaves a relative URL that the WebSocket constructor rejects. It now uses openBrowserWebSocket, already imported in that file, which resolves wss://, https:// and relative alike. The relay shares the remote's JWT secret -- that is how it trusts tokens the remote issued -- so both read /etc/vibe-kanban/env.
169 lines
4.6 KiB
TypeScript
169 lines
4.6 KiB
TypeScript
import {
|
|
invalidateRemoteSessionId,
|
|
resolveRemoteHostContext,
|
|
tryRefreshRelayHostSigningSession,
|
|
} from "@remote/shared/lib/relay/context";
|
|
import { getActiveRelayHostId } from "@remote/shared/lib/relay/activeHostContext";
|
|
import {
|
|
isAuthFailureStatus,
|
|
sendRelayHostRequest,
|
|
} from "@remote/shared/lib/relay/http";
|
|
import {
|
|
isWorkspaceRoutePath,
|
|
normalizePath,
|
|
openBrowserWebSocket,
|
|
resolveRelayHostIdForCurrentPage,
|
|
shouldRelayApiPath,
|
|
toPathAndQuery,
|
|
} from "@remote/shared/lib/relay/routing";
|
|
import {
|
|
appendSignatureToPath,
|
|
buildRelaySignature,
|
|
normalizeRequestBody,
|
|
} from "@remote/shared/lib/relay/signing";
|
|
import {
|
|
createRelaySignedWebSocket,
|
|
createRelayWsSigningContext,
|
|
} from "@remote/shared/lib/relay/ws";
|
|
import { buildRemoteSessionBaseUrl } from "@/shared/lib/relayBackendApi";
|
|
import type {
|
|
LocalApiRequestOptions,
|
|
LocalApiWebSocketOptions,
|
|
} from "@/shared/lib/localApiTransport";
|
|
|
|
const EMPTY_BYTES = new Uint8Array();
|
|
|
|
export { isWorkspaceRoutePath };
|
|
|
|
export async function requestLocalApiViaRelay(
|
|
pathOrUrl: string,
|
|
requestInit: LocalApiRequestOptions = {},
|
|
): Promise<Response> {
|
|
const pathAndQuery = toPathAndQuery(pathOrUrl);
|
|
const {
|
|
relayHostId,
|
|
hostId: _hostId,
|
|
hostScope: _hostScope,
|
|
...relayRequestInit
|
|
} = requestInit;
|
|
|
|
if (!shouldRelayApiPath(pathAndQuery)) {
|
|
return fetch(pathOrUrl, relayRequestInit);
|
|
}
|
|
|
|
const hostId =
|
|
relayHostId ?? resolveRelayHostIdForCurrentPage() ?? getActiveRelayHostId();
|
|
if (!hostId) {
|
|
throw new Error(
|
|
"Host context is required for local API requests. Navigate under /hosts/{hostId}/...",
|
|
);
|
|
}
|
|
|
|
return requestRelayHostApi(hostId, pathAndQuery, relayRequestInit);
|
|
}
|
|
|
|
export async function openLocalApiWebSocketViaRelay(
|
|
pathOrUrl: string,
|
|
options: LocalApiWebSocketOptions = {},
|
|
): Promise<WebSocket> {
|
|
const pathAndQuery = toPathAndQuery(pathOrUrl);
|
|
|
|
if (!shouldRelayApiPath(pathAndQuery)) {
|
|
return openBrowserWebSocket(pathOrUrl);
|
|
}
|
|
|
|
const hostId =
|
|
options.relayHostId ??
|
|
resolveRelayHostIdForCurrentPage() ??
|
|
getActiveRelayHostId();
|
|
if (!hostId) {
|
|
throw new Error(
|
|
"Host context is required for local API WebSocket requests. Navigate under /hosts/{hostId}/...",
|
|
);
|
|
}
|
|
|
|
return openRelayHostWebSocket(hostId, pathAndQuery);
|
|
}
|
|
|
|
export async function requestRelayHostApi(
|
|
hostId: string,
|
|
pathOrUrl: string,
|
|
requestInit: RequestInit = {},
|
|
): Promise<Response> {
|
|
const pathAndQuery = toPathAndQuery(pathOrUrl);
|
|
const normalizedPath = normalizePath(pathAndQuery);
|
|
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
|
|
const { body, bodyBytes, contentType } = await normalizeRequestBody(
|
|
requestInit.body,
|
|
);
|
|
|
|
const context = await resolveRemoteHostContext(hostId);
|
|
const initialResponse = await sendRelayHostRequest(context, {
|
|
normalizedPath,
|
|
method,
|
|
body,
|
|
bodyBytes,
|
|
contentType,
|
|
requestInit,
|
|
});
|
|
if (!isAuthFailureStatus(initialResponse.status)) {
|
|
return initialResponse;
|
|
}
|
|
|
|
invalidateRemoteSessionId(hostId);
|
|
const refreshedContext = await tryRefreshRelayHostSigningSession(context);
|
|
if (!refreshedContext) {
|
|
return initialResponse;
|
|
}
|
|
|
|
const retryResponse = await sendRelayHostRequest(refreshedContext, {
|
|
normalizedPath,
|
|
method,
|
|
body,
|
|
bodyBytes,
|
|
contentType,
|
|
requestInit,
|
|
});
|
|
if (isAuthFailureStatus(retryResponse.status)) {
|
|
invalidateRemoteSessionId(hostId);
|
|
}
|
|
|
|
return retryResponse;
|
|
}
|
|
|
|
export async function openRelayHostWebSocket(
|
|
hostId: string,
|
|
pathOrUrl: string,
|
|
): Promise<WebSocket> {
|
|
const baseContext = await resolveRemoteHostContext(hostId);
|
|
const context =
|
|
(await tryRefreshRelayHostSigningSession(baseContext)) ?? baseContext;
|
|
const pathAndQuery = toPathAndQuery(pathOrUrl);
|
|
const normalizedPath = normalizePath(pathAndQuery);
|
|
|
|
const signature = await buildRelaySignature(
|
|
context.pairedHost,
|
|
"GET",
|
|
normalizedPath,
|
|
EMPTY_BYTES,
|
|
);
|
|
const base_url = buildRemoteSessionBaseUrl(
|
|
context.pairedHost.host_id,
|
|
context.sessionId,
|
|
);
|
|
|
|
const signedPath = appendSignatureToPath(normalizedPath, signature);
|
|
// lair fork: the relay base may be origin-relative (e.g. "/relay-api") so one
|
|
// build can be served from more than one hostname. String-replacing http->ws
|
|
// silently leaves a relative URL, which the WebSocket constructor rejects.
|
|
// openBrowserWebSocket already resolves all three forms and is imported above.
|
|
const wsUrl = `${base_url}${signedPath}`;
|
|
|
|
const signingContext = await createRelayWsSigningContext(
|
|
context.pairedHost,
|
|
signature,
|
|
);
|
|
return createRelaySignedWebSocket(openBrowserWebSocket(wsUrl), signingContext);
|
|
}
|