feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

-4
View File
@@ -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);
-67
View File
@@ -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.
{
-139
View File
@@ -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 ---
/**
-139
View File
@@ -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 ---
/**
-139
View File
@@ -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?.();
}
}
-4
View File
@@ -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,
+5 -30
View File
@@ -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, {
+7 -3
View File
@@ -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.