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 } 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); const { label, lifespanHours = 1, mode = "read_only", customScopes = [], } = await c.req.json(); const cleanLabel = (label && typeof label === "string" && label.trim()) ? label.trim() : "AI Agent 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); 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 }); }); // --------------------------------------------------------- // Extend session TTL // --------------------------------------------------------- sessionRoutes.post("/api/sessions/:id/extend", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); 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"); const session = await sqlWrapper.sql` SELECT id 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); } 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 }); });