fix(session-sharing): use the linked server for desktop meetings and links (#1507)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:54:23 -05:00
1 parent 9801cc7fc7
commit ec7c2c2835
15 files changed
+621 -210

No files matched your search

@@ -118,12 +118,14 @@ function RemoteDesktopStandalone({ hostId, view }: StandaloneViewProps) {
/** What collab rooms and shared-session links draw a stream with. */
function RemoteDisplay({
token,
connectionOrigin,
protocol,
isVisible,
onConnect,
onError,
}: {
token: string;
connectionOrigin?: "local" | "remote";
protocol: Protocol;
isVisible: boolean;
onConnect?: () => void;
@@ -131,7 +133,7 @@ function RemoteDisplay({
}) {
return (
<GuacamoleDisplay
connectionConfig={{ token, protocol, type: protocol }}
connectionConfig={{ token, protocol, type: protocol, connectionOrigin }}
isVisible={isVisible}
onConnect={onConnect}
onError={onError}
+4 -1
View File
@@ -132,7 +132,10 @@
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
"created": "Room created",
"invited": "Members invited",
"roomTab": "Meeting"
"roomTab": "Meeting",
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
"hostNotSynced": "Sync this host to the meeting server before presenting.",
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
},
"nav": {
"collab": "Meetings"
@@ -520,6 +520,17 @@ export function createDirectory(
return !!rows[0]?.isAdmin;
},
async hostIdsBySyncId(syncId: string): Promise<number[]> {
const { hosts } = await refs();
const rows = await (
await client()
)
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.syncId, syncId));
return rows.map((row: { id: number }) => row.id);
},
async hostExists(hostId: number): Promise<boolean> {
const { hosts } = await refs();
const rows = await (
@@ -200,6 +200,32 @@ export function registerRoomRoutes(
} = actions;
const requireUse = ctx.rbac.require("use") as never;
router.get(
"/meeting-host/:syncId",
requireUse,
async (req: Request, res: Response) => {
if (!ctx.currentActor())
return res.status(401).json({ error: "Authentication required" });
try {
for (const id of await directory.hostIdsBySyncId(
String(req.params.syncId),
)) {
if ((await ctx.hosts.checkAccess(id, "connect")).hasAccess) {
return res.json({ id });
}
}
return res.status(404).json({
error:
"The host must be synced to the meeting server before presenting",
});
} catch {
return res
.status(500)
.json({ error: "Failed to resolve meeting host" });
}
},
);
const audit = (
req: Request,
action: string,
@@ -12,15 +12,19 @@ import {
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
import type { CollabRoom } from "./api";
import type { MeetingBackend } from "./meeting-backend";
import { getErrorMessage } from "./shared";
export function CollabPanel({
onOpenRoom,
backend,
}: {
onOpenRoom: (room: CollabRoom) => void;
backend: MeetingBackend;
}) {
const { t } = useTranslation();
const { createCollabRoom, listCollabRooms } = backend.api;
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
@@ -37,7 +41,7 @@ export function CollabPanel({
} finally {
setLoading(false);
}
}, []);
}, [backend]);
useEffect(() => {
void refresh();
@@ -34,36 +34,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@termix/plugin-sdk/ui";
import {
Input,
PluginComponent,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import { Input, PluginComponent } from "@termix/plugin-sdk/ui";
import { CollabMembersSidebar } from "./CollabMembersSidebar";
import {
RemoteDisplay,
createRemoteSessionToken,
getErrorMessage,
wsUrlForPath,
wsTargetForPath,
} from "./shared";
import {
deleteCollabRoom,
endCollabRoom,
dismissCollabControlRequest,
getCollabRoom,
getCollabStage,
inviteCollabMembers,
presentCollabStage,
requestCollabStageControl,
removeCollabMember,
setCollabGuestLink,
setCollabStageControl,
stopCollabStage,
getDirectory,
type CollabRoomDetail,
type CollabStage,
type DirectoryRole,
} from "./api";
import type { CollabRoomDetail, CollabStage, DirectoryRole } from "./api";
import { meetingGuestUrl, type MeetingBackend } from "./meeting-backend";
type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet";
@@ -84,9 +64,10 @@ const POLL_FALLBACK_MS = 15000;
// The terminal socket the ssh-terminal plugin serves. Authentication rides on
// the jwt cookie the way every terminal WS connection does.
async function roomEventsWsUrl(roomId: string): Promise<string | null> {
const { eventsWsPath } = await getCollabRoom(roomId);
return wsUrlForPath(eventsWsPath);
async function roomEventsWsUrl(roomId: string, backend: MeetingBackend) {
const { eventsWsPath } = await backend.api.getCollabRoom(roomId);
await backend.assertCurrent();
return wsTargetForPath(eventsWsPath, backend.origin);
}
type PresentDraft =
@@ -105,11 +86,28 @@ type PresentChoice = {
export function CollabRoomTab({
roomId,
isVisible,
backend,
}: {
backend: MeetingBackend;
roomId?: string;
isVisible: boolean;
}) {
const { t } = useTranslation();
const {
deleteCollabRoom,
endCollabRoom,
dismissCollabControlRequest,
getCollabRoom,
getCollabStage,
inviteCollabMembers,
presentCollabStage,
requestCollabStageControl,
removeCollabMember,
setCollabGuestLink,
setCollabStageControl,
stopCollabStage,
getDirectory,
} = backend.api;
const [detail, setDetail] = useState<CollabRoomDetail | null>(null);
const [stage, setStage] = useState<CollabStage | null>(null);
const [ended, setEnded] = useState(false);
@@ -190,7 +188,7 @@ export function CollabRoomTab({
setLoadError(getErrorMessage(error));
}
}
}, [roomId]);
}, [roomId, backend]);
useEffect(() => {
void refresh();
@@ -216,10 +214,10 @@ export function CollabRoomTab({
const connect = async () => {
if (cancelled) return;
try {
const url = await roomEventsWsUrl(roomId);
const url = await roomEventsWsUrl(roomId, backend);
if (cancelled) return;
if (!url) throw new Error("No terminal endpoint is available");
ws = new WebSocket(url);
ws = new WebSocket(url.url, url.protocols);
} catch {
startPolling();
reconnectTimer = setTimeout(() => void connect(), 5000);
@@ -359,19 +357,21 @@ export function CollabRoomTab({
if (!roomId) return;
setPresentOpen(false);
try {
await backend.assertCurrent();
if (backend.origin === "remote") {
if (!host.syncId) throw new Error(t("collab.hostNotSynced"));
const resolved = await backend.api.resolveHost(host.syncId);
host = { ...host, id: String(resolved.id), connectionOrigin: "remote" };
} else {
host = { ...host, connectionOrigin: "local" };
}
if (protocol === "ssh") {
setDraft({ protocol, host });
return;
}
const response = await createRemoteSessionToken(
Number(host.id),
await resolveConnectionOrigin(
{
connectionOrigin: host.connectionOrigin as
"local" | "remote" | undefined,
},
{ defaultRemote: true },
),
backend.origin,
protocol,
);
if (!response?.connectionId) {
@@ -465,8 +465,16 @@ export function CollabRoomTab({
}
}
function guestLinkUrl(token: string) {
return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`;
async function copyGuestLink(token: string) {
try {
await backend.assertCurrent();
const url = meetingGuestUrl(backend.publicUrl, token);
if (!url) throw new Error(t("collab.linkRequiresServer"));
await navigator.clipboard.writeText(url);
toast.success(t("collab.linkCopied"));
} catch (error) {
toast.error(getErrorMessage(error));
}
}
async function handleInvite() {
@@ -648,10 +656,7 @@ export function CollabRoomTab({
variant="outline"
className="h-8 text-xs"
onClick={() => {
void navigator.clipboard
.writeText(guestLinkUrl(guestLinkToken))
.then(() => toast.success(t("collab.linkCopied")))
.catch((error) => toast.error(getErrorMessage(error)));
void copyGuestLink(guestLinkToken);
}}
>
{t("collab.copyLink")}
@@ -706,6 +711,7 @@ export function CollabRoomTab({
/>
) : (
<RemoteDisplay
connectionOrigin={backend.origin}
token={draft.token}
protocol={draft.protocol}
isVisible={isVisible}
@@ -734,6 +740,7 @@ export function CollabRoomTab({
id="terminal.view"
hostConfig={{
id: stage.hostId ?? undefined,
connectionOrigin: backend.origin,
name: detail?.room.name ?? "stage",
ip: "",
port: 0,
@@ -748,6 +755,7 @@ export function CollabRoomTab({
/>
) : stage.connectParams?.token ? (
<RemoteDisplay
connectionOrigin={backend.origin}
key={stage.connectParams.token}
token={stage.connectParams.token}
protocol={stage.protocol}
-142
View File
@@ -249,148 +249,6 @@ export interface CollabRoomDetail {
eventsWsPath: string;
}
const room = (roomId: string, suffix = "") =>
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
export function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
return call(
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
"Failed to list rooms",
);
}
export function createCollabRoom(
name: string,
persistent: boolean,
): Promise<{ room: CollabRoom }> {
return call(
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
"Failed to create room",
);
}
export function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
return call(
() => api().get<CollabRoomDetail>(room(roomId)),
"Failed to get room",
);
}
export async function inviteCollabMembers(
roomId: string,
targets: { userIds?: string[]; roleIds?: number[] },
): Promise<void> {
await call(
() => api().post(room(roomId, "/members"), targets),
"Failed to invite members",
);
}
export async function removeCollabMember(
roomId: string,
userId: string,
): Promise<void> {
await call(
() => api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
"Failed to remove member",
);
}
export function presentCollabStage(
roomId: string,
input: { protocol: string; sessionId: string; hostId: number },
): Promise<{ stage: CollabStage }> {
return call(
() => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input),
"Failed to start presenting",
);
}
export async function stopCollabStage(roomId: string): Promise<void> {
await call(
() => api().post(room(roomId, "/stop")),
"Failed to stop presenting",
);
}
export function getCollabStage(
roomId: string,
): Promise<{ stage: CollabStage | null }> {
return call(
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
"Failed to resolve stage",
);
}
export async function setCollabStageControl(
roomId: string,
userId: string | null,
): Promise<void> {
await call(
() => api().post(room(roomId, "/control"), { userId }),
"Failed to change stage control",
);
}
export function requestCollabStageControl(
roomId: string,
): Promise<{ request: CollabControlRequest }> {
return call(
() =>
api().post<{ request: CollabControlRequest }>(
room(roomId, "/control/request"),
),
"Failed to request control",
);
}
export function listCollabControlRequests(
roomId: string,
): Promise<{ requests: CollabControlRequest[] }> {
return call(
() =>
api().get<{ requests: CollabControlRequest[] }>(
room(roomId, "/control/requests"),
),
"Failed to list control requests",
);
}
export async function dismissCollabControlRequest(
roomId: string,
userId: string,
): Promise<void> {
await call(
() =>
api().delete(
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
),
"Failed to dismiss control request",
);
}
export async function endCollabRoom(roomId: string): Promise<void> {
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
}
export async function deleteCollabRoom(roomId: string): Promise<void> {
await call(() => api().delete(room(roomId)), "Failed to delete room");
}
export function setCollabGuestLink(
roomId: string,
enabled: boolean,
): Promise<{ guestLinkToken: string | null }> {
return call(
() =>
api().post<{ guestLinkToken: string | null }>(
room(roomId, "/guest-link"),
{ enabled },
),
"Failed to update guest link",
);
}
export interface CollabGuestStage {
protocol: "ssh" | "rdp" | "vnc" | "telnet";
shareId: string;
+106 -16
View File
@@ -1,6 +1,12 @@
// The terminal surfaces need xterm's own stylesheet.
import "@xterm/xterm/css/xterm.css";
import { useEffect, type ComponentType } from "react";
import {
useEffect,
useRef,
useState,
type ReactNode,
type ComponentType,
} from "react";
import { Presentation, Share2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -11,7 +17,7 @@ import {
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { bindApi, listCollabRooms } from "./api";
import { bindApi } from "./api";
import { SharedWithMeSection } from "./SharedWithMeSection";
import CollabGuestView from "./CollabGuestView";
import { CollabPanel } from "./CollabPanel";
@@ -26,6 +32,73 @@ import {
} from "./share-dialog";
import { REMOTE_DISPLAY_SLOT } from "./shared";
import { connectMeetings, type MeetingBackend } from "./meeting-backend";
let meetingApp: TermixApp;
function MeetingScope({
children,
serverKey,
pin = false,
}: {
children: (backend: MeetingBackend) => ReactNode;
serverKey?: string;
pin?: boolean;
}) {
const [backend, setBackend] = useState<MeetingBackend | null>(null);
const [error, setError] = useState<string | null>(null);
const pinned = useRef(serverKey);
useEffect(() => {
let cancelled = false;
let sequence = 0;
const refresh = async () => {
const request = ++sequence;
try {
const next = await connectMeetings(meetingApp);
if (cancelled || request !== sequence) return;
if (
pin &&
pinned.current !== undefined &&
pinned.current !== next.key
) {
setBackend(null);
setError(meetingApp.t("collab.serverChanged"));
return;
}
if (pin) pinned.current ??= next.key;
setError(null);
setBackend((previous) =>
previous?.key === next.key ? previous : next,
);
} catch (failure) {
if (!cancelled) {
setBackend(null);
setError(String(failure));
}
}
};
void refresh();
const dispose = meetingApp.desktop.onRemoteServerChange(() => {
void refresh();
});
return () => {
cancelled = true;
dispose();
};
}, [serverKey, pin]);
if (error)
return (
<p role="alert" className="p-3 text-sm">
{error}
</p>
);
return backend ? (
<div key={backend.key} className="contents">
{children(backend)}
</div>
) : null;
}
const COLLAB = "collab";
const SHARE_ACTION = "session-sharing.share";
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
@@ -38,25 +111,39 @@ function GuestView({ view }: StandaloneViewProps) {
function CollabRoomTabView({ tab, isVisible }: TabProps) {
const roomId = tab.data?.roomId;
const serverKey =
typeof tab.data?.meetingServerKey === "string"
? tab.data.meetingServerKey
: undefined;
return (
<CollabRoomTab
roomId={typeof roomId === "string" ? roomId : undefined}
isVisible={isVisible}
/>
<MeetingScope serverKey={serverKey} pin>
{(backend) => (
<CollabRoomTab
roomId={typeof roomId === "string" ? roomId : undefined}
isVisible={isVisible}
backend={backend}
/>
)}
</MeetingScope>
);
}
function CollabPanelView({ shell }: PanelProps) {
return (
<CollabPanel
onOpenRoom={(room) =>
shell.openTab(null, COLLAB, {
label: room.name,
forceNewTab: true,
data: { roomId: room.id },
})
}
/>
<MeetingScope>
{(backend) => (
<CollabPanel
backend={backend}
onOpenRoom={(room) =>
shell.openTab(null, COLLAB, {
label: room.name,
forceNewTab: true,
data: { roomId: room.id, meetingServerKey: backend.key },
})
}
/>
)}
</MeetingScope>
);
}
@@ -76,7 +163,9 @@ function createInviteWatcher(app: TermixApp): ComponentType {
let cancelled = false;
const check = async () => {
try {
const { rooms = [] } = await listCollabRooms();
const { rooms = [] } = await (
await connectMeetings(app)
).api.listCollabRooms();
if (cancelled) return;
let seen: string[] = [];
try {
@@ -142,6 +231,7 @@ function isRemoteDesktopContext(
}
export function activate(app: TermixApp): void {
meetingApp = app;
bindApi(app.api);
app.onDispose(() => {
closeShareDialog();
@@ -0,0 +1,208 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
CollabRoom,
CollabRoomDetail,
CollabStage,
CollabControlRequest,
DirectoryUser,
DirectoryRole,
} from "./api";
export function createMeetingApi(
client: PluginApiClient,
assertCurrent: () => Promise<void>,
) {
const api = () => client;
async function call<T>(
request: () => Promise<{ data: T }>,
fallback: string,
): Promise<T> {
await assertCurrent();
try {
return (await request()).data;
} catch (error) {
const response = error as { response?: { data?: { error?: string } } };
throw new Error(response.response?.data?.error || fallback);
}
}
const room = (roomId: string, suffix = "") =>
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
return call(
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
"Failed to list rooms",
);
}
function createCollabRoom(
name: string,
persistent: boolean,
): Promise<{ room: CollabRoom }> {
return call(
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
"Failed to create room",
);
}
function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
return call(
() => api().get<CollabRoomDetail>(room(roomId)),
"Failed to get room",
);
}
async function inviteCollabMembers(
roomId: string,
targets: { userIds?: string[]; roleIds?: number[] },
): Promise<void> {
await call(
() => api().post(room(roomId, "/members"), targets),
"Failed to invite members",
);
}
async function removeCollabMember(
roomId: string,
userId: string,
): Promise<void> {
await call(
() =>
api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
"Failed to remove member",
);
}
function presentCollabStage(
roomId: string,
input: { protocol: string; sessionId: string; hostId: number },
): Promise<{ stage: CollabStage }> {
return call(
() => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input),
"Failed to start presenting",
);
}
async function stopCollabStage(roomId: string): Promise<void> {
await call(
() => api().post(room(roomId, "/stop")),
"Failed to stop presenting",
);
}
function getCollabStage(
roomId: string,
): Promise<{ stage: CollabStage | null }> {
return call(
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
"Failed to resolve stage",
);
}
async function setCollabStageControl(
roomId: string,
userId: string | null,
): Promise<void> {
await call(
() => api().post(room(roomId, "/control"), { userId }),
"Failed to change stage control",
);
}
function requestCollabStageControl(
roomId: string,
): Promise<{ request: CollabControlRequest }> {
return call(
() =>
api().post<{ request: CollabControlRequest }>(
room(roomId, "/control/request"),
),
"Failed to request control",
);
}
function listCollabControlRequests(
roomId: string,
): Promise<{ requests: CollabControlRequest[] }> {
return call(
() =>
api().get<{ requests: CollabControlRequest[] }>(
room(roomId, "/control/requests"),
),
"Failed to list control requests",
);
}
async function dismissCollabControlRequest(
roomId: string,
userId: string,
): Promise<void> {
await call(
() =>
api().delete(
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
),
"Failed to dismiss control request",
);
}
async function endCollabRoom(roomId: string): Promise<void> {
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
}
async function deleteCollabRoom(roomId: string): Promise<void> {
await call(() => api().delete(room(roomId)), "Failed to delete room");
}
function setCollabGuestLink(
roomId: string,
enabled: boolean,
): Promise<{ guestLinkToken: string | null }> {
return call(
() =>
api().post<{ guestLinkToken: string | null }>(
room(roomId, "/guest-link"),
{ enabled },
),
"Failed to update guest link",
);
}
function getDirectory() {
return call(
() =>
client.get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
"/directory",
),
"Failed to list users and roles",
);
}
function resolveHost(syncId: string) {
return call(
() =>
client.get<{ id: number }>(
`/meeting-host/${encodeURIComponent(syncId)}`,
),
"The host must be synced to the meeting server before presenting",
);
}
return {
listCollabRooms,
createCollabRoom,
getCollabRoom,
inviteCollabMembers,
removeCollabMember,
presentCollabStage,
stopCollabStage,
getCollabStage,
setCollabStageControl,
requestCollabStageControl,
listCollabControlRequests,
dismissCollabControlRequest,
endCollabRoom,
deleteCollabRoom,
setCollabGuestLink,
getDirectory,
resolveHost,
};
}
@@ -0,0 +1,44 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { createMeetingApi } from "./meeting-api";
export type MeetingBackend = Awaited<ReturnType<typeof connectMeetings>>;
/** A room, its hosts and its sockets must all belong to the same backend. */
export async function connectMeetings(app: TermixApp) {
const remoteUrl = app.desktop.available
? await app.desktop.remoteServerUrl()
: null;
const key = remoteUrl?.replace(/\/+$/, "") || "local";
const origin = remoteUrl ? ("remote" as const) : ("local" as const);
const publicUrl =
remoteUrl || (!app.desktop.available ? window.location.href : null);
const assertCurrent = async () => {
const current = app.desktop.available
? await app.desktop.remoteServerUrl()
: null;
if ((current?.replace(/\/+$/, "") || "local") !== key) {
throw new Error(app.t("collab.serverChanged"));
}
};
return {
key,
origin,
publicUrl,
assertCurrent,
api: createMeetingApi(app.apiFor(origin), assertCurrent),
};
}
export function meetingGuestUrl(
publicUrl: string | null,
token: string,
): string | null {
if (!publicUrl) return null;
const url = new URL(publicUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
url.search = "";
url.hash = "";
url.searchParams.set("view", "collab-guest");
url.searchParams.set("token", token);
return url.toString();
}
@@ -17,6 +17,7 @@ export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
export interface RemoteDisplayProps {
token: string;
connectionOrigin?: "local" | "remote";
protocol: string;
isVisible: boolean;
onConnect?: () => void;
@@ -65,14 +66,25 @@ export async function createRemoteSessionToken(
}
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
export async function wsTargetForPath(
wsPath: string,
origin: "local" | "remote" = "local",
) {
const [pathname, query] = wsPath.split("?", 2);
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
if (!match) return null;
const target = await pluginWsUrl(match[1], match[2]);
const target = await pluginWsUrl(match[1], match[2], { origin });
if (!target) return null;
if (!query) return target.url;
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
return {
...target,
url: query
? `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`
: target.url,
};
}
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
return (await wsTargetForPath(wsPath))?.url ?? null;
}
export function getErrorMessage(error: unknown): string {
@@ -1,4 +1,5 @@
import http from "node:http";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
@@ -85,7 +86,20 @@ export async function startServer(
settings?: Record<string, unknown>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sync_id TEXT");
},
});
const refs = await db.database.refs();
db.database.refs = async () =>
({
...refs,
hosts: sqliteTable("ssh_data", {
id: integer("id").primaryKey(),
syncId: text("sync_id"),
}),
}) as Awaited<ReturnType<typeof db.database.refs>>;
for (const user of options.users ?? ["alice", "bob", "carol"]) {
db.sqlite
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)")
@@ -28,6 +28,28 @@ async function present(s: TestServer, roomId: string, user = "alice") {
}
describe("collab rooms", () => {
it("resolves a synced host to the server ID only with connect access", async () => {
server = await startServer({ hostIds: [41, 42], accessibleHostIds: [42] });
server.db.sqlite.exec("UPDATE ssh_data SET sync_id = 'shared-host'");
expect(await server.request("GET", "/meeting-host/shared-host")).toEqual({
status: 200,
body: { id: 42 },
});
server.db.sqlite.exec(
"UPDATE ssh_data SET sync_id = 'private-host' WHERE id = 41",
);
expect(
(await server.request("GET", "/meeting-host/private-host")).status,
).toBe(404);
expect(
(await server.request("GET", "/meeting-host/missing-host")).status,
).toBe(404);
expect(
(await server.request("GET", "/meeting-host/shared-host", { user: null }))
.status,
).toBe(401);
});
it("creates a room with the creator as host and hides the guest token", async () => {
server = await startServer();
const res = await server.request("POST", "/rooms", {
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import {
connectMeetings,
meetingGuestUrl,
} from "../../src/frontend/meeting-backend";
function client(): PluginApiClient {
return Object.fromEntries(
["get", "post", "put", "patch", "delete"].map((method) => [
method,
vi.fn(async () => ({ data: { rooms: [] } })),
]),
) as unknown as PluginApiClient;
}
afterEach(() => vi.restoreAllMocks());
describe("meeting backend", () => {
it("routes rooms, invites and host resolution to the linked server and refuses a later switch", async () => {
const local = client();
const remote = client();
let url: string | null = "https://termix.example/base/";
const app = {
desktop: { available: true, remoteServerUrl: async () => url },
apiFor: (origin: string) => (origin === "remote" ? remote : local),
t: (key: string) => key,
} as unknown as TermixApp;
const backend = await connectMeetings(app);
expect(backend.origin).toBe("remote");
await backend.api.listCollabRooms();
await backend.api.createCollabRoom("Standup", true);
await backend.api.getDirectory();
await backend.api.resolveHost("stable-host-id");
await backend.api.presentCollabStage("room", {
hostId: 99,
sessionId: "remote-session",
protocol: "ssh",
});
expect(local.get).not.toHaveBeenCalled();
expect(local.post).not.toHaveBeenCalled();
expect(remote.get).toHaveBeenCalledWith("/meeting-host/stable-host-id");
expect(remote.post).toHaveBeenCalledWith("/rooms/room/present", {
hostId: 99,
sessionId: "remote-session",
protocol: "ssh",
});
expect(meetingGuestUrl(backend.publicUrl, "a+b&c")).toBe(
"https://termix.example/base/?view=collab-guest&token=a%2Bb%26c",
);
url = "https://other.example";
await expect(backend.api.deleteCollabRoom("room")).rejects.toThrow(
"collab.serverChanged",
);
expect(remote.delete).not.toHaveBeenCalled();
url = null;
await expect(backend.api.getCollabRoom("room")).rejects.toThrow(
"collab.serverChanged",
);
});
it("keeps standalone rooms local without generating a file URL", async () => {
const local = client();
const backend = await connectMeetings({
desktop: { available: true, remoteServerUrl: async () => null },
apiFor: () => local,
t: (key: string) => key,
} as unknown as TermixApp);
expect(backend.origin).toBe("local");
await backend.api.listCollabRooms();
expect(local.get).toHaveBeenCalledWith("/rooms");
expect(meetingGuestUrl(backend.publicUrl, "token")).toBeNull();
expect(meetingGuestUrl("file:///app/index.html", "token")).toBeNull();
});
it("preserves web deployment paths while replacing old query and fragment values", () => {
expect(
meetingGuestUrl("https://termix.example/app/?view=other#old", "token"),
).toBe("https://termix.example/app/?view=collab-guest&token=token");
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
const { pluginWsUrl } = vi.hoisted(() => ({ pluginWsUrl: vi.fn() }));
vi.mock("@termix/plugin-sdk/ui", () => ({ pluginWsUrl }));
import { wsTargetForPath } from "../../src/frontend/shared";
describe("meeting event transport", () => {
it("preserves remote authentication protocols and event query parameters", async () => {
pluginWsUrl.mockResolvedValue({
url: "wss://server/base/plugin-ws/ssh-terminal/terminal",
protocols: ["jwt", "session-token"],
});
expect(
await wsTargetForPath(
"/plugin-ws/ssh-terminal/terminal?room=abc",
"remote",
),
).toEqual({
url: "wss://server/base/plugin-ws/ssh-terminal/terminal?room=abc",
protocols: ["jwt", "session-token"],
});
expect(pluginWsUrl).toHaveBeenCalledWith("ssh-terminal", "/terminal", {
origin: "remote",
});
expect(
await wsTargetForPath("https://another-server/ws", "remote"),
).toBeNull();
});
});