335 lines
9.8 KiB
TypeScript
335 lines
9.8 KiB
TypeScript
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 });
|
|
});
|