import { Hono } from "jsr:@hono/hono@4"; import { sqlWrapper } from "../db.ts"; import { valkey } from "../valkey.ts"; import { auditWrapper } from "../audit.ts"; import { getAuthenticatedUser, getClientIp, hasScope, } from "../auth-session.ts"; export const sessionRoutes = new Hono(); // --------------------------------------------------------- // Active Sessions Listing // --------------------------------------------------------- sessionRoutes.get("/api/sessions", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const sessions = await sqlWrapper.sql` SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at FROM sessions WHERE user_id = ${auth.userId} AND expires_at > NOW() ORDER BY created_at DESC `; return c.json({ sessions, currentSessionId: auth.sessionId }); }); // --------------------------------------------------------- // Delegate a child agent session with custom lifespan and scopes // --------------------------------------------------------- sessionRoutes.post("/api/sessions/delegate", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); // If delegating, the current session must have 'admin' scope or '*' if it's an agent if (auth.isAgent) { if (!hasScope(auth, "admin") && !hasScope(auth, "*")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } } const { label, lifespanHours = 1, mode = "read_only", customScopes = [], } = await c.req.json(); const cleanLabel = (label && typeof label === "string" && label.trim()) ? label.trim() : "Delegated Session"; const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days const expiresAt = new Date(Date.now() + hours * 3600 * 1000); let effectiveScopes: string[] = []; if (mode === "read_only") { effectiveScopes = [ "read:audit", "read:users", "read:apps", "read:roles", "read:sessions", ]; } else if (mode === "operator") { effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"]; } else if (mode === "admin") { effectiveScopes = ["*"]; } else if (mode === "custom" && Array.isArray(customScopes)) { effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter( Boolean, ); } const rawBytes = new Uint8Array(32); crypto.getRandomValues(rawBytes); const tokenHex = Array.from(rawBytes).map((b) => b.toString(16).padStart(2, "0") ).join(""); const sessionId = `ay_sess_${tokenHex}`; await sqlWrapper.sql` INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at) VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW()) `; try { const sessionData = { uuid: auth.userId, username: auth.username, label: cleanLabel, isAgent: true, customScopes: effectiveScopes, }; await valkey.set( sessionId, JSON.stringify(sessionData), "EX", Math.floor(hours * 3600), ); } catch (err) { console.error("[Valkey] Failed to cache delegated session:", err); } auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, { label: cleanLabel, lifespan_hours: hours, mode, scopes: effectiveScopes, }, getClientIp(c)); return c.json({ success: true, sessionId, token: sessionId, label: cleanLabel, expiresAt: expiresAt.toISOString(), scopes: effectiveScopes, }); }); // --------------------------------------------------------- // Update permissions on an active session // --------------------------------------------------------- sessionRoutes.put("/api/sessions/:id/scopes", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(auth, "write:sessions")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const targetSessionId = c.req.param("id"); const { customScopes = [] } = await c.req.json(); const session = await sqlWrapper.sql` SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} `.then((res: any) => res[0]); if (!session) { return c.json({ error: "Session not found or access denied" }, 404); } const effectiveScopes = Array.isArray(customScopes) ? customScopes.map((s: string) => String(s).trim()).filter(Boolean) : []; await sqlWrapper.sql` UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId} `; try { const existingCached = await valkey.get(targetSessionId); if (existingCached) { const parsed = JSON.parse(existingCached); parsed.customScopes = effectiveScopes; await valkey.set(targetSessionId, JSON.stringify(parsed)); } } catch (_e) {} auditWrapper.auditLog( auth.userId, "session_scopes_updated", targetSessionId, { scopes: effectiveScopes, }, getClientIp(c), ); return c.json({ success: true, scopes: effectiveScopes }); }); // --------------------------------------------------------- // Pause / Unpause Session // --------------------------------------------------------- sessionRoutes.post("/api/sessions/:id/pause", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(auth, "write:sessions")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const targetSessionId = c.req.param("id"); const { is_paused } = await c.req.json(); const shouldPause = Boolean(is_paused); const session = await sqlWrapper.sql` SELECT id FROM sessions WHERE id = ${targetSessionId} `.then((res: any) => res[0]); if (!session) { return c.json({ error: "Session not found" }, 404); } await sqlWrapper.sql` UPDATE sessions SET is_paused = ${shouldPause} WHERE id = ${targetSessionId} `; try { const existingCached = await valkey.get(targetSessionId); if (existingCached) { const parsed = JSON.parse(existingCached); parsed.is_paused = shouldPause; // Get TTL to preserve it const ttl = await valkey.ttl(targetSessionId); if (ttl > 0) { await valkey.setex(targetSessionId, ttl, JSON.stringify(parsed)); } else { await valkey.set(targetSessionId, JSON.stringify(parsed)); } } } catch (err) { console.error("[Valkey] Failed to update session pause state:", err); } auditWrapper.auditLog( auth.userId, shouldPause ? "session_paused" : "session_unpaused", targetSessionId, {}, getClientIp(c), ); return c.json({ success: true, is_paused: shouldPause }); }); // --------------------------------------------------------- // Extend session TTL // --------------------------------------------------------- sessionRoutes.post("/api/sessions/:id/extend", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(auth, "write:sessions")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const targetSessionId = c.req.param("id"); const { extendHours = 1 } = await c.req.json(); const additionalHours = Math.max(Number(extendHours) || 1, 1); const session = await sqlWrapper.sql` SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} `.then((res: any) => res[0]); if (!session) { return c.json({ error: "Session not found or access denied" }, 404); } const currentExpiry = new Date(session.expires_at).getTime(); const newExpiry = new Date( Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000, ); await sqlWrapper.sql` UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId} `; try { const ttlSeconds = Math.max( 1, Math.floor((newExpiry.getTime() - Date.now()) / 1000), ); await valkey.expire(targetSessionId, ttlSeconds); } catch (_e) {} auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, { extended_by_hours: additionalHours, new_expires_at: newExpiry.toISOString(), }, getClientIp(c)); return c.json({ success: true, newExpiresAt: newExpiry.toISOString() }); }); // --------------------------------------------------------- // Revoke a specific session // --------------------------------------------------------- sessionRoutes.delete("/api/sessions/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const targetSessionId = c.req.param("id"); if (targetSessionId !== auth.sessionId) { if (!hasScope(auth, "write:sessions")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } } const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*"); const session = await sqlWrapper.sql` SELECT s.id FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ${targetSessionId} AND ( s.user_id = ${auth.userId} OR ${isAdmin} OR EXISTS ( SELECT 1 FROM event_passes ep WHERE ep.created_by = ${auth.userId} AND u.event_pass_id = ep.id ) ) `.then((res: any) => res[0]); if (!session) { return c.json({ error: "Session not found or access denied" }, 404); } try { await valkey.del(targetSessionId); } catch (err) { console.error("Failed to delete session from cache:", err); } await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`; auditWrapper.auditLog(auth.userId, "session_revoked", null, { revoked_session_id: targetSessionId, }, getClientIp(c)); return c.json({ success: true }); });