import { storeCreateSession, storeGetSession, storeGetEnvironment, storeUpdateSession, storeListSessions, storeListSessionsByUsername, storeListSessionsByEnvironment, storeListSessionsByOwnerUuid, storeGetSessionOwnersBulk, } from '../store' import { randomUUID } from 'node:crypto' import { getDb } from '../db/sqlite' import type { Database } from 'bun:sqlite' import { getAllEventBuses, removeEventBus } from '../transport/event-bus' import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse, } from '../types/api' const CODE_SESSION_PREFIX = 'cse_' const WEB_SESSION_PREFIX = 'session_' const CLOSED_SESSION_STATUSES = new Set(['archived']) function toResponse( row: { id: string environmentId: string | null title: string | null status: string source: string permissionMode: string | null workerEpoch: number username: string | null visibility: string createdAt: Date updatedAt: Date }, owner?: { owner_type: 'user' | 'team'; owner_id: string } | null, ): SessionResponse { return { id: row.id, environment_id: row.environmentId, title: row.title, status: row.status, source: row.source, permission_mode: row.permissionMode, worker_epoch: row.workerEpoch, username: row.username, visibility: row.visibility, created_at: row.createdAt.getTime() / 1000, updated_at: row.updatedAt.getTime() / 1000, owner_type: owner?.owner_type ?? null, owner_id: owner?.owner_id ?? null, } } export function toWebSessionId(sessionId: string): string { if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}` } function toCompatibleCodeSessionId(sessionId: string): string | null { if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}` } export function toWebSessionResponse( session: SessionResponse, ): SessionResponse { return { ...session, id: toWebSessionId(session.id) } } function toWebSessionSummaryResponse( session: SessionSummaryResponse, ): SessionSummaryResponse { return { ...session, id: toWebSessionId(session.id) } } export function createSession( req: CreateSessionRequest & { username?: string; visibility?: string }, ): SessionResponse { const record = storeCreateSession({ environmentId: req.environment_id, title: req.title, source: req.source, permissionMode: req.permission_mode, username: req.username, visibility: req.visibility, }) return toResponse(record) } export function createCodeSession( req: CreateCodeSessionRequest, ): SessionResponse { const record = storeCreateSession({ idPrefix: 'cse_', title: req.title, source: req.source, permissionMode: req.permission_mode, }) return toResponse(record) } export function getSession(sessionId: string): SessionResponse | null { const record = storeGetSession(sessionId) return record ? toResponse(record) : null } export function isSessionClosedStatus( status: string | null | undefined, ): boolean { return !!status && CLOSED_SESSION_STATUSES.has(status) } export function resolveExistingSessionId(sessionId: string): string | null { if (storeGetSession(sessionId)) { return sessionId } const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId) if (compatibleCodeSessionId && storeGetSession(compatibleCodeSessionId)) { return compatibleCodeSessionId } return null } export function resolveExistingWebSessionId(sessionId: string): string | null { return resolveExistingSessionId(sessionId) } /** * Determine the access level a user has to a session. * * Returns: * - `'owner'` — user owns the session directly or via team ownership * - `'write'` — user has a non-expired write share * - `'read'` — user has a non-expired read share (direct or via team) * - `null` — no access */ export function isSessionVisibleToUser( sessionId: string, userId: string, db: Database, ): 'owner' | 'write' | 'read' | null { // Rule 1: direct user ownership const directOwner = db .query( `SELECT 1 FROM session_owners WHERE session_id = $sessionId AND owner_type = 'user' AND owner_id = $userId LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (directOwner) return 'owner' // Rule 2: team ownership — session owned by a team the user belongs to const teamOwner = db .query( `SELECT 1 FROM session_owners so JOIN team_members tm ON tm.team_id = so.owner_id WHERE so.session_id = $sessionId AND so.owner_type = 'team' AND tm.user_id = $userId LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (teamOwner) return 'owner' // Rule 3: direct write share (non-expired) const writeShare = db .query( `SELECT 1 FROM session_shares WHERE session_id = $sessionId AND granted_to_user = $userId AND permission = 'write' AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')) LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (writeShare) return 'write' // Rule 4: direct read share (non-expired) const readShare = db .query( `SELECT 1 FROM session_shares WHERE session_id = $sessionId AND granted_to_user = $userId AND permission = 'read' AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')) LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (readShare) return 'read' // Rule 5: team write share (non-expired) const teamWriteShare = db .query( `SELECT 1 FROM session_shares ss JOIN team_members tm ON tm.team_id = ss.granted_to_team WHERE ss.session_id = $sessionId AND tm.user_id = $userId AND ss.permission = 'write' AND (ss.expires_at IS NULL OR ss.expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')) LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (teamWriteShare) return 'write' // Rule 6: team read share (non-expired) const teamReadShare = db .query( `SELECT 1 FROM session_shares ss JOIN team_members tm ON tm.team_id = ss.granted_to_team WHERE ss.session_id = $sessionId AND tm.user_id = $userId AND ss.permission = 'read' AND (ss.expires_at IS NULL OR ss.expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')) LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (teamReadShare) return 'read' return null } /** * Check if a user can perform management operations on a session (delete, * create/revoke share, etc.). * * Management is granted to: * - the direct user owner (session_owners owner_type='user') * - a team owner's admin/owner (team_members.role ∈ {owner, admin}) * - a system admin (users.role='admin') * * Unlike isSessionVisibleToUser,普通 team member 不被视为 manager — * 管理操作需要 owner/admin 级别权限。 */ export function canManageSession( sessionId: string, userId: string, db: Database, ): boolean { // System admin bypass const userRow = db .query('SELECT role FROM users WHERE id = $userId') .get({ $userId: userId }) as { role: string } | null if (userRow?.role === 'admin') return true // Direct user ownership const directOwner = db .query( `SELECT 1 FROM session_owners WHERE session_id = $sessionId AND owner_type = 'user' AND owner_id = $userId LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (directOwner) return true // Team ownership with manager role (owner/admin) const teamManager = db .query( `SELECT 1 FROM session_owners so JOIN team_members tm ON tm.team_id = so.owner_id WHERE so.session_id = $sessionId AND so.owner_type = 'team' AND tm.user_id = $userId AND tm.role IN ('owner', 'admin') LIMIT 1`, ) .get({ $sessionId: sessionId, $userId: userId }) if (teamManager) return true return false } /** * Resolve a session ID for a user, checking ownership, shares, and team access. * * @param sessionId — the session ID to resolve (web or code prefix) * @param uuid — the user ID * @param requireWrite — if true, only return the ID when the user has write access * (owner or write share); if false, read access suffices */ export function resolveOwnedWebSessionId( sessionId: string, uuid: string, requireWrite = false, ): string | null { try { const db = getDb() // Admin bypass — admin sees all sessions in list views (listSessions), // so detail/delete/control resolution must also bypass the visibility // check. Otherwise admin sees sessions they cannot open or delete // (orphan ACP sessions, other users' sessions). canManageSession still // gates destructive actions; control.ts gates on session status. const userRow = db .query('SELECT role FROM users WHERE id = $userId') .get({ $userId: uuid }) as { role: string } | null if (userRow?.role === 'admin') { if (storeGetSession(sessionId)) return sessionId const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId) if (compatibleCodeSessionId && storeGetSession(compatibleCodeSessionId)) { return compatibleCodeSessionId } return sessionId } const visibility = isSessionVisibleToUser(sessionId, uuid, db) if (visibility) { if (!requireWrite || visibility === 'owner' || visibility === 'write') { return sessionId } } // Try compatible code session ID const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId) if (compatibleCodeSessionId) { const compatVisibility = isSessionVisibleToUser( compatibleCodeSessionId, uuid, db, ) if (compatVisibility) { if ( !requireWrite || compatVisibility === 'owner' || compatVisibility === 'write' ) { return compatibleCodeSessionId } } } } catch { // DB unavailable — return null rather than falling back to redundant check } return null } export function listWebSessionsByOwnerUuid(uuid: string): SessionResponse[] { const records = storeListSessionsByOwnerUuid(uuid).filter( session => !isSessionClosedStatus(session.status), ) const owners = storeGetSessionOwnersBulk(records.map(r => r.id)) const db = getDb() return records .map(r => toResponse(r, owners.get(r.id))) .map(toWebSessionResponse) .map(s => ({ ...s, access_level: isSessionVisibleToUser(s.id, uuid, db), })) } export function listWebSessionSummariesByOwnerUuid( uuid: string, ): SessionSummaryResponse[] { const records = storeListSessionsByOwnerUuid(uuid).filter( session => !isSessionClosedStatus(session.status), ) const owners = storeGetSessionOwnersBulk(records.map(r => r.id)) const db = getDb() return records .map(r => toSummaryResponse(r, owners.get(r.id))) .map(toWebSessionSummaryResponse) .map(s => ({ ...s, access_level: isSessionVisibleToUser(s.id, uuid, db), })) } export function updateSessionTitle(sessionId: string, title: string) { storeUpdateSession(sessionId, { title }) } export function updateSessionStatus(sessionId: string, status: string) { storeUpdateSession(sessionId, { status }) const bus = getAllEventBuses().get(sessionId) if (!bus) return bus.publish({ id: randomUUID(), sessionId, type: 'session_status', payload: { status }, direction: 'inbound', }) } export function touchSession(sessionId: string) { storeUpdateSession(sessionId, {}) } export function archiveSession(sessionId: string) { updateSessionStatus(sessionId, 'archived') removeEventBus(sessionId) } /** * Archive every non-closed session bound to an environment. Called when an * environment goes offline (ACP WS close, heartbeat timeout, deregister) so * the Web UI session list stays consistent with the environment list — * otherwise sessions linger with no live worker behind them. ACP sessions * are shared across all UUIDs, so this is the only path that retires them. */ export function archiveOpenSessionsByEnvironment(envId: string): number { const sessions = storeListSessionsByEnvironment(envId).filter( s => !isSessionClosedStatus(s.status), ) for (const s of sessions) { archiveSession(s.id) } return sessions.length } export function incrementEpoch(sessionId: string): number { const record = storeGetSession(sessionId) if (!record) throw new Error('Session not found') const newEpoch = record.workerEpoch + 1 storeUpdateSession(sessionId, { workerEpoch: newEpoch }) return newEpoch } export function listSessions() { return storeListSessions().map(r => toResponse(r)) } function toSummaryResponse( row: { id: string source: string title: string | null status: string username: string | null visibility: string environmentId: string | null updatedAt: Date }, owner?: { owner_type: 'user' | 'team'; owner_id: string } | null, ): SessionSummaryResponse { // Inline environment snapshot so the Web UI session list can render a // worker-type badge without an extra round-trip per session. Mirrors the // shape returned by GET /web/sessions/:id. let environment: SessionSummaryResponse['environment'] = null if (row.environmentId) { const env = storeGetEnvironment(row.environmentId) if (env) { environment = { id: env.id, machine_name: env.machineName, worker_type: env.workerType, } } } return { id: row.id, title: row.title, status: row.status, source: row.source, username: row.username, visibility: row.visibility, environment_id: row.environmentId, owner_type: owner?.owner_type ?? null, owner_id: owner?.owner_id ?? null, environment, updated_at: row.updatedAt.getTime() / 1000, } } export function listSessionSummaries(): SessionSummaryResponse[] { return storeListSessions().map(r => toSummaryResponse(r)) } export function listSessionSummariesByOwnerUuid( uuid: string, ): SessionSummaryResponse[] { return storeListSessionsByOwnerUuid(uuid).map(r => toSummaryResponse(r)) } export function listSessionSummariesByUsername( username: string, ): SessionSummaryResponse[] { return storeListSessionsByUsername(username).map(r => toSummaryResponse(r)) } export function listSessionsByEnvironment(envId: string) { return storeListSessionsByEnvironment(envId).map(r => toResponse(r)) }