auth-yes/server/routes/sessions.ts
google-labs-jules[bot] 0a4f6a8344 feat(event-controls): implement live attendee management drawer and session pause
This commit finalizes Phase 4 of the Event & Session Overhaul:
1. Implements session pause logic across PostgreSQL schema, Valkey cache, and `auth_forward.ts` edge check (`is_paused`).
2. Implements non-destructive operational endpoints (`/api/events/:id/rotate-pin`, `/api/events/:id/expand`, `/api/events/:id/attendees`) with Zero-Trust Ownership verification.
3. Upgrades existing `end` and `extend` endpoints in `events.ts` to utilize robust Zero-Trust Ownership queries (created_by OR isGlobalAdmin).
4. Creates `EventAttendeesDrawer.tsx` to handle live participant inspection and individual session controls (Pause, Revoke).
5. Updates `EventCockpitDeck.tsx` and `SessionsScript.tsx` to mount and drive the new controls via vanilla JavaScript, respecting zero-framework guidelines.
6. Ensures `deno fmt`, `deno task lint`, `deno task check` and `deno test` execute successfully against the new schema and API guards.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-27 01:53:52 +00:00

321 lines
9.5 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 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 });
});