feat: collaboration rooms with switchable presenter (#1328)

* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
This commit is contained in:
ZacharyZcR
2026-08-25 00:56:04 +08:00
committed by GitHub
parent d35458f78b
commit 81d79cc89b
50 changed files with 56698 additions and 81 deletions
+2
View File
@@ -17,6 +17,7 @@ import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../hosts/guacamole/routes.js";
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
import collabRoutes from "../hosts/collab/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
@@ -1752,6 +1753,7 @@ app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/collab", collabRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
+35
View File
@@ -578,6 +578,37 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS collab_rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
persistent INTEGER NOT NULL DEFAULT 0,
presenter_user_id TEXT,
stage_protocol TEXT,
stage_host_id INTEGER,
stage_share_id TEXT,
guest_link_token TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS collab_room_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
room_role TEXT NOT NULL DEFAULT 'member',
added_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -2040,6 +2071,10 @@ const migrateSchema = () => {
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
);
try {
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
+77
View File
@@ -1939,3 +1939,80 @@ export const aiProposals = mysqlTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = mysqlTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: int("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = mysqlTable(
"collab_room_members",
{
id: int("id").autoincrement().primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
+77
View File
@@ -1940,3 +1940,80 @@ export const aiProposals = pgTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = pgTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = pgTable(
"collab_room_members",
{
id: serial("id").primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
+77
View File
@@ -1936,3 +1936,80 @@ export const aiProposals = sqliteTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = sqliteTable(
"collab_rooms",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: integer("persistent", { mode: "boolean" })
.notNull()
.default(false),
presenterUserId: text("presenter_user_id").references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: text("guest_link_token"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = sqliteTable(
"collab_room_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
roomId: text("room_id")
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: text("added_by").references(() => users.id, {
onDelete: "set null",
}),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
@@ -0,0 +1,200 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
export type CollabRoomRole = "host" | "member";
export interface CollabRoomMemberWithUser {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface CollabRoomStage {
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
}
export class CollabRoomRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async createRoom(input: {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
}): Promise<CollabRoomRecord> {
const [created] = await insertReturning(this.context, collabRooms, {
id: input.id,
name: input.name,
ownerUserId: input.ownerUserId,
persistent: input.persistent,
});
await this.afterWrite();
return created;
}
async findById(id: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(eq(collabRooms.id, id))
.limit(1);
return rows[0] ?? null;
}
/** The live room whose stage points at this share, if any. */
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async setGuestToken(roomId: string, token: string | null): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({ guestLinkToken: token })
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
const rows = await this.context.drizzle
.select({ room: collabRooms })
.from(collabRoomMembers)
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
.where(
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
)
.orderBy(desc(collabRooms.createdAt));
return rows.map((row) => row.room);
}
async findMember(
roomId: string,
userId: string,
): Promise<CollabRoomMemberRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
async addMember(input: {
roomId: string;
userId: string;
roomRole: CollabRoomRole;
addedBy: string | null;
}): Promise<boolean> {
if (await this.findMember(input.roomId, input.userId)) return false;
await this.context.drizzle.insert(collabRoomMembers).values({
roomId: input.roomId,
userId: input.userId,
roomRole: input.roomRole,
addedBy: input.addedBy,
});
await this.afterWrite();
return true;
}
async removeMember(roomId: string, userId: string): Promise<void> {
await this.context.drizzle
.delete(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
);
await this.afterWrite();
}
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
return this.context.drizzle
.select({
userId: collabRoomMembers.userId,
username: users.username,
roomRole: collabRoomMembers.roomRole,
createdAt: collabRoomMembers.createdAt,
})
.from(collabRoomMembers)
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
.where(eq(collabRoomMembers.roomId, roomId))
.orderBy(users.username);
}
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set(stage)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async clearStage(roomId: string): Promise<void> {
return this.updateStage(roomId, {
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
}
async endRoom(roomId: string): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({
endedAt: new Date().toISOString(),
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
})
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async deleteRoom(roomId: string): Promise<void> {
await this.context.drizzle
.delete(collabRooms)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -38,6 +38,7 @@ import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SessionShareRepository } from "./session-share-repository.js";
import { CollabRoomRepository } from "./collab-room-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
@@ -395,6 +396,13 @@ export function createCurrentSessionRepository(): SessionRepository {
);
}
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
return new CollabRoomRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("collab_room_repository_write"),
);
}
export function createCurrentSessionShareRepository(): SessionShareRepository {
return new SessionShareRepository(
createCurrentRepositoryContext(),
@@ -13,7 +13,9 @@ export type SessionShareRecord = typeof sessionShares.$inferSelect;
export type SessionShareParticipantRecord =
typeof sessionShareParticipants.$inferSelect;
export type SessionShareType = "link" | "user";
// "room" shares are minted internally by the collab-room routes and are
// joinable by any member of the room whose stage references them.
export type SessionShareType = "link" | "user" | "room";
export type SessionSharePermissionLevel = "read-only" | "read-write";
export interface SessionShareCreateInput {
+79
View File
@@ -0,0 +1,79 @@
import type { WebSocket } from "ws";
export interface CollabRoomClient {
ws: WebSocket;
userId: string;
username: string;
}
/**
* In-memory fan-out for collab room events, mirroring the single-instance
* assumption TerminalSessionManager already makes. REST mutations broadcast
* through it; the terminal WS server feeds subscribe/unsubscribe.
*/
class CollabRoomHub {
private rooms = new Map<string, Set<CollabRoomClient>>();
subscribe(roomId: string, client: CollabRoomClient): void {
let clients = this.rooms.get(roomId);
if (!clients) {
clients = new Set();
this.rooms.set(roomId, clients);
}
for (const existing of clients) {
if (existing.ws === client.ws) return;
}
clients.add(client);
this.broadcastOnline(roomId);
}
/** Drops the socket from one room, or from every room when roomId is omitted. */
unsubscribe(ws: WebSocket, roomId?: string): void {
for (const [id, clients] of this.rooms) {
if (roomId && id !== roomId) continue;
let removed = false;
for (const client of clients) {
if (client.ws === ws) {
clients.delete(client);
removed = true;
}
}
if (clients.size === 0) this.rooms.delete(id);
if (removed) this.broadcastOnline(id);
}
}
broadcast(roomId: string, message: object): void {
const clients = this.rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.ws.readyState !== client.ws.OPEN) continue;
try {
client.ws.send(payload);
} catch {
/* keep broadcasting to the rest */
}
}
}
onlineUsers(roomId: string): Array<{ userId: string; username: string }> {
const clients = this.rooms.get(roomId);
if (!clients) return [];
const seen = new Map<string, string>();
for (const client of clients) {
seen.set(client.userId, client.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
private broadcastOnline(roomId: string): void {
this.broadcast(roomId, {
type: "collab_online",
roomId,
users: this.onlineUsers(roomId),
});
}
}
export const collabRoomHub = new CollabRoomHub();
@@ -0,0 +1,16 @@
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
/**
* Whether a user may join a room-stage share: the share must be the stage of
* a live room they are a member of. Membership is the authorization - room
* stages are read-only and never expose host credentials or config.
*/
export async function canJoinRoomStageShare(
shareId: string,
userId: string,
): Promise<boolean> {
const repository = createCurrentCollabRoomRepository();
const room = await repository.findByStageShareId(shareId);
if (!room) return false;
return !!(await repository.findMember(room.id, userId));
}
+882
View File
@@ -0,0 +1,882 @@
import crypto from "crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sshLogger } from "../../utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import { collabRoomHub } from "./room-hub.js";
import { getStageController, setStageController } from "./stage-control.js";
import { sessionManager } from "../terminal/session-manager.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
isSharingEnabledForHost,
type LiveProtocol,
} from "../session-sharing/live-sessions.js";
import {
createCurrentCollabRoomRepository,
createCurrentRoleRepository,
createCurrentSessionShareRepository,
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
import type { CollabRoomRecord } from "../../database/repositories/collab-room-repository.js";
/*
* Known limits, shared with session sharing v1:
* - Room events and stage control live in this process (room-hub,
* stage-control). With more than one backend instance, members connected
* to different instances do not see each other's events.
* - Guacamole stages stay read-only because guacamole-lite cannot revoke a
* writable viewer without disconnecting the whole shared session.
*/
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const tokenService = GuacamoleTokenService.getInstance();
const STAGE_SHARE_EXPIRY_HOURS = 12;
const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"];
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function requireRoomMember(
roomId: string,
userId: string,
): Promise<{ room: CollabRoomRecord; isHost: boolean } | null> {
const repository = createCurrentCollabRoomRepository();
const room = await repository.findById(roomId);
if (!room || room.endedAt) return null;
const member = await repository.findMember(roomId, userId);
if (!member) return null;
return { room, isHost: member.roomRole === "host" };
}
async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
if (!room.stageShareId) return;
try {
await createCurrentSessionShareRepository().revokeAsAdmin(
room.stageShareId,
);
} catch {
// A stale share must never block switching presenters.
}
}
function stagePayload(room: CollabRoomRecord) {
return {
presenterUserId: room.presenterUserId,
protocol: room.stageProtocol,
hostId: room.stageHostId,
shareId: room.stageShareId,
};
}
/**
* @openapi
* /collab/rooms:
* post:
* summary: Create a collaboration room
* tags:
* - Collab
*/
router.post("/rooms", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const { name, persistent } = req.body ?? {};
if (!isNonEmptyString(name) || name.trim().length > 120) {
return res.status(400).json({ error: "Room name is required" });
}
try {
const repository = createCurrentCollabRoomRepository();
const room = await repository.createRoom({
id: crypto.randomUUID(),
name: name.trim(),
ownerUserId: userId,
persistent: persistent === true,
});
await repository.addMember({
roomId: room.id,
userId,
roomRole: "host",
addedBy: userId,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "collab_room_create",
resourceType: "collab_room",
resourceId: room.id,
resourceName: room.name,
ipAddress,
userAgent,
success: true,
});
res.json({ room });
} catch (error) {
sshLogger.error("Failed to create collab room", error, {
operation: "collab_room_create_error",
});
res.status(500).json({ error: "Failed to create room" });
}
});
/**
* @openapi
* /collab/rooms:
* get:
* summary: List rooms the caller belongs to
* tags:
* - Collab
*/
router.get("/rooms", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const rooms = await createCurrentCollabRoomRepository().listForUser(userId);
res.json({ rooms });
} catch (error) {
sshLogger.error("Failed to list collab rooms", error, {
operation: "collab_room_list_error",
});
res.status(500).json({ error: "Failed to list rooms" });
}
});
/**
* @openapi
* /collab/rooms/{id}:
* get:
* summary: Get a room with members, online users and stage state
* tags:
* - Collab
*/
router.get(
"/rooms/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
const members =
await createCurrentCollabRoomRepository().listMembers(roomId);
res.json({
room: access.room,
me: userId,
isHost: access.isHost,
members,
online: collabRoomHub.onlineUsers(roomId),
stage: stagePayload(access.room),
controllerUserId: getStageController(roomId),
});
} catch (error) {
sshLogger.error("Failed to get collab room", error, {
operation: "collab_room_get_error",
});
res.status(500).json({ error: "Failed to get room" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/members:
* post:
* summary: Invite users to a room (host only)
* tags:
* - Collab
*/
router.post(
"/rooms/:id/members",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
const { userIds = [], roleIds = [] } = req.body ?? {};
if (
!Array.isArray(userIds) ||
userIds.some((id) => !isNonEmptyString(id)) ||
!Array.isArray(roleIds) ||
roleIds.some((id) => !Number.isInteger(id)) ||
(userIds.length === 0 && roleIds.length === 0)
) {
return res.status(400).json({
error: "userIds (user ids) or roleIds (integers) are required",
});
}
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (!access.isHost) {
return res.status(403).json({ error: "Only the host can invite" });
}
const userRepository = createCurrentUserRepository();
const repository = createCurrentCollabRoomRepository();
for (const targetId of userIds as string[]) {
if (!(await userRepository.findById(targetId))) {
return res.status(404).json({ error: "User not found", targetId });
}
}
// Roles expand to their current members - a snapshot, like folder
// sharing; people joining the role later are not pulled in.
const roleRepository = createCurrentRoleRepository();
const expanded = new Set<string>(userIds as string[]);
for (const roleId of roleIds as number[]) {
if (!(await roleRepository.findRoleById(roleId))) {
return res.status(404).json({ error: "Role not found", roleId });
}
for (const memberId of await roleRepository.listRoleUserIds(roleId)) {
expanded.add(memberId);
}
}
for (const targetId of expanded) {
await repository.addMember({
roomId,
userId: targetId,
roomRole: "member",
addedBy: userId,
});
}
collabRoomHub.broadcast(roomId, {
type: "collab_members_changed",
roomId,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to invite collab room members", error, {
operation: "collab_room_invite_error",
});
res.status(500).json({ error: "Failed to invite members" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/members/{userId}:
* delete:
* summary: Remove a member (host), or leave the room (self)
* tags:
* - Collab
*/
router.delete(
"/rooms/:id/members/:userId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
const targetId = String(req.params.userId);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (targetId !== userId && !access.isHost) {
return res
.status(403)
.json({ error: "Only the host can remove members" });
}
if (targetId === access.room.ownerUserId) {
return res.status(400).json({ error: "The owner cannot be removed" });
}
const repository = createCurrentCollabRoomRepository();
await repository.removeMember(roomId, targetId);
if (getStageController(roomId) === targetId) {
await applyStageControl(access.room, roomId, null);
}
if (access.room.presenterUserId === targetId) {
await revokeStageShare(access.room);
await repository.clearStage(roomId);
collabRoomHub.broadcast(roomId, {
type: "collab_stage_changed",
roomId,
stage: null,
});
}
collabRoomHub.broadcast(roomId, {
type: "collab_members_changed",
roomId,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to remove collab room member", error, {
operation: "collab_room_remove_member_error",
});
res.status(500).json({ error: "Failed to remove member" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/present:
* post:
* summary: Take the stage with one of your live sessions
* description: Any member may take over the stage; the previous stage share is revoked. The caller must own the live session and sharing must be enabled for the host.
* tags:
* - Collab
*/
router.post(
"/rooms/:id/present",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
const { protocol, sessionId, hostId } = req.body ?? {};
if (!PROTOCOLS.includes(protocol)) {
return res.status(400).json({ error: "Invalid protocol" });
}
if (!isNonEmptyString(sessionId) || !Number.isInteger(Number(hostId))) {
return res
.status(400)
.json({ error: "sessionId and hostId are required" });
}
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
const numericHostId = Number(hostId);
const { enabled } = await isSharingEnabledForHost(numericHostId);
if (!enabled) {
return res
.status(403)
.json({ error: "Session sharing is disabled for this host" });
}
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
return res
.status(403)
.json({ error: "You do not own this live session" });
}
const shareRepository = createCurrentSessionShareRepository();
const share = await shareRepository.create({
id: crypto.randomUUID(),
hostId: numericHostId,
ownerUserId: userId,
protocol,
sessionId: String(sessionId),
shareType: "room",
permissionLevel: "read-only",
expiresAt: new Date(
Date.now() + STAGE_SHARE_EXPIRY_HOURS * 60 * 60 * 1000,
).toISOString(),
});
await revokeStageShare(access.room);
setStageController(roomId, null);
const repository = createCurrentCollabRoomRepository();
await repository.updateStage(roomId, {
presenterUserId: userId,
stageProtocol: protocol,
stageHostId: numericHostId,
stageShareId: share.id,
});
const stage = {
presenterUserId: userId,
protocol,
hostId: numericHostId,
shareId: share.id,
};
collabRoomHub.broadcast(roomId, {
type: "collab_stage_changed",
roomId,
stage,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "collab_room_present",
resourceType: "collab_room",
resourceId: roomId,
resourceName: access.room.name,
details: JSON.stringify({ protocol, hostId: numericHostId }),
ipAddress,
userAgent,
success: true,
});
res.json({ stage });
} catch (error) {
sshLogger.error("Failed to take collab room stage", error, {
operation: "collab_room_present_error",
});
res.status(500).json({ error: "Failed to start presenting" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/stop:
* post:
* summary: Stop presenting (presenter or host)
* tags:
* - Collab
*/
router.post(
"/rooms/:id/stop",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (access.room.presenterUserId !== userId && !access.isHost) {
return res
.status(403)
.json({ error: "Only the presenter or host can stop the stage" });
}
await revokeStageShare(access.room);
setStageController(roomId, null);
await createCurrentCollabRoomRepository().clearStage(roomId);
collabRoomHub.broadcast(roomId, {
type: "collab_stage_changed",
roomId,
stage: null,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to stop collab room stage", error, {
operation: "collab_room_stop_error",
});
res.status(500).json({ error: "Failed to stop presenting" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/stage:
* get:
* summary: Get connect info for the current stage (members only)
* description: SSH stages are joined over the terminal WS by shareId; guac stages get a freshly minted read-only join token.
* tags:
* - Collab
*/
router.get(
"/rooms/:id/stage",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
const { room } = access;
if (!room.stageShareId || !room.stageProtocol) {
return res.json({ stage: null });
}
const share = await createCurrentSessionShareRepository().findActiveById(
room.stageShareId,
);
const protocol = room.stageProtocol as LiveProtocol;
if (!share || !isLiveSession(protocol, share.sessionId)) {
// The presenter is gone (expired share or dead session): clear the
// stale stage so the room stops pointing at it.
setStageController(roomId, null);
await createCurrentCollabRoomRepository().clearStage(roomId);
collabRoomHub.broadcast(roomId, {
type: "collab_stage_changed",
roomId,
stage: null,
});
return res.json({ stage: null });
}
const controllerUserId = getStageController(roomId);
const stage: Record<string, unknown> = {
...stagePayload(room),
sessionId: share.sessionId,
controllerUserId,
};
if (protocol !== "ssh") {
stage.connectParams = {
token: tokenService.createJoinToken(share.sessionId, true),
};
}
res.json({ stage });
} catch (error) {
sshLogger.error("Failed to resolve collab room stage", error, {
operation: "collab_room_stage_error",
});
res.status(500).json({ error: "Failed to resolve stage" });
}
},
);
/** Sets the controller everywhere it lives: memory, live SSH gate, hub. */
async function applyStageControl(
room: CollabRoomRecord,
roomId: string,
controllerUserId: string | null,
): Promise<void> {
setStageController(roomId, controllerUserId);
if (room.stageShareId && room.stageProtocol === "ssh") {
try {
const share = await createCurrentSessionShareRepository().findActiveById(
room.stageShareId,
);
if (share) {
sessionManager.setRoomShareControl(
share.sessionId,
share.id,
controllerUserId,
);
}
} catch {
// The gate keeps its previous state; the broadcast still lands.
}
}
collabRoomHub.broadcast(roomId, {
type: "collab_control_changed",
roomId,
controllerUserId,
});
}
/**
* @openapi
* /collab/rooms/{id}/control:
* post:
* summary: Grant or revoke stage control (presenter or host)
* description: Grants a member write access to the current stage, or revokes it with a null userId. The controller may also release control themselves.
* tags:
* - Collab
*/
router.post(
"/rooms/:id/control",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
const { userId: targetId } = req.body ?? {};
if (targetId !== null && !isNonEmptyString(targetId)) {
return res
.status(400)
.json({ error: "userId must be a user id or null" });
}
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (!access.room.stageShareId) {
return res.status(400).json({ error: "Nothing is being presented" });
}
if (access.room.stageProtocol !== "ssh") {
return res.status(400).json({
error: "Remote desktop stages are read-only",
});
}
const releasingOwnControl =
targetId === null && getStageController(roomId) === userId;
const mayGrant = access.isHost || access.room.presenterUserId === userId;
if (!mayGrant && !releasingOwnControl) {
return res.status(403).json({
error: "Only the presenter or host can change stage control",
});
}
if (targetId) {
const repository = createCurrentCollabRoomRepository();
if (!(await repository.findMember(roomId, targetId))) {
return res.status(404).json({ error: "Member not found" });
}
}
await applyStageControl(access.room, roomId, targetId);
res.json({ controllerUserId: targetId });
} catch (error) {
sshLogger.error("Failed to change collab stage control", error, {
operation: "collab_control_error",
});
res.status(500).json({ error: "Failed to change stage control" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/control/request:
* post:
* summary: Ask the presenter for stage control (hand raise)
* tags:
* - Collab
*/
router.post(
"/rooms/:id/control/request",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (!access.room.stageShareId) {
return res.status(400).json({ error: "Nothing is being presented" });
}
if (access.room.stageProtocol !== "ssh") {
return res.status(400).json({
error: "Remote desktop stages are read-only",
});
}
collabRoomHub.broadcast(roomId, {
type: "collab_control_requested",
roomId,
userId,
username: await getAuditUsername(userId),
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to request collab stage control", error, {
operation: "collab_control_request_error",
});
res.status(500).json({ error: "Failed to request control" });
}
},
);
/**
* @openapi
* /collab/rooms/{id}/guest-link:
* post:
* summary: Enable, rotate or disable the room's anonymous guest link (host only)
* tags:
* - Collab
*/
router.post(
"/rooms/:id/guest-link",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
const { enabled } = req.body ?? {};
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (!access.isHost) {
return res
.status(403)
.json({ error: "Only the host can manage the guest link" });
}
const token = enabled
? crypto.randomBytes(24).toString("base64url")
: null;
await createCurrentCollabRoomRepository().setGuestToken(roomId, token);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: enabled
? "collab_guest_link_enable"
: "collab_guest_link_disable",
resourceType: "collab_room",
resourceId: roomId,
resourceName: access.room.name,
ipAddress,
userAgent,
success: true,
});
res.json({ guestLinkToken: token });
} catch (error) {
sshLogger.error("Failed to update collab guest link", error, {
operation: "collab_guest_link_error",
});
res.status(500).json({ error: "Failed to update guest link" });
}
},
);
const GUEST_WINDOW_MS = 60 * 1000;
const GUEST_MAX_ATTEMPTS = 60;
const guestAttempts = new Map<string, { count: number; windowStart: number }>();
function isGuestRateLimited(ip: string): boolean {
const now = Date.now();
const entry = guestAttempts.get(ip);
if (!entry || now - entry.windowStart > GUEST_WINDOW_MS) {
guestAttempts.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > GUEST_MAX_ATTEMPTS;
}
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of guestAttempts) {
if (now - entry.windowStart > GUEST_WINDOW_MS) guestAttempts.delete(ip);
}
}, 5 * GUEST_WINDOW_MS).unref();
/**
* @openapi
* /collab/guest/{token}:
* get:
* summary: Resolve a room's current stage for an anonymous guest
* description: Public, rate-limited per IP. Guests poll this to follow presenter switches. Never returns host details; SSH stages are joined over the terminal WS with roomGuestToken, guac stages get a read-only join token.
* tags:
* - Collab
*/
router.get("/guest/:token", async (req: Request, res: Response) => {
const ip = req.ip || req.socket.remoteAddress || "unknown";
if (isGuestRateLimited(ip)) {
return res.status(429).json({ error: "Too many requests" });
}
const token = String(req.params.token);
try {
const room =
await createCurrentCollabRoomRepository().findByGuestToken(token);
if (!room) {
return res.status(404).json({ error: "Link not found" });
}
const response: Record<string, unknown> = {
roomName: room.name,
stage: null,
};
if (room.stageShareId && room.stageProtocol) {
const share = await createCurrentSessionShareRepository().findActiveById(
room.stageShareId,
);
const protocol = room.stageProtocol as LiveProtocol;
if (share && isLiveSession(protocol, share.sessionId)) {
const { enabled } = await isSharingEnabledForHost(share.hostId);
if (enabled) {
response.stage = {
protocol,
shareId: share.id,
...(protocol === "ssh"
? {
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(token)}`,
}
: {
connectParams: {
token: tokenService.createJoinToken(share.sessionId, true),
},
}),
};
}
}
}
res.json(response);
} catch (error) {
sshLogger.error("Failed to resolve collab guest link", error, {
operation: "collab_guest_resolve_error",
});
res.status(500).json({ error: "Failed to resolve guest link" });
}
});
/**
* @openapi
* /collab/rooms/{id}/end:
* post:
* summary: End the meeting (host only)
* description: Clears the stage. One-off rooms are ended for good; persistent rooms stay listed for reuse.
* tags:
* - Collab
*/
router.post(
"/rooms/:id/end",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const roomId = String(req.params.id);
try {
const access = await requireRoomMember(roomId, userId);
if (!access) {
return res.status(404).json({ error: "Room not found" });
}
if (!access.isHost) {
return res
.status(403)
.json({ error: "Only the host can end the room" });
}
await revokeStageShare(access.room);
setStageController(roomId, null);
const repository = createCurrentCollabRoomRepository();
if (access.room.persistent) {
await repository.clearStage(roomId);
collabRoomHub.broadcast(roomId, {
type: "collab_stage_changed",
roomId,
stage: null,
});
} else {
await repository.endRoom(roomId);
collabRoomHub.broadcast(roomId, { type: "collab_room_ended", roomId });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "collab_room_end",
resourceType: "collab_room",
resourceId: roomId,
resourceName: access.room.name,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to end collab room", error, {
operation: "collab_room_end_error",
});
res.status(500).json({ error: "Failed to end room" });
}
},
);
export default router;
+20
View File
@@ -0,0 +1,20 @@
/**
* Who besides the presenter may drive the current stage, per room.
*
* Deliberately in-memory: control is a property of the live stage, and the
* stage itself (SSH session / guacd connection) is process-local already.
* Every stage switch clears it.
*/
const controllers = new Map<string, string>();
export function getStageController(roomId: string): string | null {
return controllers.get(roomId) ?? null;
}
export function setStageController(
roomId: string,
userId: string | null,
): void {
if (userId) controllers.set(roomId, userId);
else controllers.delete(roomId);
}
@@ -0,0 +1,55 @@
import { sessionManager } from "../terminal/session-manager.js";
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
export async function isSharingEnabledForHost(hostId: number): Promise<{
enabled: boolean;
hostOwnerId: string | null;
}> {
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"session_sharing_globally_enabled",
true,
);
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
const hostResolutionRepository = createCurrentHostResolutionRepository();
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
if (!host) return { enabled: false, hostOwnerId: null };
return {
enabled: host.allowSessionSharing !== false,
hostOwnerId,
};
}
export function isLiveSessionOwnedBy(
protocol: LiveProtocol,
sessionId: string,
userId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
return !!info && info.ownerUserId === userId;
}
export function isLiveSession(
protocol: LiveProtocol,
sessionId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
}
+14 -58
View File
@@ -5,13 +5,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { sshLogger } from "../../utils/logger.js";
import { sessionManager } from "../terminal/session-manager.js";
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import {
createCurrentSessionShareRepository,
createCurrentSettingsRepository,
createCurrentHostResolutionRepository,
} from "../../database/repositories/factory.js";
isLiveSession,
isLiveSessionOwnedBy,
isSharingEnabledForHost,
} from "./live-sessions.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -30,6 +30,14 @@ interface ResolveRateEntry {
windowStart: number;
}
const resolveAttempts = new Map<string, ResolveRateEntry>();
function computeExpiresAt(expiryHours: number | undefined): string {
const hours = Math.min(
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
MAX_EXPIRY_HOURS,
);
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
const RESOLVE_WINDOW_MS = 60 * 1000;
const RESOLVE_MAX_ATTEMPTS = 30;
@@ -55,58 +63,6 @@ setInterval(
5 * 60 * 1000,
);
async function isSharingEnabledForHost(hostId: number): Promise<{
enabled: boolean;
hostOwnerId: string | null;
}> {
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"session_sharing_globally_enabled",
true,
);
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
const hostResolutionRepository = createCurrentHostResolutionRepository();
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
if (!host) return { enabled: false, hostOwnerId: null };
return {
enabled: host.allowSessionSharing !== false,
hostOwnerId,
};
}
function computeExpiresAt(expiryHours: number | undefined): string {
const hours = Math.min(
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
MAX_EXPIRY_HOURS,
);
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
function isLiveSessionOwnedBy(
protocol: Protocol,
sessionId: string,
userId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
return !!info && info.ownerUserId === userId;
}
function isLiveSession(protocol: Protocol, sessionId: string): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
}
/**
* @openapi
* /session-sharing/create:
+113 -11
View File
@@ -1,5 +1,8 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { getAuditUsername } from "../../utils/audit-logger.js";
import { collabRoomHub } from "../collab/room-hub.js";
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js";
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
import {
parseWsMessage,
asObject,
@@ -150,12 +153,45 @@ async function handleShareTokenConnection(
req: import("http").IncomingMessage,
shareToken: string,
): Promise<void> {
const shareRepo = createCurrentSessionShareRepository();
const share = await shareRepo.findByLinkToken(shareToken);
const share =
await createCurrentSessionShareRepository().findByLinkToken(shareToken);
if (!share) {
ws.close(1008, "Invalid or expired share link");
return;
}
return attachShareGuest(ws, req, share);
}
/**
* Auth path for anonymous collab-room guests (?roomGuestToken=<token>): the
* room's guest link resolves to whatever share is on stage right now.
*/
async function handleRoomGuestConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
roomGuestToken: string,
): Promise<void> {
const room =
await createCurrentCollabRoomRepository().findByGuestToken(roomGuestToken);
const share = room?.stageShareId
? await createCurrentSessionShareRepository().findActiveById(
room.stageShareId,
)
: null;
if (!share) {
ws.close(1008, "Nothing is being presented");
return;
}
return attachShareGuest(ws, req, share);
}
/** Joins an anonymous guest socket to a live shared SSH session, read-only or not per the share. */
async function attachShareGuest(
ws: WebSocket,
req: import("http").IncomingMessage,
share: SessionShareRecord,
): Promise<void> {
const shareRepo = createCurrentSessionShareRepository();
if (share.protocol !== "ssh") {
ws.close(1008, "Unsupported share protocol");
return;
@@ -300,6 +336,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
await handleShareTokenConnection(ws, req, shareToken);
return;
}
const roomGuestToken = urlObj.searchParams.get("roomGuestToken");
if (roomGuestToken) {
await handleRoomGuestConnection(ws, req, roomGuestToken);
return;
}
try {
const token = extractWebSocketToken(req);
@@ -399,6 +440,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.on("close", () => {
clearInterval(wsPingInterval);
collabRoomHub.unsubscribe(ws);
sshLogger.info("Terminal WebSocket disconnected", {
operation: "terminal_ws_disconnect",
sessionId,
@@ -1231,17 +1273,72 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
case "collab_subscribe": {
const { roomId } = (data ?? {}) as { roomId?: string };
if (typeof roomId !== "string" || !roomId) break;
try {
const repository = createCurrentCollabRoomRepository();
const room = await repository.findById(roomId);
const member =
room && !room.endedAt
? await repository.findMember(roomId, userId)
: null;
if (!member) {
ws.send(
JSON.stringify({
type: "error",
message: "Room not found",
}),
);
break;
}
collabRoomHub.subscribe(roomId, {
ws,
userId,
username: await getAuditUsername(userId),
});
} catch (error) {
sshLogger.error("Failed to subscribe to collab room", error, {
operation: "collab_subscribe_error",
userId,
});
}
break;
}
case "collab_unsubscribe": {
const { roomId } = (data ?? {}) as { roomId?: string };
collabRoomHub.unsubscribe(
ws,
typeof roomId === "string" ? roomId : undefined,
);
break;
}
case "joinSharedSession": {
const joinData = data as { shareId: string; tabInstanceId?: string };
try {
const shareRepo = createCurrentSessionShareRepository();
const share = await shareRepo.findActiveById(joinData.shareId);
// Room-stage shares are joinable by any member of the live
// room whose stage points at them; user shares only by their
// target.
let eligible =
!!share &&
share.protocol === "ssh" &&
share.shareType === "user" &&
share.targetUserId === userId;
if (
!share ||
share.shareType !== "user" ||
share.targetUserId !== userId ||
share.protocol !== "ssh"
!eligible &&
share &&
share.protocol === "ssh" &&
share.shareType === "room"
) {
const { canJoinRoomStageShare } =
await import("../collab/room-share-access.js");
eligible = await canJoinRoomStageShare(share.id, userId);
}
if (!eligible || !share) {
ws.send(
JSON.stringify({
type: "error",
@@ -1251,13 +1348,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
// Room membership is the authorization for a room stage; the
// read-only share never exposes host credentials or config.
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
share.hostId,
"connect",
);
const access =
share.shareType === "room"
? { hasAccess: true }
: await PermissionManager.getInstance().canAccessHost(
userId,
share.hostId,
"connect",
);
if (!access.hasAccess) {
ws.send(
JSON.stringify({
@@ -457,6 +457,27 @@ class TerminalSessionManager {
this.broadcast(sessionId, { type: "participants", participants });
}
/**
* Grants stage control: participants joined via this share become
* read-write only while they are the controller. The owner is untouched.
*/
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const participant of session.participants.values()) {
if (participant.isOwner || participant.joinedViaShareId !== shareId)
continue;
participant.permissionLevel =
controllerUserId && participant.userId === controllerUserId
? "read-write"
: "read-only";
}
}
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
broadcast(sessionId: string, message: object): void {
const session = this.sessions.get(sessionId);
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
function fakeWs(open = true): WebSocket {
return {
OPEN: 1,
readyState: open ? 1 : 3,
send: vi.fn(),
} as unknown as WebSocket;
}
describe("collabRoomHub", () => {
it("announces the online list on subscribe and unsubscribe, deduplicated per user", () => {
const a1 = fakeWs();
const a2 = fakeWs();
const b = fakeWs();
collabRoomHub.subscribe("room-1", { ws: a1, userId: "a", username: "A" });
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
{ userId: "a", username: "A" },
{ userId: "b", username: "B" },
]);
const last = JSON.parse(
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
);
expect(last).toEqual({
type: "collab_online",
roomId: "room-1",
users: [
{ userId: "a", username: "A" },
{ userId: "b", username: "B" },
],
});
collabRoomHub.unsubscribe(a1);
expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
collabRoomHub.unsubscribe(a2);
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
{ userId: "b", username: "B" },
]);
collabRoomHub.unsubscribe(b);
expect(collabRoomHub.onlineUsers("room-1")).toEqual([]);
});
it("subscribing the same socket twice keeps one subscription", () => {
const ws = fakeWs();
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
collabRoomHub.unsubscribe(ws);
});
it("broadcast skips closed sockets and rooms nobody watches", () => {
const open = fakeWs();
const closed = fakeWs(false);
collabRoomHub.subscribe("room-3", { ws: open, userId: "a", username: "A" });
collabRoomHub.subscribe("room-3", {
ws: closed,
userId: "b",
username: "B",
});
(open.send as ReturnType<typeof vi.fn>).mockClear();
(closed.send as ReturnType<typeof vi.fn>).mockClear();
collabRoomHub.broadcast("room-3", { type: "collab_stage_changed" });
collabRoomHub.broadcast("nobody", { type: "collab_stage_changed" });
expect(open.send).toHaveBeenCalledTimes(1);
expect(closed.send).not.toHaveBeenCalled();
collabRoomHub.unsubscribe(open);
collabRoomHub.unsubscribe(closed);
});
});
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
roomsByShare: new Map<string, { id: string }>(),
members: new Set<string>(), // `${roomId}:${userId}`
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCollabRoomRepository: () => ({
findByStageShareId: async (shareId: string) =>
state.roomsByShare.get(shareId) ?? null,
findMember: async (roomId: string, userId: string) =>
state.members.has(`${roomId}:${userId}`) ? { roomId, userId } : null,
}),
}));
const { canJoinRoomStageShare } =
await import("../../../hosts/collab/room-share-access.js");
describe("canJoinRoomStageShare", () => {
beforeEach(() => {
state.roomsByShare.clear();
state.members.clear();
});
it("admits members of the live room whose stage is this share", async () => {
state.roomsByShare.set("share-1", { id: "room-1" });
state.members.add("room-1:alice");
await expect(canJoinRoomStageShare("share-1", "alice")).resolves.toBe(true);
});
it("rejects non-members and shares that are not a room stage", async () => {
state.roomsByShare.set("share-1", { id: "room-1" });
await expect(canJoinRoomStageShare("share-1", "mallory")).resolves.toBe(
false,
);
await expect(canJoinRoomStageShare("share-9", "alice")).resolves.toBe(
false,
);
});
});
@@ -0,0 +1,595 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
type Room = {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
guestLinkToken: string | null;
createdAt: string;
endedAt: string | null;
};
const state = vi.hoisted(() => ({
currentUserId: "host-1",
rooms: new Map<string, Room>(),
members: new Map<
string,
{ roomId: string; userId: string; roomRole: string }
>(),
shares: new Map<string, Record<string, unknown>>(),
users: new Set<string>(["host-1", "alice", "bob"]),
roles: new Map<number, string[]>([[7, ["alice", "carol"]]]),
sharingEnabled: true,
liveOwned: new Map<string, string>(), // sessionId -> owner
broadcasts: [] as Array<Record<string, unknown>>,
control: [] as Array<unknown[]>,
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => undefined),
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
GuacamoleTokenService: {
getInstance: () => ({
createJoinToken: (id: string, readOnly: boolean) =>
`join:${id}:${readOnly}`,
}),
},
}));
vi.mock("../../../hosts/collab/room-hub.js", () => ({
collabRoomHub: {
broadcast: (roomId: string, message: Record<string, unknown>) => {
state.broadcasts.push({ roomId, ...message });
},
onlineUsers: () => [],
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
setRoomShareControl: (...args: unknown[]) => {
state.control.push(args);
},
},
}));
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
isSharingEnabledForHost: async () => ({
enabled: state.sharingEnabled,
hostOwnerId: "host-1",
}),
isLiveSessionOwnedBy: (_p: string, sessionId: string, userId: string) =>
state.liveOwned.get(sessionId) === userId,
isLiveSession: (_p: string, sessionId: string) =>
state.liveOwned.has(sessionId),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCollabRoomRepository: () => ({
createRoom: async (
input: Omit<
Room,
| "presenterUserId"
| "stageProtocol"
| "stageHostId"
| "stageShareId"
| "guestLinkToken"
| "createdAt"
| "endedAt"
>,
) => {
const room: Room = {
...input,
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
guestLinkToken: null,
createdAt: "2026-08-25T00:00:00.000Z",
endedAt: null,
};
state.rooms.set(room.id, room);
return room;
},
findById: async (id: string) => state.rooms.get(id) ?? null,
findByGuestToken: async (token: string) =>
[...state.rooms.values()].find(
(r) => r.guestLinkToken === token && !r.endedAt,
) ?? null,
setGuestToken: async (roomId: string, token: string | null) => {
state.rooms.get(roomId)!.guestLinkToken = token;
},
listForUser: async (userId: string) =>
[...state.members.values()]
.filter((m) => m.userId === userId)
.map((m) => state.rooms.get(m.roomId)!)
.filter((r) => !r.endedAt),
findMember: async (roomId: string, userId: string) =>
state.members.get(`${roomId}:${userId}`) ?? null,
addMember: async (input: {
roomId: string;
userId: string;
roomRole: string;
}) => {
const key = `${input.roomId}:${input.userId}`;
if (state.members.has(key)) return false;
state.members.set(key, input);
return true;
},
removeMember: async (roomId: string, userId: string) => {
state.members.delete(`${roomId}:${userId}`);
},
listMembers: async (roomId: string) =>
[...state.members.values()]
.filter((m) => m.roomId === roomId)
.map((m) => ({ ...m, username: m.userId, createdAt: "" })),
updateStage: async (roomId: string, stage: Partial<Room>) => {
Object.assign(state.rooms.get(roomId)!, stage);
},
clearStage: async (roomId: string) => {
Object.assign(state.rooms.get(roomId)!, {
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
},
endRoom: async (roomId: string) => {
Object.assign(state.rooms.get(roomId)!, {
endedAt: "2026-08-25T01:00:00.000Z",
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
},
}),
createCurrentSessionShareRepository: () => ({
create: async (input: Record<string, unknown>) => {
const row = { ...input, revokedAt: null };
state.shares.set(input.id as string, row);
return row;
},
findActiveById: async (id: string) => {
const share = state.shares.get(id);
return share && !share.revokedAt ? share : null;
},
revokeAsAdmin: async (id: string) => {
const share = state.shares.get(id);
if (!share) return false;
share.revokedAt = "now";
return true;
},
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => (state.users.has(id) ? { id } : null),
}),
createCurrentRoleRepository: () => ({
findRoleById: async (id: number) => (state.roles.has(id) ? { id } : null),
listRoleUserIds: async (id: number) => state.roles.get(id) ?? [],
}),
}));
const { default: router } = await import("../../../hosts/collab/routes.js");
const { getStageController } =
await import("../../../hosts/collab/stage-control.js");
type RouteLayer = {
route?: {
path: string;
methods: Record<string, boolean>;
stack: {
handle: (req: Request, res: Response, next: () => void) => unknown;
}[];
};
};
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
ip?: string;
} = {},
) {
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
const layer = layers.find(
(l) => l.route?.path === path && l.route.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
const req = {
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
ip: overrides.ip ?? "127.0.0.1",
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.jsonBody = payload;
return this;
},
};
for (const handler of layer.route.stack) {
let calledNext = false;
await handler.handle(req, res as unknown as Response, () => {
calledNext = true;
});
if (!calledNext) break;
}
return res as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
async function as(userId: string, run: () => Promise<unknown>) {
const previous = state.currentUserId;
state.currentUserId = userId;
try {
return await run();
} finally {
state.currentUserId = previous;
}
}
async function createRoom(persistent = false): Promise<string> {
const response = await invoke("post", "/rooms", {
body: { name: "Standup", persistent },
});
return (response.jsonBody!.room as Room).id;
}
async function invite(roomId: string, userIds: string[]) {
return invoke("post", "/rooms/:id/members", {
params: { id: roomId },
body: { userIds },
});
}
async function present(roomId: string, sessionId: string, protocol = "ssh") {
return invoke("post", "/rooms/:id/present", {
params: { id: roomId },
body: { protocol, sessionId, hostId: 1 },
});
}
describe("collab room routes", () => {
beforeEach(() => {
state.currentUserId = "host-1";
state.rooms.clear();
state.members.clear();
state.shares.clear();
state.liveOwned.clear();
state.broadcasts.length = 0;
state.control.length = 0;
state.sharingEnabled = true;
});
it("creating a room makes the creator its host and lists it for them only", async () => {
const roomId = await createRoom();
expect(state.members.get(`${roomId}:host-1`)?.roomRole).toBe("host");
const mine = await invoke("get", "/rooms");
expect((mine.jsonBody!.rooms as Room[]).map((r) => r.id)).toEqual([roomId]);
const other = await as("alice", () =>
invoke("get", "/rooms/:id", { params: { id: roomId } }),
);
expect((other as { statusCode: number }).statusCode).toBe(404);
});
it("rejects an empty or oversized room name", async () => {
expect(
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
).toBe(400);
expect(
(await invoke("post", "/rooms", { body: { name: "x".repeat(121) } }))
.statusCode,
).toBe(400);
});
it("only the host invites; roles expand to their members; unknown users 404", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
const byMember = await as("alice", () => invite(roomId, ["bob"]));
expect((byMember as { statusCode: number }).statusCode).toBe(403);
expect((await invite(roomId, ["nobody"])).statusCode).toBe(404);
expect(
(
await invoke("post", "/rooms/:id/members", {
params: { id: roomId },
body: {},
})
).statusCode,
).toBe(400);
const byRole = await invoke("post", "/rooms/:id/members", {
params: { id: roomId },
body: { roleIds: [7] },
});
expect(byRole.statusCode).toBe(200);
expect(state.members.has(`${roomId}:carol`)).toBe(true);
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_members_changed",
});
});
it("members may leave, only the host removes others, the owner is never removed", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice", "bob"]);
const aliceRemovesBob = await as("alice", () =>
invoke("delete", "/rooms/:id/members/:userId", {
params: { id: roomId, userId: "bob" },
}),
);
expect((aliceRemovesBob as { statusCode: number }).statusCode).toBe(403);
await as("alice", () =>
invoke("delete", "/rooms/:id/members/:userId", {
params: { id: roomId, userId: "alice" },
}),
);
expect(state.members.has(`${roomId}:alice`)).toBe(false);
const removeOwner = await invoke("delete", "/rooms/:id/members/:userId", {
params: { id: roomId, userId: "host-1" },
});
expect(removeOwner.statusCode).toBe(400);
});
it("presenting validates the protocol, the sharing toggle and live-session ownership", async () => {
const roomId = await createRoom();
expect((await present(roomId, "s1", "ftp")).statusCode).toBe(400);
expect((await present(roomId, "s1")).statusCode).toBe(403); // not live
state.liveOwned.set("s1", "host-1");
state.sharingEnabled = false;
expect((await present(roomId, "s1")).statusCode).toBe(403);
state.sharingEnabled = true;
const ok = await present(roomId, "s1");
expect(ok.statusCode).toBe(200);
const room = state.rooms.get(roomId)!;
expect(room.presenterUserId).toBe("host-1");
expect(state.shares.get(room.stageShareId!)).toMatchObject({
shareType: "room",
permissionLevel: "read-only",
sessionId: "s1",
});
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_stage_changed",
});
});
it("a takeover revokes the previous stage share and clears control", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
state.liveOwned.set("s1", "host-1");
state.liveOwned.set("s2", "alice");
await present(roomId, "s1");
const firstShare = state.rooms.get(roomId)!.stageShareId!;
await invoke("post", "/rooms/:id/control", {
params: { id: roomId },
body: { userId: "alice" },
});
expect(getStageController(roomId)).toBe("alice");
await as("alice", () => present(roomId, "s2"));
const room = state.rooms.get(roomId)!;
expect(room.presenterUserId).toBe("alice");
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
expect(getStageController(roomId)).toBeNull();
});
it("stop is for the presenter or host", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice", "bob"]);
state.liveOwned.set("s2", "alice");
await as("alice", () => present(roomId, "s2"));
const bob = await as("bob", () =>
invoke("post", "/rooms/:id/stop", { params: { id: roomId } }),
);
expect((bob as { statusCode: number }).statusCode).toBe(403);
await invoke("post", "/rooms/:id/stop", { params: { id: roomId } }); // host
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
});
it("keeps remote desktop stages read-only", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
const stageOf = (user: string) =>
as(user, () =>
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
) as Promise<{
jsonBody: { stage: Record<string, unknown> | null };
}>;
expect((await stageOf("alice")).jsonBody.stage).toBeNull();
state.liveOwned.set("s1", "host-1");
await present(roomId, "s1");
const ssh = (await stageOf("alice")).jsonBody.stage!;
expect(ssh).toMatchObject({ protocol: "ssh", sessionId: "s1" });
expect(ssh.connectParams).toBeUndefined();
state.liveOwned.set("g1", "host-1");
await present(roomId, "g1", "rdp");
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
token: "join:g1:true",
});
const control = await invoke("post", "/rooms/:id/control", {
params: { id: roomId },
body: { userId: "alice" },
});
expect(control.statusCode).toBe(400);
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
token: "join:g1:true",
});
});
it("a dead presenter session clears the stale stage on resolve", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
state.liveOwned.set("s1", "host-1");
await present(roomId, "s1");
state.liveOwned.delete("s1");
const resolved = await as("alice", () =>
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
);
expect(
(resolved as { jsonBody: { stage: unknown } }).jsonBody.stage,
).toBeNull();
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_stage_changed",
stage: null,
});
});
it("control: presenter/host grant, controller releases, members may only ask", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice", "bob"]);
const control = (user: string, target: string | null) =>
as(user, () =>
invoke("post", "/rooms/:id/control", {
params: { id: roomId },
body: { userId: target },
}),
) as Promise<{ statusCode: number }>;
expect((await control("host-1", "alice")).statusCode).toBe(400); // nothing presented
state.liveOwned.set("s1", "host-1");
await present(roomId, "s1");
expect((await control("bob", "bob")).statusCode).toBe(403);
expect((await control("host-1", "nobody")).statusCode).toBe(404);
expect((await control("host-1", "alice")).statusCode).toBe(200);
expect(state.control.at(-1)).toEqual([
"s1",
state.rooms.get(roomId)!.stageShareId,
"alice",
]);
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_control_changed",
controllerUserId: "alice",
});
expect((await control("bob", null)).statusCode).toBe(403);
expect((await control("alice", null)).statusCode).toBe(200);
expect(getStageController(roomId)).toBeNull();
const asked = await as("bob", () =>
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
);
expect((asked as { statusCode: number }).statusCode).toBe(200);
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_control_requested",
userId: "bob",
username: "BOB",
});
});
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
const toggle = (user: string, enabled: boolean) =>
as(user, () =>
invoke("post", "/rooms/:id/guest-link", {
params: { id: roomId },
body: { enabled },
}),
) as Promise<{
statusCode: number;
jsonBody: { guestLinkToken: string | null };
}>;
expect((await toggle("alice", true)).statusCode).toBe(403);
const { guestLinkToken } = (await toggle("host-1", true)).jsonBody;
expect(guestLinkToken).toBeTruthy();
const resolve = (token: string, ip = "9.9.9.9") =>
invoke("get", "/guest/:token", { params: { token }, ip });
expect((await resolve("nope")).statusCode).toBe(404);
expect((await resolve(guestLinkToken!)).jsonBody).toEqual({
roomName: "Standup",
stage: null,
});
state.liveOwned.set("s1", "host-1");
await present(roomId, "s1");
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
protocol: "ssh",
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
});
state.liveOwned.set("g1", "host-1");
await present(roomId, "g1", "vnc");
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
protocol: "vnc",
connectParams: { token: "join:g1:true" },
});
await toggle("host-1", false);
expect((await resolve(guestLinkToken!)).statusCode).toBe(404);
let last = 200;
for (let i = 0; i < 61; i++) {
last = (await resolve("x", "1.2.3.4")).statusCode;
}
expect(last).toBe(429);
});
it("ending a one-off room ends it; ending a persistent room only clears the stage", async () => {
const oneOff = await createRoom(false);
await invite(oneOff, ["alice"]);
const byMember = await as("alice", () =>
invoke("post", "/rooms/:id/end", { params: { id: oneOff } }),
);
expect((byMember as { statusCode: number }).statusCode).toBe(403);
await invoke("post", "/rooms/:id/end", { params: { id: oneOff } });
expect(state.rooms.get(oneOff)!.endedAt).toBeTruthy();
expect(state.broadcasts.at(-1)).toMatchObject({
type: "collab_room_ended",
});
const persistent = await createRoom(true);
state.liveOwned.set("s1", "host-1");
await present(persistent, "s1");
await invoke("post", "/rooms/:id/end", { params: { id: persistent } });
const room = state.rooms.get(persistent)!;
expect(room.endedAt).toBeNull();
expect(room.stageShareId).toBeNull();
});
});
@@ -218,6 +218,42 @@ describe("TerminalSessionManager - multiplayer participants", () => {
sessionManager.destroySession(id);
});
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const aliceWs = makeFakeWs();
const bobWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, bobWs, {
userId: "bob",
permissionLevel: "read-only",
shareId: "stage-share",
});
sessionManager.setRoomShareControl(id, "stage-share", "alice");
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-write");
expect(
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
).toBe("read-only");
expect(
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
).toBe("read-write");
sessionManager.setRoomShareControl(id, "stage-share", null);
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-only");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {