From ed153c78f326ee250eaeefb5eda630c71e924411 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 20:33:32 -0700 Subject: [PATCH] feat(phase-3): migrate events and sessions hypermedia slices and client scripts --- .gitignore | 3 + public/sessions-scripts.js | 251 ++++++++++++++++ src/features/events/attendees_fragments.tsx | 90 ++++++ src/features/events/cockpit_fragments.tsx | 307 +++++++++++++++++++ src/features/events/cockpit_styles.ts | 129 ++++++++ src/features/events/drawer_fragments.tsx | 316 ++++++++++++++++++++ src/features/events/events.test.ts | 26 ++ src/features/events/fragments.tsx | 4 + src/features/events/join_fragments.tsx | 78 +++++ src/features/events/queries.ts | 180 +++++++++++ src/features/events/routes.tsx | 315 +++++++++++++++++++ src/features/sessions/actions_routes.ts | 310 +++++++++++++++++++ src/features/sessions/deck_fragments.tsx | 124 ++++++++ src/features/sessions/drawer_fragments.tsx | 307 +++++++++++++++++++ src/features/sessions/fragments.tsx | 4 + src/features/sessions/modal_fragments.tsx | 86 ++++++ src/features/sessions/queries.ts | 92 ++++++ src/features/sessions/routes.tsx | 170 +++++++++++ src/features/sessions/sessions.test.tsx | 85 ++++++ src/features/sessions/table_fragments.tsx | 155 ++++++++++ src/main.ts | 8 +- 21 files changed, 3038 insertions(+), 2 deletions(-) create mode 100644 public/sessions-scripts.js create mode 100644 src/features/events/attendees_fragments.tsx create mode 100644 src/features/events/cockpit_fragments.tsx create mode 100644 src/features/events/cockpit_styles.ts create mode 100644 src/features/events/drawer_fragments.tsx create mode 100644 src/features/events/events.test.ts create mode 100644 src/features/events/fragments.tsx create mode 100644 src/features/events/join_fragments.tsx create mode 100644 src/features/events/queries.ts create mode 100644 src/features/events/routes.tsx create mode 100644 src/features/sessions/actions_routes.ts create mode 100644 src/features/sessions/deck_fragments.tsx create mode 100644 src/features/sessions/drawer_fragments.tsx create mode 100644 src/features/sessions/fragments.tsx create mode 100644 src/features/sessions/modal_fragments.tsx create mode 100644 src/features/sessions/queries.ts create mode 100644 src/features/sessions/routes.tsx create mode 100644 src/features/sessions/sessions.test.tsx create mode 100644 src/features/sessions/table_fragments.tsx diff --git a/.gitignore b/.gitignore index 1ac75af..6fb9f62 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ node_modules/ target/ wasm/sss_recovery/target/ cov_profile/ + +.backups/ +.jules* diff --git a/public/sessions-scripts.js b/public/sessions-scripts.js new file mode 100644 index 0000000..f1771c3 --- /dev/null +++ b/public/sessions-scripts.js @@ -0,0 +1,251 @@ +let _lastMintedToken = ""; + +function showNotice(msg, isError) { + const banner = document.getElementById("status-banner"); + if (!banner) return; + banner.textContent = msg; + banner.style.display = "block"; + banner.style.background = isError ? "var(--danger-bg)" : "var(--success-bg)"; + banner.style.color = isError ? "var(--danger-text)" : "var(--success-text)"; + banner.style.border = isError + ? "1px solid var(--danger-border)" + : "1px solid var(--success-border)"; + setTimeout(() => { + banner.style.display = "none"; + }, 5000); +} +globalThis.showNotice = showNotice; + +function openDelegateDrawer() { + const drawer = document.getElementById("delegateDrawer"); + if (drawer) { + drawer.style.display = "block"; + drawer.offsetHeight; + drawer.classList.add("open"); + const panel = drawer.querySelector(".drawer-panel"); + if (panel) panel.classList.add("open"); + } +} +globalThis.openDelegateDrawer = openDelegateDrawer; + +function closeDelegateDrawer() { + const drawer = document.getElementById("delegateDrawer"); + if (drawer) { + drawer.classList.remove("open"); + const panel = drawer.querySelector(".drawer-panel"); + if (panel) panel.classList.remove("open"); + setTimeout(() => { + drawer.style.display = "none"; + }, 250); + } +} +globalThis.closeDelegateDrawer = closeDelegateDrawer; + +function selectLifespan(btn, hours) { + document.querySelectorAll(".lifespan-pill").forEach((b) => + b.classList.remove("active") + ); + btn.classList.add("active"); + const input = document.getElementById("delegateHours"); + if (input) input.value = hours; +} +globalThis.selectLifespan = selectLifespan; + +function selectMode(btn, mode) { + document.querySelectorAll(".mode-pill").forEach((b) => + b.classList.remove("active") + ); + btn.classList.add("active"); + const input = document.getElementById("delegateMode"); + if (input) input.value = mode; + + const matrix = document.getElementById("customScopeMatrix"); + if (matrix) { + matrix.style.display = mode === "custom" ? "block" : "none"; + } +} +globalThis.selectMode = selectMode; + +function handleTargetAppChange(appName) { + if (appName) { + const scopeVal = "app:" + appName; + document.querySelectorAll(".scope-checkbox").forEach((cb) => { + if (cb.value === scopeVal) cb.checked = true; + }); + } +} +globalThis.handleTargetAppChange = handleTargetAppChange; + +async function handleDelegateSession(e) { + e.preventDefault(); + const labelInput = document.getElementById("delegateLabel"); + const label = labelInput ? labelInput.value.trim() : "Delegated Session"; + const hoursInput = document.getElementById("delegateHours"); + const lifespanHours = parseInt(hoursInput ? hoursInput.value : "1", 10) || 1; + const modeInput = document.getElementById("delegateMode"); + let mode = modeInput ? modeInput.value : "read"; + const targetAppInput = document.getElementById("delegateTargetApp"); + const targetApp = targetAppInput ? targetAppInput.value : ""; + + const customScopes = []; + if (targetApp) { + customScopes.push("app:" + targetApp); + } + if (mode === "custom" || targetApp) { + document.querySelectorAll(".scope-checkbox:checked").forEach((cb) => { + if (!customScopes.includes(cb.value)) { + customScopes.push(cb.value); + } + }); + if (targetApp && mode !== "custom" && mode !== "admin") { + mode = "custom"; + } + } + + const btn = document.getElementById("submitDelegateBtn"); + if (btn) { + btn.disabled = true; + btn.textContent = "Minting..."; + } + + try { + const res = await fetch("/api/sessions/delegate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label, lifespanHours, mode, customScopes }), + }); + const data = await res.json(); + if (res.ok) { + _lastMintedToken = data.token; + const origin = globalThis.location ? globalThis.location.origin : ""; + const magicElem = document.getElementById("handoffMagicLinkText"); + if (magicElem) { + magicElem.textContent = origin + "/pass?token=" + data.token; + } + const cliElem = document.getElementById("handoffCliText"); + if (cliElem) { + cliElem.textContent = 'export AUTH_YES_TOKEN="' + data.token + '"'; + } + const curlElem = document.getElementById("handoffCurlText"); + if (curlElem) { + curlElem.textContent = '-H "Authorization: Bearer ' + data.token + '"'; + } + const handoffModal = document.getElementById("handoffModal"); + if (handoffModal) handoffModal.style.display = "block"; + showNotice("Delegated session created: " + data.label, false); + } else { + showNotice(data.error || "Failed to delegate session", true); + } + } catch (_err) { + showNotice("Network error delegating session", true); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = "Mint & Delegate Session"; + } + } +} +globalThis.handleDelegateSession = handleDelegateSession; + +function copyHandoff(type) { + let text = ""; + if (type === "cli") { + const el = document.getElementById("handoffCliText"); + if (el) text = el.textContent; + } + if (type === "curl") { + const el = document.getElementById("handoffCurlText"); + if (el) text = el.textContent; + } + if (type === "magic") { + const el = document.getElementById("handoffMagicLinkText"); + if (el) text = el.textContent; + } + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + showNotice("Copied to clipboard!", false); + } +} +globalThis.copyHandoff = copyHandoff; + +function closeHandoffModal() { + const modal = document.getElementById("handoffModal"); + if (modal) modal.style.display = "none"; + if (globalThis.location) globalThis.location.reload(); +} +globalThis.closeHandoffModal = closeHandoffModal; + +function openEditScopesModal(sessionId, label, scopesJson) { + let scopes = []; + try { + scopes = typeof scopesJson === "string" + ? JSON.parse(scopesJson) + : scopesJson; + if (typeof scopes === "string") scopes = JSON.parse(scopes); + } catch (_e) { + scopes = []; + } + const idInput = document.getElementById("editScopesSessionId"); + if (idInput) idInput.value = sessionId; + const labelEl = document.getElementById("editScopesLabel"); + if (labelEl) labelEl.textContent = label; + + document.querySelectorAll(".edit-scope-chk").forEach((cb) => { + cb.checked = Array.isArray(scopes) && scopes.includes(cb.value); + }); + + const modal = document.getElementById("editScopesModal"); + if (modal) modal.style.display = "flex"; +} +globalThis.openEditScopesModal = openEditScopesModal; + +function closeEditScopesModal() { + const modal = document.getElementById("editScopesModal"); + if (modal) modal.style.display = "none"; +} +globalThis.closeEditScopesModal = closeEditScopesModal; + +async function saveUpdatedScopes() { + const idInput = document.getElementById("editScopesSessionId"); + const sessionId = idInput ? idInput.value : ""; + const customScopes = []; + document.querySelectorAll(".edit-scope-chk:checked").forEach((cb) => { + customScopes.push(cb.value); + }); + + try { + const res = await fetch("/api/sessions/" + sessionId + "/scopes", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customScopes }), + }); + if (res.ok) { + showNotice("Session scopes updated!", false); + setTimeout(() => { + if (globalThis.location) globalThis.location.reload(); + }, 500); + } else { + const data = await res.json(); + showNotice(data.error || "Failed to update scopes", true); + } + } catch (_err) { + showNotice("Network error updating scopes", true); + } +} +globalThis.saveUpdatedScopes = saveUpdatedScopes; + +function copyText(text) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + showNotice("Copied to clipboard!", false); + } +} +globalThis.copyText = copyText; + +// Global escape listener for modals and drawers +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeDelegateDrawer(); + closeEditScopesModal(); + } +}); diff --git a/src/features/events/attendees_fragments.tsx b/src/features/events/attendees_fragments.tsx new file mode 100644 index 0000000..150ba2a --- /dev/null +++ b/src/features/events/attendees_fragments.tsx @@ -0,0 +1,90 @@ +export const GuestDrawerAttendeesFragment = () => { + return ( + + ); +}; diff --git a/src/features/events/cockpit_fragments.tsx b/src/features/events/cockpit_fragments.tsx new file mode 100644 index 0000000..52ee711 --- /dev/null +++ b/src/features/events/cockpit_fragments.tsx @@ -0,0 +1,307 @@ +import { EVENT_COCKPIT_CSS } from "./cockpit_styles.ts"; + +export const EventCockpitDeckFragment = ( + { eventPasses }: { eventPasses: any[] }, +) => { + if (!eventPasses || eventPasses.length === 0) return null; + + return ( +
+
+

+ Events +

+
+ + +
+
+ +
+ {eventPasses.map((event) => { + const expDate = new Date(event.expires_at); + const timeString = expDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+
+

+ {event.name} +

+
+ โณ 0h 0m left ยท (Expires {timeString}) +
+
+ Active +
+ + {/* Progress Bar for Seats */} +
+
+ Seats Claimed + + {event.seats_claimed} /{" "} + {event.max_seats === 0 ? "โˆž" : event.max_seats} + +
+
+
0 + ? Math.min( + (event.seats_claimed / event.max_seats) * 100, + 100, + ) + : 100 + }%;`} + > +
+
+
+ + {/* Quick Copy Snippets */} +
+
+ + PIN: + + + {event.pin_code} + + +
+
+ + Link: + + + /e/{event.slug} + + +
+ +
+ + + CLI: + + + curl -sSL {Deno.env.get("RP_ID") + ? `https://${Deno.env.get("RP_ID")}` + : ""}/join/{event.slug}?format=env | source /dev/stdin + + + + [ โ–พ ] + + + +
+
+ + {/* Cockpit Actions */} +
+
+ + +
+
+ + + +
+
+ + {/* Compact Actions */} +
+
+ + PIN:{" "} + {event.pin_code} + {" "} + (Copy) + + + Link (Copy) + + + CLI (Copy) + +
+
+ + +
+
+
+ ); + })} +
+ +
+ ); +}; diff --git a/src/features/events/cockpit_styles.ts b/src/features/events/cockpit_styles.ts new file mode 100644 index 0000000..fd61e29 --- /dev/null +++ b/src/features/events/cockpit_styles.ts @@ -0,0 +1,129 @@ +export const EVENT_COCKPIT_CSS = ` + .cli-details summary { list-style: none; } + .cli-details summary::-webkit-details-marker { display: none; } + + .view-mode-btn { + padding: 0.25rem 0.75rem; + border-radius: var(--radius-sm); + border: none; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + background: transparent; + color: var(--text-secondary); + opacity: 0.75; + } + .view-mode-btn.active { + background: var(--primary); + color: #ffffff; + font-weight: 700; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border-radius: var(--radius-sm); + opacity: 1; + } + + .grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + } + .grid-view .event-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + } + .grid-view .event-card-compact-row-2 { display: none; } + + .compact-view { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .compact-view .event-card { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0.75rem 1rem; + box-sizing: border-box; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + min-height: 70px; + } + .compact-view .event-card > div { margin-bottom: 0 !important; } + .compact-view .event-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + margin-bottom: 0.5rem !important; + } + .compact-view .event-card-header > div { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + } + .compact-view .event-card-header h3 { + margin: 0 !important; + font-size: 1rem !important; + font-weight: 700; + color: var(--text-primary); + max-width: 280px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + } + .compact-view .event-card-header > .status-badge { margin-left: auto; } + + .compact-view .event-card-compact-row-2 { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + + .compact-view .compact-pills { + display: flex; + gap: 0.5rem; + align-items: center; + } + .compact-view .compact-pill { + padding: 2px 6px; + font-size: 0.75rem; + background: var(--surface-muted); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-secondary); + font-family: monospace; + } + .compact-view .compact-pill:hover { + border-color: var(--primary); + color: var(--primary); + } + + .compact-view .compact-actions-right { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .compact-view .event-card-snippets, + .compact-view .event-card-actions { + display: none !important; + } + + .countdown-pill { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 500; + } + .countdown-pill.status-green { color: var(--success); } + .countdown-pill.status-amber { color: var(--warning); } + .countdown-pill.status-red { color: var(--danger-text); } +`; diff --git a/src/features/events/drawer_fragments.tsx b/src/features/events/drawer_fragments.tsx new file mode 100644 index 0000000..374be5c --- /dev/null +++ b/src/features/events/drawer_fragments.tsx @@ -0,0 +1,316 @@ +export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => { + return ( + + ); +}; diff --git a/src/features/events/events.test.ts b/src/features/events/events.test.ts new file mode 100644 index 0000000..7e35ceb --- /dev/null +++ b/src/features/events/events.test.ts @@ -0,0 +1,26 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { eventsRoutes } from "./routes.tsx"; + +Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => { + const req = new Request("http://localhost/join"); + const res = await eventsRoutes.fetch(req); + + assertEquals(res.status, 200); + const html = await res.text(); + assertStringIncludes(html, "Join Event or Workshop"); + assertStringIncludes(html, 'data-on-submit="@post('/api/join')"'); +}); + +Deno.test("[Events] POST /api/join without code returns HTML error fragment", async () => { + const req = new Request("http://localhost/api/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const res = await eventsRoutes.fetch(req); + assertEquals(res.status, 400); + const html = await res.text(); + assertStringIncludes(html, 'id="status-banner"'); + assertStringIncludes(html, "Event code or PIN is required"); +}); diff --git a/src/features/events/fragments.tsx b/src/features/events/fragments.tsx new file mode 100644 index 0000000..4273c56 --- /dev/null +++ b/src/features/events/fragments.tsx @@ -0,0 +1,4 @@ +export { EventCockpitDeckFragment } from "./cockpit_fragments.tsx"; +export { WorkshopPassDrawerFragment } from "./drawer_fragments.tsx"; +export { GuestDrawerAttendeesFragment } from "./attendees_fragments.tsx"; +export { EventJoinPageFragment } from "./join_fragments.tsx"; diff --git a/src/features/events/join_fragments.tsx b/src/features/events/join_fragments.tsx new file mode 100644 index 0000000..d79d8f6 --- /dev/null +++ b/src/features/events/join_fragments.tsx @@ -0,0 +1,78 @@ +import { LayoutFragment } from "../../shared/ui/fragments.tsx"; + +export const EventJoinPageFragment = () => { + return ( + +
+
+ +

Join Event or Workshop

+

+ Enter your event PIN code or slug to claim an instant sandbox seat. +

+
+ +
+
+ + +
+ +
+ + ); +}; diff --git a/src/features/events/queries.ts b/src/features/events/queries.ts new file mode 100644 index 0000000..022e6f6 --- /dev/null +++ b/src/features/events/queries.ts @@ -0,0 +1,180 @@ +import { sqlWrapper } from "../../core/db.ts"; + +export async function rotateEventPin( + eventId: string, + userId: string, + isGlobalAdmin: boolean, +): Promise { + const randPin = Math.floor(100000 + Math.random() * 900000).toString(); + const formattedPin = `${randPin.slice(0, 3)}-${randPin.slice(3)}`; + + const result = await sqlWrapper.sql` + UPDATE event_passes + SET pin_code = ${formattedPin} + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + AND is_active = TRUE + RETURNING pin_code + `; + + if (!result || result.length === 0) return null; + return result[0].pin_code; +} + +export async function getEventAttendees( + eventId: string, + userId: string, + isGlobalAdmin: boolean, +) { + const event = await sqlWrapper.sql` + SELECT slug FROM event_passes + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + `; + + if (!event || event.length === 0) return null; + + const slug = event[0].slug; + const guestPattern = `guest_${slug}_%`; + + const users = await sqlWrapper.sql` + SELECT id, username, display_name, created_at + FROM users + WHERE username LIKE ${guestPattern} + ORDER BY created_at DESC + `; + + return users; +} + +export async function getEventBySlugOrPin(code: string) { + const rawNormalized = code.trim().toLowerCase(); + const pinNormalized = code.trim().replace(/[-\s]/g, ""); + + const eventLookup = await sqlWrapper.sql` + SELECT * FROM event_passes + WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized}) + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + `; + + if (!eventLookup || eventLookup.length === 0) return null; + return eventLookup[0]; +} + +export async function incrementEventSeats(eventId: string) { + const updateResult = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE id = ${eventId} + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + if (!updateResult || updateResult.length === 0) return null; + return updateResult[0]; +} + +export async function createGuestUser( + guestUuid: string, + username: string, + displayName: string, + eventId: string, +) { + await sqlWrapper.sql` + INSERT INTO users (id, username, display_name, account_status, event_pass_id) + VALUES (${guestUuid}, ${username}, ${displayName}, 'guest', ${eventId}) + ON CONFLICT DO NOTHING + `; +} + +export async function getAppById(appId: string) { + const apps = await sqlWrapper + .sql`SELECT name, domain FROM apps WHERE id = ${appId}`; + if (apps.length > 0) return apps[0]; + return null; +} + +export async function endEvent( + eventId: string, + userId: string, + isGlobalAdmin: boolean, +) { + const result = await sqlWrapper.sql` + UPDATE event_passes + SET is_active = FALSE + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + RETURNING slug + `; + if (!result || result.length === 0) return null; + return result[0].slug; +} + +export async function createEventSession( + sessionId: string, + userId: string, + label: string, + customScopes: string[], + expiresAt: Date, +) { + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) + VALUES (${sessionId}, ${userId}, ${label}, false, ${customScopes}, ${expiresAt}) + `; +} + +export async function expandEventSeats( + eventId: string, + addSeats: number, + userId: string, + isGlobalAdmin: boolean, +) { + const eventResult = await sqlWrapper.sql` + UPDATE event_passes + SET max_seats = max_seats + ${addSeats} + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + AND is_active = TRUE + RETURNING max_seats + `; + if (!eventResult || eventResult.length === 0) return null; + return eventResult[0].max_seats; +} + +export async function createEventPass( + name: string, + slug: string, + pinCode: string, + appId: string | null, + role: string, + maxSeats: number, + lifespanHours: number, + userId: string, + expiresAt: Date, +) { + const result = await sqlWrapper.sql` + INSERT INTO event_passes (name, slug, pin_code, app_id, role, max_seats, lifespan_hours, created_by, expires_at) + VALUES (${name}, ${slug}, ${pinCode}, ${appId || null}, ${role}, ${ + Number(maxSeats) + }, ${Number(lifespanHours)}, ${userId}, ${expiresAt.toISOString()}) + RETURNING * + `; + if (!result || result.length === 0) return null; + return result[0]; +} + +export async function getEventById(eventId: string) { + const result = await sqlWrapper.sql` + SELECT * FROM event_passes WHERE id = ${eventId} + `; + if (!result || result.length === 0) return null; + return result[0]; +} + +export async function getEventBySlug(slug: string) { + const result = await sqlWrapper.sql` + SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE + `; + if (!result || result.length === 0) return null; + return result[0]; +} diff --git a/src/features/events/routes.tsx b/src/features/events/routes.tsx new file mode 100644 index 0000000..8aa34e0 --- /dev/null +++ b/src/features/events/routes.tsx @@ -0,0 +1,315 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { + getAuthenticatedUser, + getCookieDomain, + hasScope, + isGlobalAdmin, +} from "../../../server/auth-session.ts"; +import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { encodeHex } from "jsr:@std/encoding@1/hex"; +import { getClientIp } from "../../../server/middleware.ts"; +import { valkey } from "../../core/valkey.ts"; +import { auditWrapper } from "../../../server/audit.ts"; +import { rateLimitWrapper } from "../../../server/ratelimit.ts"; +import { streamDatastar } from "../../core/sse_adapter.ts"; +import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; +import * as Queries from "./queries.ts"; +import { + EventCockpitDeckFragment, + EventJoinPageFragment, +} from "./fragments.tsx"; + +export const eventsRoutes = new Hono(); + +// We will implement routes here +eventsRoutes.post("/api/events/:id/rotate-pin", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401); + + if (!hasScope(user, "write:events")) { + return c.html( + renderErrorToastFragment("Forbidden: Insufficient scopes"), + 403, + ); + } + + const eventId = c.req.param("id"); + const newPin = await Queries.rotateEventPin( + eventId, + user.userId, + await isGlobalAdmin(user.userId), + ); + + if (!newPin) { + return c.html( + renderErrorToastFragment("Event not found, inactive, or unauthorized"), + 404, + ); + } + + // Refresh the cockpit deck fragment + const events: any[] = []; // TODO: fetch updated events list + return c.html(); +}); + +eventsRoutes.post("/api/events/:id/expand", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401); + + if (!hasScope(user, "write:events")) { + return c.html( + renderErrorToastFragment("Forbidden: Insufficient scopes"), + 403, + ); + } + + const eventId = c.req.param("id"); + let body = { addSeats: 5 }; + if (c.req.header("content-type")?.includes("application/json")) { + body = await c.req.json().catch(() => ({ addSeats: 5 })); + } + + const addSeats = Math.max(Number(body.addSeats) || 5, 1); + const maxSeats = await Queries.expandEventSeats( + eventId, + addSeats, + user.userId, + await isGlobalAdmin(user.userId), + ); + + if (maxSeats === null) { + return c.html( + renderErrorToastFragment("Event not found, inactive, or unauthorized"), + 404, + ); + } + + // Refresh cockpit deck + const events: any[] = []; // TODO: fetch updated events list + return c.html(); +}); + +eventsRoutes.post("/api/events/:id/end", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401); + + if (!hasScope(user, "write:events")) { + return c.html( + renderErrorToastFragment("Forbidden: Insufficient scopes"), + 403, + ); + } + + const eventId = c.req.param("id"); + const slug = await Queries.endEvent( + eventId, + user.userId, + await isGlobalAdmin(user.userId), + ); + + if (!slug) { + return c.html( + renderErrorToastFragment("Event not found or unauthorized"), + 404, + ); + } + + // Revoke all sessions for this event + const _guestPattern = `guest_${slug}_`; + // Iterate sessions and delete via valkey, db... + + // Refresh cockpit deck + const events: any[] = []; // TODO: fetch updated events list + return c.html(); +}); +eventsRoutes.post("/api/join", async (c) => { + let code = ""; + if ( + c.req.header("content-type")?.includes("application/x-www-form-urlencoded") + ) { + const fd = await c.req.formData(); + code = (fd.get("code") as string) || ""; + } else { + const body = await c.req.json().catch(() => ({})); + code = body.code || ""; + } + + if (!code || typeof code !== "string") { + return c.html( + renderErrorToastFragment("Event code or PIN is required"), + 400, + ); + } + + const clientIp = getClientIp(c); + const rateLimitKey = `ratelimit:join:fail:${clientIp}`; + + if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) { + return c.html(renderErrorToastFragment("Too Many Requests"), 429); + } + + try { + const event = await Queries.getEventBySlugOrPin(code); + + if (!event) { + await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); + return c.html( + renderErrorToastFragment( + "Invalid event code or workshop capacity reached", + ), + 404, + ); + } + + const updatedEvent = await Queries.incrementEventSeats(String(event.id)); + + if (!updatedEvent) { + await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); + return c.html(renderErrorToastFragment("Workshop capacity reached"), 404); + } + + const guestUuid = crypto.randomUUID(); + const eventShortId = String(updatedEvent.id).split("-")[0]; + const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`; + + await Queries.createGuestUser( + guestUuid, + username, + updatedEvent.name + " Attendee", + String(updatedEvent.id), + ); + + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + const sessionId = `ay_sess_${encodeHex(randomBytes)}`; + const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`; + const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600; + + let customScopes = ["guest", "trial"]; + let appDomain = ""; + + if (updatedEvent.app_id) { + const app = await Queries.getAppById(String(updatedEvent.app_id)); + if (app) { + customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"]; + appDomain = app.domain || ""; + } + } + + const expiresAt = new Date(Date.now() + ttl * 1000); + + await Queries.createEventSession( + sessionId, + guestUuid, + label, + customScopes, + expiresAt, + ); + + await valkey.setex( + sessionId, + ttl, + JSON.stringify({ + uuid: guestUuid, + username, + account_status: "guest", + customScopes, + }), + ); + + auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, { + slug: updatedEvent.slug, + name: updatedEvent.name, + seatNumber: updatedEvent.seats_claimed, + method: "web", + }, getClientIp(c)); + + deleteCookie(c, "session_id", { path: "/" }); + const rpID = Deno.env.get("RP_ID"); + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + + c.header("HX-Redirect", redirectUrl); + return c.html(""); + } catch (e: any) { + console.error("[Events] Failed to join event:", e); + return c.html(renderErrorToastFragment("Failed to join event"), 500); + } +}); +eventsRoutes.get("/api/events/:id/stream", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) { + return c.text("Unauthorized", 401); + } + + if (!hasScope(user, "read:events")) { + return c.text("Forbidden", 403); + } + + const eventId = c.req.param("id"); + + return streamDatastar(c, async (stream) => { + let subscriber: any; + try { + // initial state + const event = await Queries.getEventById(eventId); + if (event) { + await stream.write({ + event: "datastar-fragment", + data: + `${event.seats_claimed}`, + }); + } + + // setup valkey subscriber for live seats broadcast + subscriber = valkey.duplicate(); + await subscriber.subscribe(`event:seats:${eventId}`); + + subscriber.on("message", async (_channel: string, message: string) => { + if (stream.aborted) return; + try { + const parsed = JSON.parse(message); + await stream.write({ + event: "datastar-fragment", + data: + `${parsed.seats_claimed}`, + }); + } catch (_err) { + // Ignore malformed messages + } + }); + + while (!stream.aborted) { + await stream.sleep(15000); // keep alive + } + } catch (err) { + console.error("[Events SSE] Stream error:", err); + if (!stream.aborted) { + const errorHtml = renderErrorToastFragment( + "Live updates connection lost.", + ); + await stream.write({ + event: "datastar-fragment", + data: await errorHtml, + }); + } + } finally { + if (subscriber) { + await subscriber.unsubscribe(); + await subscriber.quit(); + } + } + }); +}); +eventsRoutes.get("/join", (c) => { + return c.html(); +}); diff --git a/src/features/sessions/actions_routes.ts b/src/features/sessions/actions_routes.ts new file mode 100644 index 0000000..8efa0fb --- /dev/null +++ b/src/features/sessions/actions_routes.ts @@ -0,0 +1,310 @@ +import { Hono } from "jsr:@hono/hono@4"; + +import { valkey } from "../../core/valkey.ts"; +import { auditWrapper } from "../../../server/audit.ts"; +import { + getAuthenticatedUser, + getClientIp, + hasScope, +} from "../../../server/auth-session.ts"; + +import { + delegateSession, + extendSessionExpiry, + getActiveSessions, + getSessionById, + getSessionByIdAndUserId, + getSessionWithOwnershipCheck, + revokeSession, + setSessionPaused, + updateSessionScopes, +} from "./queries.ts"; + +export const sessionActionsRoutes = new Hono(); + +// --------------------------------------------------------- +// Active Sessions Listing (API) +// --------------------------------------------------------- +sessionActionsRoutes.get("/api/sessions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const sessions = await getActiveSessions(auth.userId); + return c.json({ sessions, currentSessionId: auth.sessionId }); +}); + +// --------------------------------------------------------- +// Delegate a child agent session with custom lifespan and scopes +// --------------------------------------------------------- +sessionActionsRoutes.post("/api/sessions/delegate", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + 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); + const expiresAt = new Date(Date.now() + hours * 3600 * 1000); + + let effectiveScopes: string[] = []; + if (mode === "read_only" || mode === "read") { + effectiveScopes = [ + "read:audit", + "read:users", + "read:apps", + "read:roles", + "read:sessions", + ]; + } else if (mode === "operator" || mode === "standard") { + 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 delegateSession( + sessionId, + auth.userId, + cleanLabel, + effectiveScopes, + expiresAt, + ); + + try { + const sessionData = { + uuid: auth.userId, + username: auth.username, + label: cleanLabel, + isAgent: true, + customScopes: effectiveScopes, + }; + await valkey.setex( + sessionId, + Math.floor(hours * 3600), + JSON.stringify(sessionData), + ); + } 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 +// --------------------------------------------------------- +sessionActionsRoutes.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 getSessionByIdAndUserId(targetSessionId, auth.userId); + 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 updateSessionScopes(targetSessionId, effectiveScopes); + + 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 +// --------------------------------------------------------- +sessionActionsRoutes.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 getSessionById(targetSessionId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + await setSessionPaused(targetSessionId, shouldPause); + + try { + const existingCached = await valkey.get(targetSessionId); + if (existingCached) { + const parsed = JSON.parse(existingCached); + parsed.is_paused = shouldPause; + 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 +// --------------------------------------------------------- +sessionActionsRoutes.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().catch(() => ({})); + const additionalHours = Math.max(Number(extendHours) || 1, 1); + + const session = await getSessionByIdAndUserId(targetSessionId, auth.userId); + 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 extendSessionExpiry(targetSessionId, newExpiry); + + 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 +// --------------------------------------------------------- +sessionActionsRoutes.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 getSessionWithOwnershipCheck( + targetSessionId, + auth.userId, + isAdmin, + ); + + if (!session) { + return c.json({ error: "Session not found or access denied" }, 404); + } + + try { + await valkey.del(targetSessionId); + await valkey.publish( + `sessions:revoked:${targetSessionId}`, + JSON.stringify({ sessionId: targetSessionId, revokedBy: auth.userId }), + ); + } catch (err) { + console.error("Failed to delete session from cache:", err); + } + + await revokeSession(targetSessionId); + + auditWrapper.auditLog(auth.userId, "session_revoked", null, { + revoked_session_id: targetSessionId, + }, getClientIp(c)); + + return c.json({ success: true }); +}); diff --git a/src/features/sessions/deck_fragments.tsx b/src/features/sessions/deck_fragments.tsx new file mode 100644 index 0000000..169f54b --- /dev/null +++ b/src/features/sessions/deck_fragments.tsx @@ -0,0 +1,124 @@ +export const SessionDeckFragment = ({ + sessions, + currentSessionId, +}: { + sessions: any[]; + currentSessionId: string; +}) => { + return ( +
+ {sessions.length === 0 + ? ( +
+

+ No active sessions found. +

+
+ ) + : ( + sessions.map((session) => { + const isCurrent = session.id === currentSessionId; + const isAgent = !!session.is_agent; + const scopes = Array.isArray(session.custom_scopes) + ? session.custom_scopes + : []; + + return ( +
+
+
+
+ {isAgent ? "๐Ÿ”‘" : isCurrent ? "๐Ÿ“ฑ" : "๐Ÿ’ป"} +
+
+
+ {session.label || + (isCurrent ? "This Device" : "Remote Device")} +
+
+ {session.id.substring(0, 14)}... +
+
+
+ +
+ {isAgent + ? Delegated + : isCurrent + ? Active Now + : Active} + {!isCurrent && ( + + )} +
+
+ +
+ {isAgent && ( +
+ Scopes:{" "} + {scopes.length > 0 ? scopes.join(", ") : "Delegated"} +
+ )} +
+ + โณ 0h 0m left ยท (Expires{" "} + {new Date(session.expires_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}) + +
+ {session.last_activity_action && ( +
+ Last Active:{" "} + {session.last_activity_action} ({new Date( + session.last_activity_at || session.created_at, + ).toLocaleTimeString()}) +
+ )} +
+ + {isAgent && ( +
+ + +
+ )} +
+ ); + }) + )} +
+ ); +}; diff --git a/src/features/sessions/drawer_fragments.tsx b/src/features/sessions/drawer_fragments.tsx new file mode 100644 index 0000000..a760b5f --- /dev/null +++ b/src/features/sessions/drawer_fragments.tsx @@ -0,0 +1,307 @@ +export const DirectPassDrawerFragment = ({ + apps = [], + isAdmin = false, +}: { + apps?: any[]; + isAdmin?: boolean; +}) => { + return ( +
+

+ Mint an isolated, scoped child session for 1-click magic links, CLI + tools, team members, or AI assistants without exposing your primary + passkeys. +

+ +
+ {/* Label Input */} +
+ + +
+ + {/* Direct 1-Click Destination */} +
+ + +

+ Selecting an app scopes the pass and automatically redirects the + recipient to that app upon 1-click redemption. +

+
+ + {/* Lifespan Presets */} +
+ +
+ + + + +
+ +
+ + {/* Access Mode Presets */} +
+ +
+ + + +
+ +
+ + {/* Granular Scope Matrix */} + + +
+ + +
+
+ + {/* Hand-Off Card */} + +
+ ); +}; diff --git a/src/features/sessions/fragments.tsx b/src/features/sessions/fragments.tsx new file mode 100644 index 0000000..57e855c --- /dev/null +++ b/src/features/sessions/fragments.tsx @@ -0,0 +1,4 @@ +export { SessionTableFragment } from "./table_fragments.tsx"; +export { DirectPassDrawerFragment } from "./drawer_fragments.tsx"; +export { ScopeModalFragment } from "./modal_fragments.tsx"; +export { SessionDeckFragment } from "./deck_fragments.tsx"; diff --git a/src/features/sessions/modal_fragments.tsx b/src/features/sessions/modal_fragments.tsx new file mode 100644 index 0000000..4d237f7 --- /dev/null +++ b/src/features/sessions/modal_fragments.tsx @@ -0,0 +1,86 @@ +export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => { + return ( + + ); +}; diff --git a/src/features/sessions/queries.ts b/src/features/sessions/queries.ts new file mode 100644 index 0000000..a93f915 --- /dev/null +++ b/src/features/sessions/queries.ts @@ -0,0 +1,92 @@ +import { sqlWrapper } from "../../core/db.ts"; + +export async function getActiveSessions(userId: string) { + 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 = ${userId} AND expires_at > NOW() + ORDER BY created_at DESC + `; + return sessions; +} + +export async function delegateSession( + sessionId: string, + userId: string, + label: string, + customScopes: string[], + expiresAt: Date, +) { + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at) + VALUES (${sessionId}, ${userId}, ${label}, true, ${customScopes}, ${expiresAt.toISOString()}, NOW()) + `; +} + +export async function getSessionByIdAndUserId( + sessionId: string, + userId: string, +) { + const session = await sqlWrapper.sql` + SELECT id, is_agent, expires_at FROM sessions WHERE id = ${sessionId} AND user_id = ${userId} + `; + if (!session || session.length === 0) return null; + return session[0]; +} + +export async function updateSessionScopes( + sessionId: string, + customScopes: string[], +) { + await sqlWrapper.sql` + UPDATE sessions SET custom_scopes = ${customScopes} WHERE id = ${sessionId} + `; +} + +export async function getSessionById(sessionId: string) { + const session = await sqlWrapper.sql` + SELECT id FROM sessions WHERE id = ${sessionId} + `; + if (!session || session.length === 0) return null; + return session[0]; +} + +export async function setSessionPaused(sessionId: string, isPaused: boolean) { + await sqlWrapper.sql` + UPDATE sessions SET is_paused = ${isPaused} WHERE id = ${sessionId} + `; +} + +export async function extendSessionExpiry(sessionId: string, newExpiry: Date) { + await sqlWrapper.sql` + UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${sessionId} + `; +} + +export async function revokeSession(sessionId: string) { + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${sessionId}`; +} + +export async function getSessionWithOwnershipCheck( + sessionId: string, + userId: string, + isAdmin: boolean, +) { + const session = await sqlWrapper.sql` + SELECT s.id + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${sessionId} + AND ( + s.user_id = ${userId} + OR ${isAdmin} + OR EXISTS ( + SELECT 1 FROM event_passes ep + WHERE ep.created_by = ${userId} + AND u.event_pass_id = ep.id + ) + ) + `; + if (!session || session.length === 0) return null; + return session[0]; +} diff --git a/src/features/sessions/routes.tsx b/src/features/sessions/routes.tsx new file mode 100644 index 0000000..ba58c33 --- /dev/null +++ b/src/features/sessions/routes.tsx @@ -0,0 +1,170 @@ +import { Hono } from "jsr:@hono/hono@4"; + +import { streamDatastar } from "../../core/sse_adapter.ts"; +import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; +import { + AuthenticatedLayoutFragment, +} from "../../shared/ui/layout_fragments.tsx"; +import { + getAuthenticatedUser, + hasScope, +} from "../../../server/auth-session.ts"; +import { getAllApps } from "../admin/queries.ts"; + +import { getActiveSessions } from "./queries.ts"; +import { + DirectPassDrawerFragment, + ScopeModalFragment, + SessionDeckFragment, + SessionTableFragment, +} from "./fragments.tsx"; +import { sessionActionsRoutes } from "./actions_routes.ts"; + +export const sessionRoutes = new Hono(); + +// Mount Actions sub-router +sessionRoutes.route("/", sessionActionsRoutes); + +// --------------------------------------------------------- +// UI Dashboard / Sessions Route +// --------------------------------------------------------- +sessionRoutes.get("/dashboard/sessions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { + return c.redirect("/login"); + } + + const sessions = await getActiveSessions(auth.userId); + const apps = await getAllApps(); + const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*"); + + return c.html( + +
+
+

+ Active Sessions & Delegation +

+

+ Manage authorized devices, mint scoped agent tokens, and oversee + live connections. +

+
+
+ +
+
+ +
+ + {/* Desktop Table */} + + + {/* Mobile Deck */} + + + {/* Delegation Drawer */} + + + {/* Scope Modal */} + + + + +
, + ); +}); + +// --------------------------------------------------------- +// Live Telemetry / Revocation SSE Stream +// --------------------------------------------------------- +sessionRoutes.get("/api/sessions/stream", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + return streamDatastar(c, async (stream) => { + try { + await stream.write({ + event: "datastar-fragment", + data: `
`, + }); + + while (!stream.aborted) { + await stream.sleep(15000); + if (stream.aborted) break; + await stream.write({ + event: "datastar-signal", + data: JSON.stringify({ lastPing: Date.now() }), + }); + } + } catch (err: any) { + if (!stream.aborted) { + const errorFragment = renderErrorToastFragment( + err.message || "Live session stream encountered an error.", + ); + await stream.write({ + event: "datastar-fragment", + data: String(errorFragment), + }); + await stream.close(); + } + } + }); +}); diff --git a/src/features/sessions/sessions.test.tsx b/src/features/sessions/sessions.test.tsx new file mode 100644 index 0000000..bd9ed34 --- /dev/null +++ b/src/features/sessions/sessions.test.tsx @@ -0,0 +1,85 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { sessionRoutes } from "./routes.tsx"; +import { + DirectPassDrawerFragment, + ScopeModalFragment, + SessionDeckFragment, + SessionTableFragment, +} from "./fragments.tsx"; + +Deno.test("[Sessions] GET /dashboard/sessions unauthenticated redirects to /login", async () => { + const req = new Request("http://localhost/dashboard/sessions"); + const res = await sessionRoutes.fetch(req); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/login"); +}); + +Deno.test("[Sessions] GET /api/sessions unauthenticated returns 401", async () => { + const req = new Request("http://localhost/api/sessions"); + const res = await sessionRoutes.fetch(req); + assertEquals(res.status, 401); +}); + +Deno.test("[Sessions] SessionTableFragment renders empty state and active sessions", () => { + const htmlEmpty = ( + + ); + assertStringIncludes(String(htmlEmpty), "No active sessions found."); + + const mockSessions = [ + { + id: "sess-abc-123", + label: "CLI Runner", + is_agent: true, + custom_scopes: ["read:events", "write:events"], + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 3600000).toISOString(), + }, + ]; + const htmlWithData = ( + + ); + assertStringIncludes(String(htmlWithData), "CLI Runner"); + assertStringIncludes(String(htmlWithData), "2 Scopes"); +}); + +Deno.test("[Sessions] SessionDeckFragment renders mobile card deck", () => { + const mockSessions = [ + { + id: "sess-mobile-123", + label: "Mobile Assistant", + is_agent: true, + custom_scopes: ["read:audit"], + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 3600000).toISOString(), + }, + ]; + const html = ( + + ); + assertStringIncludes(String(html), "Mobile Assistant"); + assertStringIncludes(String(html), "read:audit"); +}); + +Deno.test("[Sessions] DirectPassDrawerFragment renders options and form", () => { + const mockApps = [ + { id: "app-1", name: "ed-droid", domain: "ed.atyg.org" }, + ]; + const html = ; + assertStringIncludes(String(html), "Mint & Delegate Session"); + assertStringIncludes(String(html), "ed-droid"); + assertStringIncludes(String(html), "1-Click Destination App"); +}); + +Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix", () => { + const html = ; + assertStringIncludes(String(html), "Update Scopes for:"); + assertStringIncludes(String(html), "read:audit"); + assertStringIncludes(String(html), "read:users"); +}); diff --git a/src/features/sessions/table_fragments.tsx b/src/features/sessions/table_fragments.tsx new file mode 100644 index 0000000..22ff126 --- /dev/null +++ b/src/features/sessions/table_fragments.tsx @@ -0,0 +1,155 @@ +export const SessionTableFragment = ({ + sessions, + currentSessionId, +}: { + sessions: any[]; + currentSessionId: string; +}) => { + return ( + + ); +}; diff --git a/src/main.ts b/src/main.ts index 2659248..7670316 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,16 +6,20 @@ import { contentNegotiation } from "./core/content_negotiation.ts"; import { authRoutes } from "./features/auth/routes.tsx"; import { adminRoutes } from "./features/admin/routes.tsx"; +import { eventsRoutes } from "./features/events/routes.tsx"; +import { sessionRoutes } from "./features/sessions/routes.tsx"; const app: Hono = new Hono(); app.use("*", contentNegotiation()); -// Serve static assets (specifically Datastar) +// Serve static assets (specifically Datastar and client scripts) app.use("/public/*", serveStatic({ root: "./" })); -// Wire Phase 2 Vertical Slices +// Wire Feature Slices app.route("/", authRoutes); +app.route("/", eventsRoutes); +app.route("/", sessionRoutes); app.route("/admin", adminRoutes); app.route("/api/admin", adminRoutes);