mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -11,8 +11,6 @@ import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import collabRoutes from "../hosts/collab/routes.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
@@ -1808,8 +1806,6 @@ app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/collab", collabRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
|
||||
@@ -500,69 +500,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
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 secret_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -2060,10 +1997,6 @@ 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<{
|
||||
|
||||
@@ -135,16 +135,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "session_recordings",
|
||||
columns: "host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_session_shares_session_id",
|
||||
table: "session_shares",
|
||||
columns: "session_id",
|
||||
},
|
||||
{
|
||||
name: "idx_session_shares_host_id",
|
||||
table: "session_shares",
|
||||
columns: "host_id",
|
||||
},
|
||||
|
||||
// Automations.
|
||||
{
|
||||
|
||||
@@ -761,70 +761,6 @@ export const sessionRecordings = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShares = mysqlTable(
|
||||
"session_shares",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
|
||||
hostId: int("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
protocol: varchar("protocol", { length: 255 }).notNull(),
|
||||
|
||||
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||
// row (process-local, in-memory) so this intentionally has no FK.
|
||||
sessionId: varchar("session_id", { length: 255 }).notNull(),
|
||||
tabInstanceId: text("tab_instance_id"),
|
||||
|
||||
shareType: text("share_type").notNull(), // "link" | "user"
|
||||
targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
linkToken: varchar("link_token", { length: 255 }).unique(),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
|
||||
lastJoinedAt: text("last_joined_at"),
|
||||
joinCount: int("join_count").notNull().default(0),
|
||||
},
|
||||
// Resolved from the live session on join, and listed per host.
|
||||
(table) => [
|
||||
index("idx_session_shares_session_id").on(table.sessionId),
|
||||
index("idx_session_shares_host_id").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShareParticipants = mysqlTable(
|
||||
"session_share_participants",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
shareId: varchar("share_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
guestLabel: text("guest_label"),
|
||||
|
||||
joinedAt: text("joined_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
leftAt: text("left_at"),
|
||||
},
|
||||
);
|
||||
|
||||
export const opksshTokens = mysqlTable(
|
||||
"opkssh_tokens",
|
||||
{
|
||||
@@ -1569,81 +1505,6 @@ export const aiProposals = mysqlTable(
|
||||
|
||||
// --- 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),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -762,70 +762,6 @@ export const sessionRecordings = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShares = pgTable(
|
||||
"session_shares",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
protocol: varchar("protocol", { length: 255 }).notNull(),
|
||||
|
||||
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||
// row (process-local, in-memory) so this intentionally has no FK.
|
||||
sessionId: varchar("session_id", { length: 255 }).notNull(),
|
||||
tabInstanceId: text("tab_instance_id"),
|
||||
|
||||
shareType: text("share_type").notNull(), // "link" | "user"
|
||||
targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
linkToken: varchar("link_token", { length: 255 }).unique(),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
|
||||
lastJoinedAt: text("last_joined_at"),
|
||||
joinCount: integer("join_count").notNull().default(0),
|
||||
},
|
||||
// Resolved from the live session on join, and listed per host.
|
||||
(table) => [
|
||||
index("idx_session_shares_session_id").on(table.sessionId),
|
||||
index("idx_session_shares_host_id").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShareParticipants = pgTable(
|
||||
"session_share_participants",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
shareId: varchar("share_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
guestLabel: text("guest_label"),
|
||||
|
||||
joinedAt: text("joined_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
leftAt: text("left_at"),
|
||||
},
|
||||
);
|
||||
|
||||
export const opksshTokens = pgTable(
|
||||
"opkssh_tokens",
|
||||
{
|
||||
@@ -1570,81 +1506,6 @@ export const aiProposals = pgTable(
|
||||
|
||||
// --- 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),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -756,70 +756,6 @@ export const sessionRecordings = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShares = sqliteTable(
|
||||
"session_shares",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
protocol: text("protocol").notNull(),
|
||||
|
||||
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||
// row (process-local, in-memory) so this intentionally has no FK.
|
||||
sessionId: text("session_id").notNull(),
|
||||
tabInstanceId: text("tab_instance_id"),
|
||||
|
||||
shareType: text("share_type").notNull(), // "link" | "user"
|
||||
targetUserId: text("target_user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
linkToken: text("link_token").unique(),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
|
||||
lastJoinedAt: text("last_joined_at"),
|
||||
joinCount: integer("join_count").notNull().default(0),
|
||||
},
|
||||
// Resolved from the live session on join, and listed per host.
|
||||
(table) => [
|
||||
index("idx_session_shares_session_id").on(table.sessionId),
|
||||
index("idx_session_shares_host_id").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionShareParticipants = sqliteTable(
|
||||
"session_share_participants",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
shareId: text("share_id")
|
||||
.notNull()
|
||||
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
guestLabel: text("guest_label"),
|
||||
|
||||
joinedAt: text("joined_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
leftAt: text("left_at"),
|
||||
},
|
||||
);
|
||||
|
||||
export const opksshTokens = sqliteTable(
|
||||
"opkssh_tokens",
|
||||
{
|
||||
@@ -1568,81 +1504,6 @@ export const aiProposals = sqliteTable(
|
||||
|
||||
// --- 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),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
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";
|
||||
import { rowsAffected } from "./mutation-result.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 replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: CollabRoomStage,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(collabRooms.stageShareId, expectedShareId)
|
||||
: isNull(collabRooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,6 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
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 { SecretSourceRepository } from "./secret-source-repository.js";
|
||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||
@@ -416,20 +414,6 @@ 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(),
|
||||
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sessionShareParticipants,
|
||||
sessionShares,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||
export type SessionShareParticipantRecord =
|
||||
typeof sessionShareParticipants.$inferSelect;
|
||||
|
||||
// "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 {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SessionShareWithHost extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
function activeShareFilter(now: string) {
|
||||
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||
}
|
||||
|
||||
export class SessionShareRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||
const [created] = await insertReturning(this.context, sessionShares, {
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(eq(sessionShares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.hostId, hostId),
|
||||
eq(sessionShares.ownerUserId, ownerUserId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SharedWithMeRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
share: sessionShares,
|
||||
hostName: hosts.name,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(sessionShares)
|
||||
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.shareType, "user"),
|
||||
eq(sessionShares.targetUserId, userId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.share,
|
||||
hostName: row.hostName,
|
||||
ownerUsername: row.ownerUsername,
|
||||
}));
|
||||
}
|
||||
|
||||
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.id, shareId),
|
||||
eq(sessionShares.ownerUserId, requestingUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(lt(sessionShares.expiresAt, now));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async touchShareUsage(
|
||||
shareId: string,
|
||||
lastJoinedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const current = await this.findById(shareId);
|
||||
await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({
|
||||
lastJoinedAt,
|
||||
joinCount: (current?.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<SessionShareParticipantRecord> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
sessionShareParticipants,
|
||||
{ shareId, userId, guestLabel },
|
||||
);
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionShareParticipants)
|
||||
.set({ leftAt: new Date().toISOString() })
|
||||
.where(eq(sessionShareParticipants.id, participantId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(eq(sessionShares.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,6 @@ router.post(
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
enableAiAssistant,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -343,7 +342,6 @@ router.post(
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -918,7 +916,6 @@ router.put(
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
enableAiAssistant,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -1064,7 +1061,6 @@ router.put(
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
|
||||
@@ -3,10 +3,7 @@ import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
|
||||
import {
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { createCurrentOpenTabRepository } from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -298,14 +295,14 @@ router.get(
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: >
|
||||
* Returns live terminal sessions from the session manager, both sessions the
|
||||
* caller owns and SSH sessions shared to the caller by another user (via
|
||||
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||
* Returns the live terminal sessions the caller owns. Sessions other
|
||||
* users shared with the caller come from the session sharing plugin.
|
||||
* Used by the Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active sessions (own and shared-with-me).
|
||||
* description: List of the caller's active sessions.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -357,28 +354,6 @@ router.get(
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
const sharedWithMe =
|
||||
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||
userId,
|
||||
);
|
||||
for (const share of sharedWithMe) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const sharedSession = liveTerminalSessions.getSession(share.sessionId);
|
||||
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: sharedSession.id,
|
||||
hostId: sharedSession.hostId,
|
||||
hostName: sharedSession.hostName,
|
||||
tabInstanceId: sharedSession.tabInstanceId,
|
||||
isConnected: sharedSession.isConnected,
|
||||
createdAt: sharedSession.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: share.ownerUsername,
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, {
|
||||
|
||||
@@ -54,7 +54,8 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
* No auth. A shared-session or collab guest link has no session, yet its
|
||||
* page still draws surfaces a plugin provides (a remote desktop stream).
|
||||
* Returns only enabled plugins whose manifest sets contributes.guest,
|
||||
* with the fields the browser loader needs and nothing operational.
|
||||
* with the fields the browser loader needs and nothing operational,
|
||||
* plus the ?view= names each serves to guests (contributes.guestViews).
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -69,7 +70,7 @@ router.get("/public", async (_req: Request, res: Response) => {
|
||||
if (record.state !== "enabled") return [];
|
||||
const loaded = loader.get(record.id);
|
||||
let manifest: {
|
||||
contributes?: { guest?: boolean };
|
||||
contributes?: { guest?: boolean; guestViews?: string[] };
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
@@ -86,7 +87,10 @@ router.get("/public", async (_req: Request, res: Response) => {
|
||||
version: record.version,
|
||||
enabled: true,
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes: { guest: true },
|
||||
contributes: {
|
||||
guest: true,
|
||||
guestViews: manifest.contributes.guestViews ?? [],
|
||||
},
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
optionalDependencies: manifest.optionalDependencies ?? {},
|
||||
...describePluginFrontend(loaded),
|
||||
|
||||
@@ -587,91 +587,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get session sharing enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get session sharing enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* patch:
|
||||
* summary: Update session sharing globally enabled setting (admin only)
|
||||
* description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/session-sharing-enabled",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"session_sharing_globally_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_session_sharing_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update session sharing enabled setting",
|
||||
err,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update session sharing enabled setting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
||||
* Shared by notifications and Step CA; each keeps its own setting key.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
const WINDOW_MS = 60_000;
|
||||
const MAX_ATTEMPTS = 60;
|
||||
const attempts = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
export function isCollabGuestRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||
attempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of attempts) {
|
||||
if (now - entry.windowStart > WINDOW_MS) attempts.delete(ip);
|
||||
}
|
||||
}, 5 * WINDOW_MS).unref();
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { collabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||
|
||||
export interface CollabRoomClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room
|
||||
* events and presence. The terminal WS server feeds subscribe/unsubscribe.
|
||||
*/
|
||||
class CollabRoomHub {
|
||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||
|
||||
constructor() {
|
||||
collabRuntimeStore.onEvent((roomId, message) => {
|
||||
if (
|
||||
"type" in message &&
|
||||
typeof message.type === "string" &&
|
||||
message.type.startsWith("collab_internal_")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLocal(roomId, message);
|
||||
});
|
||||
setInterval(() => {
|
||||
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
|
||||
}, PRESENCE_HEARTBEAT_MS).unref();
|
||||
}
|
||||
|
||||
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);
|
||||
void this.refreshPresence(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) void this.refreshPresence(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(roomId: string, message: object): void {
|
||||
this.broadcastLocal(roomId, message);
|
||||
void collabRuntimeStore.publish(roomId, message);
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
return collabRuntimeStore.onlineUsers(
|
||||
roomId,
|
||||
this.localOnlineUsers(roomId),
|
||||
);
|
||||
}
|
||||
|
||||
private broadcastLocal(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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private localOnlineUsers(
|
||||
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 async refreshPresence(roomId: string): Promise<void> {
|
||||
const localUsers = this.localOnlineUsers(roomId);
|
||||
await collabRuntimeStore.updatePresence(roomId, localUsers);
|
||||
this.broadcast(roomId, {
|
||||
type: "collab_online",
|
||||
roomId,
|
||||
users: await this.onlineUsers(roomId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const collabRoomHub = new CollabRoomHub();
|
||||
@@ -1,16 +0,0 @@
|
||||
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));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,286 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { createClient } from "redis";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
interface PresenceEntry {
|
||||
instanceId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface EventEnvelope {
|
||||
source: string;
|
||||
roomId: string;
|
||||
message: object;
|
||||
}
|
||||
|
||||
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
|
||||
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
|
||||
const PRESENCE_TTL_MS = 45_000;
|
||||
const STATE_TTL_SECONDS = 12 * 60 * 60;
|
||||
const CONNECT_RETRY_MS = 15_000;
|
||||
|
||||
export class CollabRuntimeStore {
|
||||
private readonly instanceId = crypto.randomUUID();
|
||||
private publisher: ReturnType<typeof createClient> | null = null;
|
||||
private subscriber: ReturnType<typeof createClient> | null = null;
|
||||
private connecting: Promise<boolean> | null = null;
|
||||
private nextConnectAttempt = 0;
|
||||
private eventListeners = new Set<(roomId: string, message: object) => void>();
|
||||
private localControllers = new Map<string, string>();
|
||||
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
|
||||
private presenceMembers = new Map<string, Set<string>>();
|
||||
|
||||
onEvent(listener: (roomId: string, message: object) => void): void {
|
||||
this.eventListeners.add(listener);
|
||||
void this.ensureConnected();
|
||||
}
|
||||
|
||||
async publish(roomId: string, message: object): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.publish(
|
||||
EVENT_CHANNEL,
|
||||
JSON.stringify({ source: this.instanceId, roomId, message }),
|
||||
)
|
||||
.catch((error) => this.logRedisFailure("publish", error));
|
||||
}
|
||||
|
||||
async getController(roomId: string): Promise<string | null> {
|
||||
if (await this.ensureConnected()) {
|
||||
const value = await this.publisher
|
||||
?.get(`${KEY_PREFIX}:controller:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("get_controller", error);
|
||||
return undefined;
|
||||
});
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return this.localControllers.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
async setController(roomId: string, userId: string | null): Promise<void> {
|
||||
if (userId) this.localControllers.set(roomId, userId);
|
||||
else this.localControllers.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:controller:${roomId}`;
|
||||
const operation = userId
|
||||
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
|
||||
: this.publisher.del(key);
|
||||
await operation.catch((error) =>
|
||||
this.logRedisFailure("set_controller", error),
|
||||
);
|
||||
}
|
||||
|
||||
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
|
||||
if (await this.ensureConnected()) {
|
||||
const values = await this.publisher
|
||||
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("list_requests", error);
|
||||
return null;
|
||||
});
|
||||
if (values) {
|
||||
return values
|
||||
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
|
||||
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
||||
}
|
||||
}
|
||||
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
|
||||
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
|
||||
);
|
||||
}
|
||||
|
||||
async upsertRequest(
|
||||
roomId: string,
|
||||
request: CollabControlRequest,
|
||||
): Promise<void> {
|
||||
let requests = this.localRequests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
this.localRequests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:requests:${roomId}`;
|
||||
await this.publisher
|
||||
.multi()
|
||||
.hSet(key, request.userId, JSON.stringify(request))
|
||||
.expire(key, STATE_TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((error) => this.logRedisFailure("upsert_request", error));
|
||||
}
|
||||
|
||||
async removeRequest(roomId: string, userId: string): Promise<void> {
|
||||
const requests = this.localRequests.get(roomId);
|
||||
requests?.delete(userId);
|
||||
if (requests?.size === 0) this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
|
||||
.catch((error) => this.logRedisFailure("remove_request", error));
|
||||
}
|
||||
|
||||
async clearRequests(roomId: string): Promise<void> {
|
||||
this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.del(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => this.logRedisFailure("clear_requests", error));
|
||||
}
|
||||
|
||||
async updatePresence(
|
||||
roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
|
||||
const expiresAt = Date.now() + PRESENCE_TTL_MS;
|
||||
const current = new Set(
|
||||
users.map((user) =>
|
||||
JSON.stringify({
|
||||
instanceId: this.instanceId,
|
||||
...user,
|
||||
} satisfies PresenceEntry),
|
||||
),
|
||||
);
|
||||
const transaction = this.publisher.multi();
|
||||
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
|
||||
for (const member of current) {
|
||||
transaction.zAdd(key, { score: expiresAt, value: member });
|
||||
}
|
||||
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
|
||||
await transaction
|
||||
.exec()
|
||||
.then(() => {
|
||||
if (current.size > 0) this.presenceMembers.set(roomId, current);
|
||||
else this.presenceMembers.delete(roomId);
|
||||
})
|
||||
.catch((error) => this.logRedisFailure("update_presence", error));
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
localUsers: Array<{ userId: string; username: string }>,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const now = Date.now();
|
||||
const transaction = this.publisher
|
||||
.multi()
|
||||
.zRemRangeByScore(key, 0, now)
|
||||
.zRangeByScore(key, now + 1, "+inf");
|
||||
const result = await transaction.exec().catch((error) => {
|
||||
this.logRedisFailure("online_users", error);
|
||||
return null;
|
||||
});
|
||||
const members = result?.[1] as string[] | undefined;
|
||||
if (!members) return localUsers;
|
||||
const seen = new Map<string, string>();
|
||||
for (const member of members) {
|
||||
const entry = this.parse<PresenceEntry>(member);
|
||||
if (entry) seen.set(entry.userId, entry.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
|
||||
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL?.trim();
|
||||
if (!url) return false;
|
||||
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
|
||||
if (Date.now() < this.nextConnectAttempt) return false;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = this.connect(url).finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async connect(url: string): Promise<boolean> {
|
||||
try {
|
||||
this.publisher = createClient({
|
||||
url,
|
||||
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||
});
|
||||
this.subscriber = this.publisher.duplicate();
|
||||
this.publisher.on("error", (error) =>
|
||||
this.logRedisFailure("client", error),
|
||||
);
|
||||
this.subscriber.on("error", (error) =>
|
||||
this.logRedisFailure("subscriber", error),
|
||||
);
|
||||
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
||||
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
|
||||
const envelope = this.parse<EventEnvelope>(raw);
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope.source !== "string" ||
|
||||
typeof envelope.roomId !== "string" ||
|
||||
!envelope.message ||
|
||||
typeof envelope.message !== "object" ||
|
||||
envelope.source === this.instanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(envelope.roomId, envelope.message);
|
||||
} catch (error) {
|
||||
this.logRedisFailure("event_listener", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
sshLogger.info("Collaboration Redis runtime connected", {
|
||||
operation: "collab_redis_connected",
|
||||
});
|
||||
this.nextConnectAttempt = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||
this.logRedisFailure("connect", error);
|
||||
await Promise.allSettled([
|
||||
this.publisher?.disconnect(),
|
||||
this.subscriber?.disconnect(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parse<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private logRedisFailure(operation: string, error: unknown): void {
|
||||
sshLogger.warn(
|
||||
"Collaboration Redis runtime unavailable; using local state",
|
||||
{
|
||||
operation: `collab_redis_${operation}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const collabRuntimeStore = new CollabRuntimeStore();
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Who besides the presenter may drive the current stage, per room.
|
||||
*
|
||||
* Redis-backed when REDIS_URL is configured, with a local fallback for
|
||||
* single-instance deployments. Every stage switch clears it.
|
||||
*/
|
||||
import { collabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
export function getStageController(roomId: string): Promise<string | null> {
|
||||
return collabRuntimeStore.getController(roomId);
|
||||
}
|
||||
|
||||
export function setStageController(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
return collabRuntimeStore.setController(roomId, userId);
|
||||
}
|
||||
@@ -1,79 +1,23 @@
|
||||
/**
|
||||
* What core needs from the Remote Desktop plugin, resolved through the plugin
|
||||
* registry rather than an import.
|
||||
* registry rather than an import: restarting its guacd WebSocket server after
|
||||
* an admin edits the guacd URL setting.
|
||||
*
|
||||
* collab, session-sharing and the admin guacd setting all need three things
|
||||
* from a running Guacamole server: mint a join token, look up a live session,
|
||||
* restart after a settings change. None of them can import the plugin -- core
|
||||
* cannot statically import something outside its rootDir -- and the plugin
|
||||
* cannot hand core a module reference without becoming core again.
|
||||
*
|
||||
* So the plugin publishes "remote-desktop.sessions" through ctx.registry on
|
||||
* activate, the registry revokes it on deactivate, and the helpers below
|
||||
* degrade the way each caller needs when nothing is published:
|
||||
*
|
||||
* - a lookup returns null, because "no such live session" and "the plugin is
|
||||
* off" mean the same thing to a caller asking whether a session is live;
|
||||
* - a restart is a no-op, because resurrecting a server the plugin does not
|
||||
* own would be worse than doing nothing;
|
||||
* - minting a token throws, because a caller about to hand a viewer a join
|
||||
* link needs to know it failed rather than get an unusable value.
|
||||
*
|
||||
* This replaces the guacamole-dispatch bridge that used to live in
|
||||
* database/routes/.
|
||||
* The plugin publishes "remote-desktop.sessions" through ctx.registry on
|
||||
* activate and the registry revokes it on deactivate. A restart while it is
|
||||
* off does nothing, because resurrecting a server the plugin does not own
|
||||
* would be worse than doing nothing.
|
||||
*/
|
||||
|
||||
import { consume } from "../plugins/registry.js";
|
||||
|
||||
export const GUACAMOLE_SESSIONS_KEY = "remote-desktop.sessions";
|
||||
|
||||
export interface GuacamoleSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
export interface GuacamoleSessionProvider {
|
||||
restart: () => Promise<void>;
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
|
||||
getSessionInfo: (
|
||||
guacamoleConnectionId: string,
|
||||
) => GuacamoleSessionInfo | null;
|
||||
}
|
||||
|
||||
function provider(): GuacamoleSessionProvider | undefined {
|
||||
return consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits the
|
||||
* guacd URL setting. A no-op while the plugin is disabled.
|
||||
*/
|
||||
/** Restarts the plugin's guacd WebSocket server. A no-op while it is off. */
|
||||
export async function restartGuacamoleService(): Promise<void> {
|
||||
await provider()?.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a read-only or writable join token for a live Guacamole session, for
|
||||
* collab and session-sharing to hand an invited viewer. Throws when the plugin
|
||||
* is not running.
|
||||
*/
|
||||
export function createGuacamoleJoinToken(
|
||||
guacamoleConnectionId: string,
|
||||
readOnly: boolean,
|
||||
): string {
|
||||
const active = provider();
|
||||
if (!active) {
|
||||
throw new Error("Remote Desktop plugin is not available");
|
||||
}
|
||||
return active.createJoinToken(guacamoleConnectionId, readOnly);
|
||||
}
|
||||
|
||||
/** Looks up a live Guacamole session by guacd's own connection id. */
|
||||
export function getGuacamoleSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacamoleSessionInfo | null {
|
||||
return provider()?.getSessionInfo(guacamoleConnectionId) ?? null;
|
||||
await consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY)?.restart();
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* What core needs from live SSH terminal sessions, through the plugin
|
||||
* service "sessions.live" rather than an import.
|
||||
* service "sessions.live" (the "ssh" provider) rather than an import.
|
||||
*
|
||||
* Session sharing, collab and the open-tabs route still live in core and
|
||||
* reach the sessions the ssh-terminal plugin holds. With no provider (the
|
||||
* plugin is off) a lookup finds nothing and a control call does nothing,
|
||||
* because "no such live session" and "the terminal is off" mean the same
|
||||
* thing to every caller here.
|
||||
* The open-tabs route lists the caller's sessions and ties saved tabs to the
|
||||
* terminal's idle timeout. With no provider (the terminal is off) a lookup
|
||||
* finds nothing, because "no live session" and "the terminal is off" mean the
|
||||
* same thing there.
|
||||
*/
|
||||
|
||||
import { getServiceImplementation } from "../plugins/service-registry.js";
|
||||
|
||||
export const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
const SSH_PROVIDER = "ssh";
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
@@ -28,19 +28,7 @@ export interface LiveSessionInfo {
|
||||
}
|
||||
|
||||
interface LiveSessionsV1 {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
listForUser: (userId: string) => LiveSessionInfo[];
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
disconnectParticipants: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
setRoomShareControl: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
idleTimeoutMinutes: () => number;
|
||||
}
|
||||
|
||||
@@ -48,33 +36,14 @@ function provider(): LiveSessionsV1 | undefined {
|
||||
return getServiceImplementation<LiveSessionsV1>(
|
||||
LIVE_SESSIONS_SERVICE,
|
||||
"^1.0.0",
|
||||
SSH_PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
export const liveTerminalSessions = {
|
||||
getSession(sessionId: string): LiveSessionInfo | null {
|
||||
return provider()?.getSession(sessionId) ?? null;
|
||||
},
|
||||
listForUser(userId: string): LiveSessionInfo[] {
|
||||
return provider()?.listForUser(userId) ?? [];
|
||||
},
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
provider()?.ownerEndSession(sessionId, reason);
|
||||
},
|
||||
disconnectShareParticipants(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
): number {
|
||||
return provider()?.disconnectParticipants(sessionId, shareId, options) ?? 0;
|
||||
},
|
||||
setRoomShareControl(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
): void {
|
||||
provider()?.setRoomShareControl(sessionId, shareId, controllerUserId);
|
||||
},
|
||||
/** How long the terminal keeps a detached session, or null while it is off. */
|
||||
idleTimeoutMinutes(): number | null {
|
||||
return provider()?.idleTimeoutMinutes() ?? null;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { liveTerminalSessions } from "../live-terminal-sessions.js";
|
||||
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||
// through the same in-core bridge its router is mounted behind, since
|
||||
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||
// statically import across that boundary. See
|
||||
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||
// README -- this is an accepted, temporary coupling until collab/
|
||||
// session-sharing themselves become plugins.
|
||||
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../guacamole-sessions.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 = liveTerminalSessions.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 = liveTerminalSessions.getSession(sessionId);
|
||||
return !!session && session.isConnected;
|
||||
}
|
||||
return !!getGuacSessionInfo(sessionId);
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
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 { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { liveTerminalSessions } from "../live-terminal-sessions.js";
|
||||
import {
|
||||
isLiveSession,
|
||||
isLiveSessionOwnedBy,
|
||||
isSharingEnabledForHost,
|
||||
} from "./live-sessions.js";
|
||||
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||
// through the same in-core bridge its router is mounted behind, since
|
||||
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||
// statically import across that boundary. See
|
||||
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||
// README -- this is an accepted, temporary coupling until session-sharing
|
||||
// becomes a plugin of its own.
|
||||
import { createGuacamoleJoinToken } from "../guacamole-sessions.js";
|
||||
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
const DEFAULT_EXPIRY_HOURS = 24;
|
||||
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||
|
||||
type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
type PermissionLevel = "read-only" | "read-write";
|
||||
|
||||
interface ResolveRateEntry {
|
||||
count: number;
|
||||
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;
|
||||
|
||||
function isResolveRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = resolveAttempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
|
||||
resolveAttempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > RESOLVE_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of resolveAttempts.entries()) {
|
||||
if (now - entry.windowStart > RESOLVE_WINDOW_MS)
|
||||
resolveAttempts.delete(ip);
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/create:
|
||||
* post:
|
||||
* summary: Create a session share (link or targeted user)
|
||||
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - hostId
|
||||
* - sessionId
|
||||
* - protocol
|
||||
* - shareType
|
||||
* - permissionLevel
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* sessionId:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [ssh, rdp, vnc, telnet]
|
||||
* shareType:
|
||||
* type: string
|
||||
* enum: [link, user]
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [read-only, read-write]
|
||||
* expiryHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share created
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 403:
|
||||
* description: Sharing disabled, or caller does not own the session
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const {
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType,
|
||||
targetUserId,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
if (!["link", "user"].includes(shareType)) {
|
||||
return res.status(400).json({ error: "Invalid shareType" });
|
||||
}
|
||||
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
||||
return res.status(400).json({ error: "Invalid permissionLevel" });
|
||||
}
|
||||
if (shareType === "user" && !targetUserId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "targetUserId is required for user shares" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
const { enabled: sharingEnabled } =
|
||||
await isSharingEnabledForHost(numericHostId);
|
||||
if (!sharingEnabled) {
|
||||
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" });
|
||||
}
|
||||
|
||||
if (shareType === "user") {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
targetUserId,
|
||||
numericHostId,
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shareId = crypto.randomUUID();
|
||||
const linkToken =
|
||||
shareType === "link"
|
||||
? crypto.randomBytes(24).toString("base64url")
|
||||
: null;
|
||||
const expiresAt = computeExpiresAt(expiryHours);
|
||||
|
||||
const created = await createCurrentSessionShareRepository().create({
|
||||
id: shareId,
|
||||
hostId: numericHostId,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
sessionId: String(sessionId),
|
||||
tabInstanceId: tabInstanceId ?? null,
|
||||
shareType,
|
||||
targetUserId: shareType === "user" ? targetUserId : null,
|
||||
linkToken,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
res.json({
|
||||
shareId: created.id,
|
||||
linkToken: created.linkToken,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create session share", error, {
|
||||
operation: "session_share_create_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create session share" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/host/{hostId}/active:
|
||||
* get:
|
||||
* summary: List active session shares for a host
|
||||
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active shares
|
||||
* 400:
|
||||
* description: Invalid host id
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/host/:hostId/active",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
if (!hostId || Number.isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const shares =
|
||||
await createCurrentSessionShareRepository().findActiveSharesForHost(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json({ shares });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to list session shares", error, {
|
||||
operation: "session_share_list_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list session shares" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/{shareId}:
|
||||
* delete:
|
||||
* summary: Revoke a session share
|
||||
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share revoked
|
||||
* 403:
|
||||
* description: Not authorized to revoke this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.delete(
|
||||
"/:shareId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const shareId = String(req.params.shareId);
|
||||
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
|
||||
let revoked = await repository.revoke(shareId, userId);
|
||||
if (!revoked) {
|
||||
if (await permissionManager.isAdmin(userId)) {
|
||||
revoked = await repository.revokeAsAdmin(shareId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!revoked) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Not authorized to revoke this share" });
|
||||
}
|
||||
|
||||
// Best-effort kick of live participants. SSH sessions support ending
|
||||
// just the guests via ownerEndSession; guac joins aren't force-kickable
|
||||
// from a REST handler (guacamole-lite exposes no kick API), so a revoked
|
||||
// guac link only blocks *future* resolves until the guest's own socket ends.
|
||||
if (share.protocol === "ssh") {
|
||||
try {
|
||||
liveTerminalSessions.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session share revoked by owner",
|
||||
);
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to revoke session share", error, {
|
||||
operation: "session_share_revoke_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke session share" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/resolve/{linkToken}:
|
||||
* get:
|
||||
* summary: Resolve a guest share link
|
||||
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: linkToken
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved share connection info
|
||||
* 404:
|
||||
* description: Link not found, expired, revoked, or sharing disabled
|
||||
* 429:
|
||||
* description: Too many requests
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
if (isResolveRateLimited(ip)) {
|
||||
return res.status(429).json({ error: "Too many requests" });
|
||||
}
|
||||
|
||||
const linkToken = String(req.params.linkToken);
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findByLinkToken(linkToken);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const { enabled: sharingEnabled } = await isSharingEnabledForHost(
|
||||
share.hostId,
|
||||
);
|
||||
if (!sharingEnabled) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const protocol = share.protocol as Protocol;
|
||||
if (!isLiveSession(protocol, share.sessionId)) {
|
||||
return res.status(404).json({ error: "Session is no longer active" });
|
||||
}
|
||||
|
||||
// Field-by-field by design - never spread a host row into this response.
|
||||
// Anonymous guests must never see hostname/IP/username/hostId (decision #5).
|
||||
const response: {
|
||||
protocol: Protocol;
|
||||
permissionLevel: PermissionLevel;
|
||||
wsPath: string;
|
||||
connectParams?: Record<string, string>;
|
||||
} = {
|
||||
protocol,
|
||||
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||
// Advisory only: the client builds its own socket URL per deployment
|
||||
// (see pluginWsUrl). Kept accurate rather than left pointing at the
|
||||
// ports these two moved off in A4.
|
||||
wsPath:
|
||||
protocol === "ssh"
|
||||
? `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(linkToken)}`
|
||||
: "/plugin-ws/remote-desktop/display",
|
||||
};
|
||||
|
||||
if (protocol !== "ssh") {
|
||||
const joinToken = createGuacamoleJoinToken(
|
||||
share.sessionId,
|
||||
share.permissionLevel === "read-only",
|
||||
);
|
||||
response.connectParams = { token: joinToken };
|
||||
}
|
||||
|
||||
try {
|
||||
await repository.touchShareUsage(share.id);
|
||||
await repository.recordParticipantJoin(share.id, null, "Guest");
|
||||
} catch {
|
||||
// best-effort, never fail the resolve response over audit bookkeeping
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve session share link", error, {
|
||||
operation: "session_share_resolve_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to resolve share link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/{shareId}/end:
|
||||
* post:
|
||||
* summary: End a shared session for all participants
|
||||
* description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session ended
|
||||
* 403:
|
||||
* description: Not the owner of this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/:shareId/end",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const shareId = String(req.params.shareId);
|
||||
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
if (share.ownerUserId !== userId) {
|
||||
return res.status(403).json({ error: "Not the owner of this share" });
|
||||
}
|
||||
|
||||
if (share.protocol === "ssh") {
|
||||
liveTerminalSessions.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session ended by owner",
|
||||
);
|
||||
}
|
||||
// Guac protocols: no kick API available from a REST handler in v1 - see
|
||||
// DELETE /:shareId for the same limitation.
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to end shared session", error, {
|
||||
operation: "session_share_end_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to end shared session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -435,7 +435,7 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
services: {
|
||||
provide: (service, implementation) => {
|
||||
provide: (service, implementation, options) => {
|
||||
// Declared AND provided: a plugin cannot publish a service its
|
||||
// manifest never mentioned.
|
||||
const entry = manifest.provides?.find(
|
||||
@@ -446,9 +446,16 @@ export function createPluginContext(
|
||||
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
|
||||
);
|
||||
}
|
||||
const name = options?.name ?? "";
|
||||
if (entry.names ? !entry.names.includes(name) : name !== "") {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot provide service "${service}" as "${name}": the manifest's provides[].names does not list it`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = serviceRegistry.provideService({
|
||||
service,
|
||||
name,
|
||||
version: entry.version,
|
||||
permission: entry.permission,
|
||||
pluginId,
|
||||
@@ -463,14 +470,21 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
get: (service, options) =>
|
||||
serviceRegistry.createServiceHandle(service, pluginId, {
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
}),
|
||||
serviceRegistry.createServiceHandle(
|
||||
service,
|
||||
pluginId,
|
||||
{
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
},
|
||||
options?.provider ?? "",
|
||||
),
|
||||
|
||||
providers: (service) => serviceRegistry.listProviderNames(service),
|
||||
},
|
||||
|
||||
secrets: {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* - core registers the SSH connection pool as "ssh.transport" at boot, so
|
||||
* everything that needs a pooled connection resolves a provider rather
|
||||
* than importing the pool directly.
|
||||
* - the ssh-terminal plugin publishes "terminal.sessions" so collab and
|
||||
* session-sharing can reach live sessions without importing a plugin.
|
||||
* - the session-sharing plugin publishes "sessions.sharing.guests", guest
|
||||
* link resolution for the terminal, keyed by a token rather than a user.
|
||||
*
|
||||
* Deliberately NOT reachable from a worker plugin's ctx. Values here are live
|
||||
* objects -- pools, session managers, sockets -- and handing one across the
|
||||
|
||||
@@ -42,6 +42,11 @@ import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
export interface ServiceRegistration {
|
||||
service: string;
|
||||
/**
|
||||
* The provider's name, for a service several plugins provide side by side
|
||||
* (sessions.live, keyed by session type). Empty for the one unnamed provider.
|
||||
*/
|
||||
name: string;
|
||||
version: string;
|
||||
permission: string;
|
||||
/** The plugin that provided it. */
|
||||
@@ -57,9 +62,10 @@ export interface ServiceRegistration {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const services = new Map<string, ServiceRegistration>();
|
||||
/** service -> provider name ("" when unnamed) -> registration. */
|
||||
const services = new Map<string, Map<string, ServiceRegistration>>();
|
||||
/**
|
||||
* What a service looked like before it was revoked.
|
||||
* What a provider looked like before it was revoked, by the same two keys.
|
||||
*
|
||||
* A handle taken while the provider was up must keep throwing a named error
|
||||
* after it goes away, rather than degrading to "undefined is not a function",
|
||||
@@ -68,6 +74,14 @@ const services = new Map<string, ServiceRegistration>();
|
||||
const retired = new Map<string, { pluginId: string; methods: Set<string> }>();
|
||||
let generationCounter = 0;
|
||||
|
||||
function retiredKey(service: string, name: string): string {
|
||||
return `${service}#${name}`;
|
||||
}
|
||||
|
||||
function lookup(service: string, name = ""): ServiceRegistration | undefined {
|
||||
return services.get(service)?.get(name);
|
||||
}
|
||||
|
||||
/** Thrown when the provider went away while a consumer still held a handle. */
|
||||
export class PluginServiceUnavailableError extends Error {
|
||||
readonly code = "EPLUGINSVCGONE";
|
||||
@@ -115,22 +129,31 @@ export class PluginServiceActorError extends Error {
|
||||
}
|
||||
|
||||
export function provideService(
|
||||
registration: Omit<ServiceRegistration, "generation">,
|
||||
registration: Omit<ServiceRegistration, "generation" | "name"> & {
|
||||
name?: string;
|
||||
},
|
||||
): ServiceRegistration {
|
||||
const existing = services.get(registration.service);
|
||||
const name = registration.name ?? "";
|
||||
const existing = lookup(registration.service, name);
|
||||
if (existing) {
|
||||
pluginLogger.warn(
|
||||
`Service "${registration.service}" is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
{ operation: "plugin_service_registry" },
|
||||
);
|
||||
}
|
||||
|
||||
const entry: ServiceRegistration = {
|
||||
...registration,
|
||||
name,
|
||||
generation: ++generationCounter,
|
||||
};
|
||||
services.set(registration.service, entry);
|
||||
retired.delete(registration.service);
|
||||
let providers = services.get(registration.service);
|
||||
if (!providers) {
|
||||
providers = new Map();
|
||||
services.set(registration.service, providers);
|
||||
}
|
||||
providers.set(name, entry);
|
||||
retired.delete(retiredKey(registration.service, name));
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -143,13 +166,14 @@ export function revokeService(
|
||||
service: string,
|
||||
registration?: ServiceRegistration,
|
||||
): boolean {
|
||||
const current = services.get(service);
|
||||
const name = registration?.name ?? "";
|
||||
const current = lookup(service, name);
|
||||
if (!current) return false;
|
||||
if (registration && current.generation !== registration.generation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
retired.set(service, {
|
||||
retired.set(retiredKey(service, name), {
|
||||
pluginId: current.pluginId,
|
||||
methods: new Set(
|
||||
Object.keys(current.implementation).filter(
|
||||
@@ -157,13 +181,22 @@ export function revokeService(
|
||||
),
|
||||
),
|
||||
});
|
||||
return services.delete(service);
|
||||
const providers = services.get(service)!;
|
||||
providers.delete(name);
|
||||
if (providers.size === 0) services.delete(service);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getRegistration(
|
||||
service: string,
|
||||
name = "",
|
||||
): ServiceRegistration | undefined {
|
||||
return services.get(service);
|
||||
return lookup(service, name);
|
||||
}
|
||||
|
||||
/** The names of every provider of a service right now ("" when unnamed). */
|
||||
export function listProviderNames(service: string): string[] {
|
||||
return [...(services.get(service)?.keys() ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,15 +211,16 @@ export function getRegistration(
|
||||
export function getServiceImplementation<T extends object>(
|
||||
service: string,
|
||||
versionRange: string,
|
||||
name = "",
|
||||
): T | undefined {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) return undefined;
|
||||
if (!semver.satisfies(registration.version, versionRange)) return undefined;
|
||||
return registration.implementation as T;
|
||||
}
|
||||
|
||||
export function listServices(): ServiceRegistration[] {
|
||||
return [...services.values()];
|
||||
return [...services.values()].flatMap((providers) => [...providers.values()]);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
@@ -214,7 +248,13 @@ export function resolveRequirements(
|
||||
const missingOptional: string[] = [];
|
||||
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
const registration = services.get(requirement.service);
|
||||
// Any one provider satisfies it; a consumer of a named service picks the
|
||||
// provider per call.
|
||||
const providers = [...(services.get(requirement.service)?.values() ?? [])];
|
||||
const registration =
|
||||
providers.find((candidate) =>
|
||||
semver.satisfies(candidate.version, requirement.versionRange),
|
||||
) ?? providers[0];
|
||||
|
||||
if (!registration) {
|
||||
recordUnsatisfied(
|
||||
@@ -292,6 +332,7 @@ export function createServiceHandle<T extends object>(
|
||||
service: string,
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
name = "",
|
||||
): T {
|
||||
const target = Object.create(null) as T;
|
||||
|
||||
@@ -301,10 +342,12 @@ export function createServiceHandle<T extends object>(
|
||||
|
||||
if (property === "asUser") {
|
||||
return (userId: string) =>
|
||||
createServiceHandle<T>(service, consumerPluginId, {
|
||||
...context,
|
||||
resolveUserId: () => userId,
|
||||
});
|
||||
createServiceHandle<T>(
|
||||
service,
|
||||
consumerPluginId,
|
||||
{ ...context, resolveUserId: () => userId },
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved at call time, not here: the provider may have gone away
|
||||
@@ -312,9 +355,9 @@ export function createServiceHandle<T extends object>(
|
||||
// must keep throwing the typed error rather than degrading to
|
||||
// `undefined is not a function`, which tells a consumer nothing about
|
||||
// why its dependency vanished.
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) {
|
||||
const gone = retired.get(service);
|
||||
const gone = retired.get(retiredKey(service, name));
|
||||
if (!gone?.methods.has(property)) return undefined;
|
||||
return () => {
|
||||
throw new PluginServiceUnavailableError(service, gone.pluginId);
|
||||
@@ -332,17 +375,18 @@ export function createServiceHandle<T extends object>(
|
||||
consumerPluginId,
|
||||
context,
|
||||
registration.generation,
|
||||
name,
|
||||
);
|
||||
},
|
||||
|
||||
has(_target, property) {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration || typeof property !== "string") return false;
|
||||
return typeof registration.implementation[property] === "function";
|
||||
},
|
||||
|
||||
ownKeys() {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) return [];
|
||||
return Object.keys(registration.implementation).filter(
|
||||
(key) => typeof registration.implementation[key] === "function",
|
||||
@@ -350,7 +394,7 @@ export function createServiceHandle<T extends object>(
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration || typeof property !== "string") return undefined;
|
||||
if (typeof registration.implementation[property] !== "function") {
|
||||
return undefined;
|
||||
@@ -367,8 +411,9 @@ async function invokeGuarded(
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
expectedGeneration: number,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
|
||||
// Re-checked here rather than trusted from the get trap: a call is async and
|
||||
// the provider can be disabled between resolving the method and running it.
|
||||
|
||||
@@ -369,6 +369,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runTmuxMonitorSettingsMigration } =
|
||||
await import("./utils/crypto-migration/tmux-monitor-settings-migration.js");
|
||||
await runTmuxMonitorSettingsMigration();
|
||||
|
||||
const { runSessionSharingSettingsMigration } =
|
||||
await import("./utils/crypto-migration/session-sharing-settings-migration.js");
|
||||
await runSessionSharingSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -29,8 +29,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
"api_keys",
|
||||
"session_shares",
|
||||
"session_share_participants",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
|
||||
|
||||
describe("SessionShareRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionShareRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SessionShareRepository(context, onWrite);
|
||||
}
|
||||
|
||||
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
|
||||
const FAR_PAST = "2000-01-01T00:00:00.000Z";
|
||||
|
||||
it("creates a share and finds it by id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const created = await repo.create({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
const found = await repo.findById("share-1");
|
||||
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-revoked",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-revoked",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
|
||||
|
||||
await repo.revoke("share-revoked", "owner-1");
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes expired shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-expired",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-expired")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "vnc",
|
||||
sessionId: "guac-session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-active",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const found = await repo.findByLinkToken("token-active");
|
||||
expect(found).toMatchObject({
|
||||
id: "share-active",
|
||||
protocol: "vnc",
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
});
|
||||
|
||||
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "share-user-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
// Expired user share for the same target - must be excluded
|
||||
await repo.create({
|
||||
id: "share-user-expired",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
// Link share, not targeting a user - must be excluded even though it's active
|
||||
await repo.create({
|
||||
id: "share-link-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-unrelated",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const shares = await repo.findSharesTargetingUser("guest-1");
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0]).toMatchObject({
|
||||
id: "share-user-active",
|
||||
hostName: "host-one",
|
||||
ownerUsername: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("revoke only affects the requesting owner's own share", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-owned",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-owned",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
|
||||
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("revokeAsAdmin revokes regardless of owner", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-admin-target",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-admin",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
|
||||
expect(await repo.findByLinkToken("token-admin")).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteExpiredShares removes only expired rows", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-old",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-old",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-current",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-current",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const deletedCount = await repo.deleteExpiredShares();
|
||||
expect(deletedCount).toBe(1);
|
||||
expect(await repo.findById("share-old")).toBeNull();
|
||||
expect(await repo.findById("share-current")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-touch",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-touch",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
|
||||
let row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(1);
|
||||
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
|
||||
row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and closes participant joins", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-participants",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-participants",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const participant = await repo.recordParticipantJoin(
|
||||
"share-participants",
|
||||
null,
|
||||
"Guest",
|
||||
);
|
||||
expect(participant).toMatchObject({
|
||||
shareId: "share-participants",
|
||||
userId: null,
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
expect(participant.leftAt).toBeNull();
|
||||
|
||||
await repo.recordParticipantLeave(participant.id);
|
||||
});
|
||||
|
||||
it("write hook fires on mutating operations", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "share-write-hook",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-write-hook",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
await repo.revoke("share-write-hook", "owner-1");
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deleteSharesForHost removes all shares for a host", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-host-1a",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1a",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-1b",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1b",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-2",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-h2",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.deleteSharesForHost(1)).toBe(2);
|
||||
expect(await repo.findById("share-host-2")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe("plugins route", () => {
|
||||
id: "guest-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["hosts:read"],
|
||||
contributes: { guest: true },
|
||||
contributes: { guest: true, guestViews: ["shared"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -225,7 +225,10 @@ describe("plugins route", () => {
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"guest-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({ guest: true });
|
||||
expect(body[0].contributes).toEqual({
|
||||
guest: true,
|
||||
guestViews: ["shared"],
|
||||
});
|
||||
expect(body[0]).not.toHaveProperty("capabilities");
|
||||
expect(body[0]).not.toHaveProperty("lastError");
|
||||
});
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
listener: null as ((roomId: string, message: object) => void) | null,
|
||||
publish: vi.fn(async () => undefined),
|
||||
updatePresence: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: (listener: (roomId: string, message: object) => void) => {
|
||||
runtime.listener = listener;
|
||||
},
|
||||
publish: runtime.publish,
|
||||
updatePresence: runtime.updatePresence,
|
||||
onlineUsers: async (
|
||||
_roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
) => users,
|
||||
},
|
||||
}));
|
||||
|
||||
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", async () => {
|
||||
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(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(b.send).toHaveBeenCalled());
|
||||
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(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
collabRoomHub.unsubscribe(a2);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
collabRoomHub.unsubscribe(b);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("subscribing the same socket twice keeps one subscription", async () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
await vi.waitFor(() =>
|
||||
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);
|
||||
});
|
||||
|
||||
it("fans out remote Redis events but keeps internal events server-side", () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-4", { ws, userId: "a", username: "A" });
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
runtime.listener?.("room-4", { type: "collab_members_changed" });
|
||||
runtime.listener?.("room-4", { type: "collab_internal_stage_revoked" });
|
||||
|
||||
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,689 +0,0 @@
|
||||
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[]>,
|
||||
controllers: new Map<string, string>(),
|
||||
requests: new Map<
|
||||
string,
|
||||
Map<string, { userId: string; username: string; requestedAt: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
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-sessions.js", () => ({
|
||||
createGuacamoleJoinToken: (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/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: vi.fn(),
|
||||
publish: vi.fn(async () => undefined),
|
||||
getController: async (roomId: string) =>
|
||||
state.controllers.get(roomId) ?? null,
|
||||
setController: async (roomId: string, userId: string | null) => {
|
||||
if (userId) state.controllers.set(roomId, userId);
|
||||
else state.controllers.delete(roomId);
|
||||
},
|
||||
listRequests: async (roomId: string) =>
|
||||
Array.from(state.requests.get(roomId)?.values() ?? []).sort((a, b) =>
|
||||
a.requestedAt.localeCompare(b.requestedAt),
|
||||
),
|
||||
upsertRequest: async (
|
||||
roomId: string,
|
||||
request: { userId: string; username: string; requestedAt: string },
|
||||
) => {
|
||||
let requests = state.requests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
state.requests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
},
|
||||
removeRequest: async (roomId: string, userId: string) => {
|
||||
state.requests.get(roomId)?.delete(userId);
|
||||
},
|
||||
clearRequests: async (roomId: string) => {
|
||||
state.requests.delete(roomId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
disconnectShareParticipants: vi.fn(() => 0),
|
||||
},
|
||||
}));
|
||||
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);
|
||||
},
|
||||
replaceStage: async (
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: Partial<Room>,
|
||||
) => {
|
||||
const room = state.rooms.get(roomId)!;
|
||||
if (room.stageShareId !== expectedShareId) return false;
|
||||
Object.assign(room, stage);
|
||||
return true;
|
||||
},
|
||||
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.controllers.clear();
|
||||
state.requests.clear();
|
||||
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("never exposes the guest bearer token in room responses", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.rooms.get(roomId)!.guestLinkToken = "secret-token";
|
||||
|
||||
const list = await as("alice", () => invoke("get", "/rooms"));
|
||||
const listedRoom = (list as Awaited<ReturnType<typeof invoke>>).jsonBody!
|
||||
.rooms as Array<Record<string, unknown>>;
|
||||
expect(listedRoom[0]).not.toHaveProperty("guestLinkToken");
|
||||
expect(listedRoom[0]).toHaveProperty("guestLinkEnabled", true);
|
||||
|
||||
const detail = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
);
|
||||
const room = (detail as Awaited<ReturnType<typeof invoke>>).jsonBody!
|
||||
.room as Record<string, unknown>;
|
||||
expect(room).not.toHaveProperty("guestLinkToken");
|
||||
expect(room).toHaveProperty("guestLinkEnabled", true);
|
||||
});
|
||||
|
||||
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(await 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(await 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).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await control("bob", null)).statusCode).toBe(403);
|
||||
expect((await control("alice", null)).statusCode).toBe(200);
|
||||
expect(await 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",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await as("bob", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
)
|
||||
).jsonBody!.controlRequests,
|
||||
).toEqual([expect.objectContaining({ userId: "bob" })]);
|
||||
|
||||
const repeated = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", {
|
||||
params: { id: roomId },
|
||||
}),
|
||||
);
|
||||
expect((repeated as { statusCode: number }).statusCode).toBe(429);
|
||||
|
||||
const listed = await invoke("get", "/rooms/:id/control/requests", {
|
||||
params: { id: roomId },
|
||||
});
|
||||
expect(listed.jsonBody!.requests).toEqual([
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await invoke("delete", "/rooms/:id/control/requests/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
});
|
||||
expect(state.requests.get(roomId)?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
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: `/plugin-ws/ssh-terminal/terminal?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();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collabRuntimeStore } from "../../../hosts/collab/runtime-store.js";
|
||||
|
||||
describe("collabRuntimeStore local fallback", () => {
|
||||
it("stores stage control and an ordered, deduplicated request queue", async () => {
|
||||
const roomId = `fallback-${crypto.randomUUID()}`;
|
||||
|
||||
await collabRuntimeStore.setController(roomId, "alice");
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBe("alice");
|
||||
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:02.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "alice",
|
||||
username: "Alice",
|
||||
requestedAt: "2026-08-25T00:00:01.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([
|
||||
expect.objectContaining({ userId: "alice" }),
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await collabRuntimeStore.removeRequest(roomId, "alice");
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toHaveLength(1);
|
||||
|
||||
await collabRuntimeStore.clearRequests(roomId);
|
||||
await collabRuntimeStore.setController(roomId, null);
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([]);
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,508 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
globalSharingEnabled: true,
|
||||
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||
guacSessions: new Map<
|
||||
string,
|
||||
{ ownerUserId: string; hostId: number; protocol: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
admins: new Set<string>(),
|
||||
}));
|
||||
|
||||
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/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
_action: string,
|
||||
) => ({
|
||||
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||
}),
|
||||
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return { ...session };
|
||||
},
|
||||
ownerEndSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||
createGuacamoleJoinToken: (
|
||||
guacamoleConnectionId: string,
|
||||
readOnly: boolean,
|
||||
) => `join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
...input,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastJoinedAt: null,
|
||||
joinCount: 0,
|
||||
};
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||
findByLinkToken: async (linkToken: string) => {
|
||||
for (const share of state.shares.values()) {
|
||||
if (
|
||||
share.linkToken === linkToken &&
|
||||
!share.revokedAt &&
|
||||
(share.expiresAt as string) > new Date().toISOString()
|
||||
) {
|
||||
return share;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||
return [...state.shares.values()].filter(
|
||||
(s) =>
|
||||
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||
);
|
||||
},
|
||||
revoke: async (shareId: string, requestingUserId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
revokeAsAdmin: async (shareId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
touchShareUsage: async () => {},
|
||||
recordParticipantJoin: async () => ({ id: 1 }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: async () => state.globalSharingEnabled,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
state.hosts.get(hostId)?.userId ?? null,
|
||||
findHostById: async (hostId: number) => {
|
||||
const host = state.hosts.get(hostId);
|
||||
if (!host) return null;
|
||||
return { allowSessionSharing: host.allowSessionSharing };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../hosts/session-sharing/routes.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
function findHandlers(method: string, path: 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}`);
|
||||
return layer.route.stack.map((s) => s.handle);
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}) {
|
||||
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 as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const handlers = findHandlers(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
|
||||
for (const handler of handlers) {
|
||||
let calledNext = false;
|
||||
await handler(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.globalSharingEnabled = true;
|
||||
state.hosts = new Map([
|
||||
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||
]);
|
||||
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||
state.sshSessions = new Map([
|
||||
["session-1", { userId: "user-1", isConnected: true }],
|
||||
]);
|
||||
state.guacSessions = new Map([
|
||||
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||
]);
|
||||
state.shares = new Map();
|
||||
state.admins = new Set();
|
||||
});
|
||||
|
||||
describe("POST /session-sharing/create", () => {
|
||||
it("rejects a caller who does not own the live session", async () => {
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "You do not own this live session",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a link share for the session owner", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a user share when the target lacks host access", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "user",
|
||||
targetUserId: "no-access-user",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||
state.globalSharingEnabled = false;
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Session sharing is disabled for this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 2,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||
async function createActiveLinkShare(
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
) {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()];
|
||||
return share as { linkToken: string; id: string };
|
||||
}
|
||||
|
||||
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.jsonBody as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(body).toLowerCase();
|
||||
|
||||
expect(body).not.toHaveProperty("hostname");
|
||||
expect(body).not.toHaveProperty("ip");
|
||||
expect(body).not.toHaveProperty("username");
|
||||
expect(body).not.toHaveProperty("hostId");
|
||||
expect(body).not.toHaveProperty("hostName");
|
||||
expect(serialized).not.toContain("10.0.0");
|
||||
expect(serialized).not.toContain("hostname");
|
||||
expect(serialized).not.toContain('"ip"');
|
||||
expect(serialized).not.toContain("username");
|
||||
});
|
||||
|
||||
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.jsonBody).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a fresh join token for guac protocols", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "guac-conn-1",
|
||||
protocol: "vnc",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as {
|
||||
linkToken: string;
|
||||
}[];
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||
token: "join-token:guac-conn-1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown link token", async () => {
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "does-not-exist" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a revoked link token", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an expired link token", async () => {
|
||||
state.shares.set("share-expired", {
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "expired-token",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "expired-token" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
state.globalSharingEnabled = false;
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /session-sharing/:shareId", () => {
|
||||
it("allows the owner to revoke their own share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a non-owner, non-admin caller", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("allows an admin to revoke someone else's share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "admin-1";
|
||||
state.admins.add("admin-1");
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* ctx.services with named providers: a plugin may only register the names
|
||||
* its manifest lists, a consumer reaches one provider by name, and each is
|
||||
* disposed with the plugin that provided it.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import {
|
||||
clearServiceRegistry,
|
||||
getRegistration,
|
||||
} from "../../plugins/service-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string, names?: string[]) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
provides: [
|
||||
{
|
||||
service: "sample.live",
|
||||
version: "1.0.0",
|
||||
permission: `${pluginId}.use`,
|
||||
...(names ? { names } : {}),
|
||||
},
|
||||
],
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
afterEach(() => clearServiceRegistry());
|
||||
|
||||
describe("ctx.services named providers", () => {
|
||||
it("registers a declared name and lists it", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
|
||||
|
||||
expect(getRegistration("sample.live", "ssh")?.pluginId).toBe(
|
||||
"terminal-like",
|
||||
);
|
||||
expect(ctx.services.providers("sample.live")).toEqual(["ssh"]);
|
||||
});
|
||||
|
||||
it("refuses a name the manifest does not list", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
expect(() =>
|
||||
ctx.services.provide("sample.live", {}, { name: "rdp" }),
|
||||
).toThrow(/provides\[\]\.names does not list it/);
|
||||
expect(() => ctx.services.provide("sample.live", {})).toThrow(
|
||||
/does not list it/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a name when the manifest declares an unnamed service", () => {
|
||||
const { ctx } = contextFor("plain");
|
||||
expect(() =>
|
||||
ctx.services.provide("sample.live", {}, { name: "ssh" }),
|
||||
).toThrow(/does not list it/);
|
||||
});
|
||||
|
||||
it("reaches one provider by name through a handle", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
|
||||
|
||||
const named = ctx.services.get<{ get: () => Promise<number> }>(
|
||||
"sample.live",
|
||||
{ provider: "ssh" },
|
||||
);
|
||||
const other = ctx.services.get("sample.live", { provider: "rdp" });
|
||||
expect("get" in named).toBe(true);
|
||||
expect("get" in other).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -589,6 +589,19 @@ describe("panels, dashboard cards and guest pages", () => {
|
||||
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
|
||||
});
|
||||
|
||||
it("accepts guest views on a guest plugin and refuses them otherwise", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { guest: true, guestViews: ["shared"] } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { guestViews: ["Shared View"] } }),
|
||||
).join();
|
||||
expect(errors).toMatch(/contributes\.guestViews entries must be/);
|
||||
expect(errors).toMatch(/needs "contributes\.guest"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a view and a non-boolean guest", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
@@ -602,3 +615,41 @@ describe("panels, dashboard cards and guest pages", () => {
|
||||
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("named service providers", () => {
|
||||
const provides = (names: unknown) => [
|
||||
{
|
||||
service: "sample.live",
|
||||
version: "1.0.0",
|
||||
permission: "sample-plugin.use",
|
||||
names,
|
||||
},
|
||||
];
|
||||
const permissions = {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("accepts a list of provider names", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ provides: provides(["ssh", "rdp"]), contributes: permissions }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an empty, duplicated or malformed list", () => {
|
||||
for (const names of [[], ["ssh", "ssh"], ["SSH"], "ssh"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ provides: provides(names), contributes: permissions }),
|
||||
).join(),
|
||||
).toMatch(/provides\[0\]\.names/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
createServiceHandle,
|
||||
getRegistration,
|
||||
getServiceImplementation,
|
||||
listProviderNames,
|
||||
listServices,
|
||||
provideService,
|
||||
resolveRequirements,
|
||||
@@ -99,6 +100,94 @@ describe("service registry", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("named providers", () => {
|
||||
it("keeps several providers of one service side by side", () => {
|
||||
const ssh = register({
|
||||
name: "ssh",
|
||||
implementation: { kind: async () => "ssh" },
|
||||
});
|
||||
const rdp = register({
|
||||
name: "rdp",
|
||||
pluginId: "other-plugin",
|
||||
implementation: { kind: async () => "rdp" },
|
||||
});
|
||||
|
||||
expect(listProviderNames("testplugin.greet").sort()).toEqual([
|
||||
"rdp",
|
||||
"ssh",
|
||||
]);
|
||||
expect(getRegistration("testplugin.greet", "ssh")).toBe(ssh);
|
||||
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
||||
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
||||
expect(listServices()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hands core a named implementation only by its name", () => {
|
||||
const ssh = register({ name: "ssh" });
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0", "ssh"),
|
||||
).toBe(ssh.implementation);
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes one provider and leaves the other", () => {
|
||||
const ssh = register({ name: "ssh" });
|
||||
const rdp = register({ name: "rdp" });
|
||||
|
||||
expect(revokeService("testplugin.greet", ssh)).toBe(true);
|
||||
expect(getRegistration("testplugin.greet", "ssh")).toBeUndefined();
|
||||
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
||||
});
|
||||
|
||||
it("routes a named handle to its provider and fails typed once it goes", async () => {
|
||||
const ssh = register({
|
||||
name: "ssh",
|
||||
implementation: { hello: async () => "from ssh" },
|
||||
});
|
||||
register({
|
||||
name: "rdp",
|
||||
implementation: { hello: async () => "from rdp" },
|
||||
});
|
||||
const context = {
|
||||
resolveUserId: () => "user-1",
|
||||
hasPermission: async () => true,
|
||||
audit: () => {},
|
||||
};
|
||||
const handle = createServiceHandle<{ hello: () => Promise<string> }>(
|
||||
"testplugin.greet",
|
||||
"consumer-plugin",
|
||||
context,
|
||||
"ssh",
|
||||
);
|
||||
const unnamed = createServiceHandle<{ hello?: () => Promise<string> }>(
|
||||
"testplugin.greet",
|
||||
"consumer-plugin",
|
||||
context,
|
||||
);
|
||||
|
||||
await expect(handle.hello()).resolves.toBe("from ssh");
|
||||
expect("hello" in unnamed).toBe(false);
|
||||
|
||||
revokeService("testplugin.greet", ssh);
|
||||
expect(() => handle.hello()).toThrow(PluginServiceUnavailableError);
|
||||
});
|
||||
|
||||
it("satisfies a requirement with any compatible provider", () => {
|
||||
register({ name: "rdp", version: "2.0.0" });
|
||||
register({ name: "ssh", version: "1.2.0" });
|
||||
|
||||
expect(
|
||||
resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
).satisfied,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("is satisfied when the version is in range", () => {
|
||||
register({ version: "1.2.0" });
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The session sharing settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an instance with sharing switched off, and a
|
||||
* host with sharing switched off, must stay off once the plugin reads its own
|
||||
* settings. Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const blockedHostIds: number[] = [];
|
||||
const coreSettings = new Map<string, string>();
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => blockedHostIds.map((id) => ({ id })),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSessionSharingSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
|
||||
|
||||
function stored(scopeId: string | null, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === scopeId && entry.key === key,
|
||||
);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
blockedHostIds.length = 0;
|
||||
coreSettings.clear();
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runSessionSharingSettingsMigration", () => {
|
||||
it("moves a disabled global switch into the admin setting", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "false");
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.movedGlobal).toBe(true);
|
||||
expect(stored(null, "globallyEnabled")).toBe(false);
|
||||
expect(settingsRows[0]).toMatchObject({
|
||||
pluginId: "session-sharing",
|
||||
scope: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("moves hosts that turned sharing off into host settings", async () => {
|
||||
blockedHostIds.push(4, 9);
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.movedHosts).toBe(2);
|
||||
expect(stored("4", "allowSessionSharing")).toBe(false);
|
||||
expect(stored("9", "allowSessionSharing")).toBe(false);
|
||||
});
|
||||
|
||||
it("writes nothing when sharing was on everywhere", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "true");
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "false");
|
||||
blockedHostIds.push(2);
|
||||
|
||||
await runSessionSharingSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
const second = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(second.movedGlobal).toBe(false);
|
||||
expect(second.movedHosts).toBe(0);
|
||||
expect(second.skipped).toBe(2);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
blockedHostIds.push(5);
|
||||
await pluginSettingsRepository.set(
|
||||
"session-sharing",
|
||||
"host",
|
||||
"5",
|
||||
"allowSessionSharing",
|
||||
"true",
|
||||
);
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(stored("5", "allowSessionSharing")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Moves session sharing's two settings into the session-sharing plugin:
|
||||
* the instance-wide switch (settings.session_sharing_globally_enabled) into
|
||||
* its admin setting, and ssh_data.allow_session_sharing into its host setting.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row, so it is
|
||||
* safe on every boot. Both default to on, so only a switch that was turned off
|
||||
* needs a row. The legacy settings row is left in place for a release.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
import {
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "session-sharing";
|
||||
const LEGACY_GLOBAL_KEY = "session_sharing_globally_enabled";
|
||||
|
||||
export interface SessionSharingSettingsMigrationResult {
|
||||
movedGlobal: boolean;
|
||||
movedHosts: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export async function runSessionSharingSettingsMigration(): Promise<SessionSharingSettingsMigrationResult> {
|
||||
const result: SessionSharingSettingsMigrationResult = {
|
||||
movedGlobal: false,
|
||||
movedHosts: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const legacy =
|
||||
await createCurrentSettingsRepository().get(LEGACY_GLOBAL_KEY);
|
||||
if (legacy === "false") {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"globallyEnabled",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"globallyEnabled",
|
||||
JSON.stringify(false),
|
||||
);
|
||||
result.movedGlobal = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Raw SQL, so this keeps working once schema.ts stops declaring the column.
|
||||
const rows = await getDb().all<LegacyHostRow>(sql`
|
||||
SELECT id FROM ssh_data WHERE allow_session_sharing = 0
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"allowSessionSharing",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"allowSessionSharing",
|
||||
JSON.stringify(false),
|
||||
);
|
||||
result.movedHosts++;
|
||||
}
|
||||
|
||||
if (result.movedGlobal || result.movedHosts > 0) {
|
||||
databaseLogger.info("Moved session sharing settings into the plugin", {
|
||||
operation: "session_sharing_settings_migration",
|
||||
movedGlobal: result.movedGlobal,
|
||||
movedHosts: result.movedHosts,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Never stops the backend. Sharing then stays on, the plugin's default.
|
||||
databaseLogger.warn("Session sharing settings migration failed", {
|
||||
operation: "session_sharing_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -145,7 +145,11 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
},
|
||||
{
|
||||
name: "Session Sharing",
|
||||
description: "Live terminal session collaboration",
|
||||
description: "Share live sessions by link or with another user",
|
||||
},
|
||||
{
|
||||
name: "Collab",
|
||||
description: "Collaboration rooms that present a live session",
|
||||
},
|
||||
{
|
||||
name: "Session Logs",
|
||||
|
||||
Reference in new issue
Block a user