diff --git a/scratch/add_data_ignore.ts b/scratch/add_data_ignore.ts new file mode 100644 index 0000000..55c8c6a --- /dev/null +++ b/scratch/add_data_ignore.ts @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "fs"; + +let content = readFileSync("src/features/events/fragments.tsx", "utf-8"); + +// Add data-ignore to buttons with onclick="copyText..." +content = content.replace(/onclick=\{`copyText/g, 'data-ignore onclick={`copyText'); +content = content.replace(/onclick=\{`event\.stopPropagation\(\); copyText/g, 'data-ignore onclick={`event.stopPropagation(); copyText'); +content = content.replace(/class="btn-outline extend-event-btn"/g, 'class="btn-outline extend-event-btn" data-on-click={`@post("/api/events/${event.id}/extend")`}'); +content = content.replace(/class="btn-danger end-event-btn"/g, 'class="btn-danger end-event-btn" data-on-click={`@post("/api/events/${event.id}/end")`}'); +content = content.replace(/onclick=\{`openAttendeesDrawer/g, 'data-on-click={`@get("/api/events/${event.id}/attendees")`} onclick={`openAttendeesDrawer'); +content = content.replace(/onclick=\{`expandSeats/g, 'data-on-click={`@post("/api/events/${event.id}/expand")`} onclick={`expandSeats'); +content = content.replace(/onclick=\{`rotatePin/g, 'data-on-click={`@post("/api/events/${event.id}/rotate-pin")`} onclick={`rotatePin'); + +writeFileSync("src/features/events/fragments.tsx", content); diff --git a/scratch/add_data_ignore_2.ts b/scratch/add_data_ignore_2.ts new file mode 100644 index 0000000..9ee72b0 --- /dev/null +++ b/scratch/add_data_ignore_2.ts @@ -0,0 +1,8 @@ +import { readFileSync, writeFileSync } from "fs"; + +let content = readFileSync("src/features/events/fragments.tsx", "utf-8"); + +content = content.replace(/onclick="copyEventHandoff/g, 'data-ignore onclick="copyEventHandoff'); +content = content.replace(/id="submitEventBtn"/g, 'id="submitEventBtn" data-on-click="@post(\'/api/events\')"'); + +writeFileSync("src/features/events/fragments.tsx", content); diff --git a/scratch/add_data_ignore_sessions.ts b/scratch/add_data_ignore_sessions.ts new file mode 100644 index 0000000..4684cf4 --- /dev/null +++ b/scratch/add_data_ignore_sessions.ts @@ -0,0 +1,8 @@ +import { readFileSync, writeFileSync } from "fs"; +let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8"); + +content = content.replace(/onclick="copyHandoff/g, 'data-ignore onclick="copyHandoff'); +content = content.replace(/class="btn-danger revoke-btn"/g, 'class="btn-danger revoke-btn" data-on-click={`@delete("/api/sessions/${session.id}")`}'); +content = content.replace(/onclick=\{`extendSession\('\$\{session.id\}', 1\)`\}/g, 'data-on-click={`@post("/api/sessions/${session.id}/extend")`}'); + +writeFileSync("src/features/sessions/fragments.tsx", content); diff --git a/scratch/clean.ts b/scratch/clean.ts new file mode 100644 index 0000000..32b0f5f --- /dev/null +++ b/scratch/clean.ts @@ -0,0 +1,8 @@ +import { readFileSync, writeFileSync } from "fs"; +let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8"); +content = content.replace(" );\n};", " );\n};"); // no-op just to make sure +let lines = content.split('\n'); +lines[466] = ' );'; +lines[467] = '};'; +lines[468] = ''; +writeFileSync("src/features/sessions/fragments.tsx", lines.join('\n')); diff --git a/scratch/fix_imports.ts b/scratch/fix_imports.ts new file mode 100644 index 0000000..b5ce8a5 --- /dev/null +++ b/scratch/fix_imports.ts @@ -0,0 +1,15 @@ +import { readFileSync, writeFileSync } from "fs"; + +let content = readFileSync("src/features/events/routes.tsx", "utf-8"); + +content = content.replace('../../core/auth_session.ts', '../../../server/auth-session.ts'); +content = content.replace('../../core/middleware.ts', '../../../server/middleware.ts'); +content = content.replace('../../core/audit.ts', '../../../server/audit.ts'); +content = content.replace('../../core/ratelimit.ts', '../../../server/ratelimit.ts'); + +content = content.replace(/data: errorHtml/g, 'data: await errorHtml'); // Since it might be a promise of HtmlEscapedString + +// Fix implicitly any array +content = content.replace(/const events = \[\];/g, 'const events: any[] = [];'); + +writeFileSync("src/features/events/routes.tsx", content); diff --git a/scratch/fix_test.ts b/scratch/fix_test.ts new file mode 100644 index 0000000..31fa1a8 --- /dev/null +++ b/scratch/fix_test.ts @@ -0,0 +1,4 @@ +import { readFileSync, writeFileSync } from "fs"; +let content = readFileSync("src/features/events/events.test.ts", "utf-8"); +content = content.replace('assertStringIncludes(html, "data-on-submit=\\\"@post(\'/api/join\')\\\"");', 'assertStringIncludes(html, "data-on-submit=\\\"@post('/api/join')\\\"");'); +writeFileSync("src/features/events/events.test.ts", content); diff --git a/scratch/replace.ts b/scratch/replace.ts new file mode 100644 index 0000000..64d5893 --- /dev/null +++ b/scratch/replace.ts @@ -0,0 +1,23 @@ +import { readFileSync, writeFileSync } from "fs"; + +let content = readFileSync("src/features/events/fragments.tsx", "utf-8"); + +const workshopDrawer = readFileSync("ui/components/sessions/WorkshopDrawer.tsx", "utf-8") + .replace('export const WorkshopDrawer = ({ apps }: { apps: any[] }) => {', '') + .replace(/}$/, ''); + +const eventGuestsDrawer = readFileSync("ui/components/sessions/EventGuestsDrawer.tsx", "utf-8") + .replace('export const EventGuestsDrawer = () => {', '') + .replace(/}$/, ''); + +content = content.replace( + 'export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {\n return
Workshop Pass Drawer Placeholder
\n}', + 'export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {' + workshopDrawer + '}' +); + +content = content.replace( + 'export const GuestDrawerAttendeesFragment = () => {\n return
Guest Drawer Attendees Placeholder
\n}', + 'export const GuestDrawerAttendeesFragment = () => {' + eventGuestsDrawer + '}' +); + +writeFileSync("src/features/events/fragments.tsx", content); diff --git a/scratch/replace_sessions.ts b/scratch/replace_sessions.ts new file mode 100644 index 0000000..7282cca --- /dev/null +++ b/scratch/replace_sessions.ts @@ -0,0 +1,23 @@ +import { readFileSync, writeFileSync } from "fs"; + +let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8"); + +const directPassDrawer = readFileSync("ui/components/sessions/DirectPassDrawer.tsx", "utf-8") + .replace('export const DirectPassDrawer = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {', '') + .replace(/}$/, ''); + +const scopeModal = readFileSync("ui/components/sessions/ScopeModal.tsx", "utf-8") + .replace('export const ScopeModal = ({ apps }: { apps: any[] }) => {', '') + .replace(/}$/, ''); + +content = content.replace( + 'export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {\n// We will insert the content of DirectPassDrawer and adapt it to Datastar\n return
DirectPassDrawerFragment Placeholder
\n}', + 'export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {' + directPassDrawer + '}' +); + +content = content.replace( + 'export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {\n return
ScopeModalFragment Placeholder
\n}', + 'export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {' + scopeModal + '}' +); + +writeFileSync("src/features/sessions/fragments.tsx", content); diff --git a/scratch/replace_sessions2.ts b/scratch/replace_sessions2.ts new file mode 100644 index 0000000..5b2bb3e --- /dev/null +++ b/scratch/replace_sessions2.ts @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "fs"; +let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8"); + +const sessionDeck = readFileSync("ui/components/sessions/SessionDeck.tsx", "utf-8") + .replace('export const SessionDeck = (\n { sessions, currentSessionId }: {\n sessions: any[];\n currentSessionId: string;\n },\n) => {', '') + .replace('export const SessionDeck = (\n { sessions, currentSessionId }: {\n sessions: any[];\n currentSessionId: string;\n }\n) => {', '') + .replace(/}$/, ''); + +content = content.replace( + 'export const SessionDeckFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {\n return
SessionDeckFragment Placeholder
\n}', + 'export const SessionDeckFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {' + sessionDeck + '}' +); + +writeFileSync("src/features/sessions/fragments.tsx", content); diff --git a/src/features/events/events.test.ts b/src/features/events/events.test.ts new file mode 100644 index 0000000..d584819 --- /dev/null +++ b/src/features/events/events.test.ts @@ -0,0 +1,32 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { eventsRoutes } from "./routes.tsx"; +import { Hono } from "jsr:@hono/hono@4"; +import * as Queries from "./queries.ts"; + +// Mocking authenticated user logic is complex in a simple pure unit test without a mock DB, +// so we'll test the public join page for HTML response. + +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({}) + }); + + // We expect it to fail early in validation and return an HTML error fragment + 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..e870de0 --- /dev/null +++ b/src/features/events/fragments.tsx @@ -0,0 +1,910 @@ +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 (only visible in compact mode) */} +
+
+ + PIN:{" "} + {event.pin_code} + {" "} + (Copy) + + + Link (Copy) + + + CLI (Copy) + +
+
+ + +
+
+
+ ); + })} +
+ +
+ ); +}; + +export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => { + return ( + + ); +}; + +export const GuestDrawerAttendeesFragment = () => { + return ( + + ); +}; + +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..37ba1a6 --- /dev/null +++ b/src/features/events/queries.ts @@ -0,0 +1,149 @@ +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..3263658 --- /dev/null +++ b/src/features/events/routes.tsx @@ -0,0 +1,253 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { getAuthenticatedUser, hasScope, isGlobalAdmin, getCookieDomain } 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, WorkshopPassDrawerFragment, GuestDrawerAttendeesFragment, 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/fragments.tsx b/src/features/sessions/fragments.tsx new file mode 100644 index 0000000..ab8917f --- /dev/null +++ b/src/features/sessions/fragments.tsx @@ -0,0 +1,675 @@ +import { Hono } from "jsr:@hono/hono@4"; + +export const SessionTableFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => { + return ( + + ); +};; + +export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => { +
+

+ 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 (App Selection) */} +
+ + +

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

+
+ + {/* Lifespan Presets */} +
+ +
+ + + + +
+ +
+ + {/* Access Mode Presets */} +
+ +
+ + + {isAdmin && ( + + )} + +
+ +
+ + {/* Progressive Disclosure: Specific App & Capability Scopes */} +
+ + โ–ธ Customize Specific App Grants & Capabilities (Optional) + +
+

+ Select which subsidiary microservices and IAM capabilities this + delegated token is permitted to access: +

+ +
+ + + + {apps.map((app) => ( + + ))} +
+
+
+ +
+ + +
+
+ + {/* Hand-Off Card (Revealed upon generation) */} + +
+ ); +}; + +export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => { + return ( + + ); +}; +} + +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/queries.ts b/src/features/sessions/queries.ts new file mode 100644 index 0000000..723eec7 --- /dev/null +++ b/src/features/sessions/queries.ts @@ -0,0 +1,76 @@ +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]; +}