import { Hono } from "jsr:@hono/hono@4"; import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeHex } from "jsr:@std/encoding@1/hex"; import { sqlWrapper } from "../db.ts"; import { valkey } from "../valkey.ts"; import { getAuthenticatedUser, getCookieDomain, hasScope, isGlobalAdmin, } from "../auth-session.ts"; import { getClientIp } from "../middleware.ts"; import { auditWrapper } from "../audit.ts"; import { rateLimitWrapper } from "../ratelimit.ts"; import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; export const eventRoutes = new Hono(); // --------------------------------------------------------- // Multi-Claim Event Passes & Short Code Join Portal // --------------------------------------------------------- eventRoutes.post("/api/events/:id/rotate-pin", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "write:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const eventId = c.req.param("id"); try { const event = await sqlWrapper.sql` SELECT slug, name FROM event_passes WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) AND is_active = TRUE `; if (!event || event.length === 0) { return c.json( { error: "Event not found, inactive, or unauthorized" }, 404, ); } // Generate new PIN const randPin = Math.floor(100000 + Math.random() * 900000).toString(); const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); // Generate new Slug const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, ""); const newSuffix = Math.random().toString(36).substring(2, 6); const newSlug = `${baseSlug}-${newSuffix}`; const updateResult = await sqlWrapper.sql` UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId} RETURNING pin_code, slug `; auditWrapper.auditLog( user.userId, "event_ingress_rotated", eventId, {}, getClientIp(c), ); return c.json({ success: true, pinCode: updateResult[0].pin_code, slug: updateResult[0].slug, }); } catch (e: any) { console.error("[Events] Failed to rotate event PIN:", e); return c.json({ error: "Failed to rotate PIN" }, 500); } }); eventRoutes.post("/api/events/:id/expand", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "write:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const eventId = c.req.param("id"); const body = await c.req.json().catch(() => ({})); const addSeats = Math.max(Number(body.addSeats) || 5, 1); try { const eventResult = await sqlWrapper.sql` UPDATE event_passes SET max_seats = max_seats + ${addSeats} WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) AND is_active = TRUE RETURNING max_seats `; if (!eventResult || eventResult.length === 0) { return c.json( { error: "Event not found, inactive, or unauthorized" }, 404, ); } auditWrapper.auditLog(user.userId, "event_seats_expanded", eventId, { added: addSeats, newMax: eventResult[0].max_seats, }, getClientIp(c)); return c.json({ success: true, maxSeats: eventResult[0].max_seats }); } catch (e: any) { console.error("[Events] Failed to expand event seats:", e); return c.json({ error: "Failed to expand seats" }, 500); } }); eventRoutes.get("/api/events/:id/attendees", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "read:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const eventId = c.req.param("id"); try { const event = await sqlWrapper.sql` SELECT slug FROM event_passes WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) `.then((res: any) => res[0]); if (!event) { return c.json({ error: "Event not found or unauthorized" }, 404); } const likePattern = `guest_${event.slug}_%`; const attendees = await sqlWrapper.sql` SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, u.username, u.display_name FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username LIKE ${likePattern} ORDER BY s.created_at DESC `; return c.json({ success: true, attendees }); } catch (e: any) { console.error("[Events] Failed to fetch attendees:", e); return c.json({ error: "Failed to fetch attendees" }, 500); } }); eventRoutes.post("/api/events/:id/end", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "write:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const eventId = c.req.param("id"); try { const eventResult = await sqlWrapper.sql` UPDATE event_passes SET is_active = FALSE WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( user.userId, )}) RETURNING slug `; if (!eventResult || eventResult.length === 0) { return c.json({ error: "Event not found or unauthorized" }, 404); } const slug = eventResult[0].slug; const sessionResult = await sqlWrapper.sql` DELETE FROM sessions WHERE user_id IN ( SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"} ) RETURNING id `; const sessionIds = sessionResult.map((s: any) => s.id); if (sessionIds.length > 0) { await valkey.del(...sessionIds); } return c.json({ success: true, revokedCount: sessionIds.length }); } catch (e: any) { console.error("[Events] Failed to end event:", e); return c.json({ error: "Failed to end event" }, 500); } }); eventRoutes.post("/api/events/:id/extend", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "write:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const eventId = c.req.param("id"); const body = await c.req.json().catch(() => ({})); const extendHours = Math.max(Number(body.extendHours) || 1, 1); try { const eventResult = await sqlWrapper.sql` UPDATE event_passes SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours' WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( user.userId, )}) AND is_active = TRUE RETURNING slug, expires_at `; if (!eventResult || eventResult.length === 0) { return c.json( { error: "Event not found, inactive, or unauthorized" }, 404, ); } const slug = eventResult[0].slug; const newExpiresAt = new Date(eventResult[0].expires_at); const sessionResult = await sqlWrapper.sql` UPDATE sessions SET expires_at = expires_at + interval '${extendHours} hours' WHERE user_id IN ( SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"} ) RETURNING id `; const sessionIds = sessionResult.map((s: any) => s.id); if (sessionIds.length > 0) { const ttlSeconds = Math.max( 1, Math.floor((newExpiresAt.getTime() - Date.now()) / 1000), ); for (const sid of sessionIds) { await valkey.expire(sid, ttlSeconds); } } return c.json({ success: true, expiresAt: newExpiresAt.toISOString() }); } catch (e: any) { console.error("[Events] Failed to extend event:", e); return c.json({ error: "Failed to extend event" }, 500); } }); eventRoutes.post("/api/events", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); if (!hasScope(user, "write:events")) { return c.json({ error: "Forbidden: Insufficient scopes" }, 403); } const body = await c.req.json().catch(() => ({})); const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = body; let { slug, pinCode } = body; if (!name || typeof name !== "string") { return c.json({ error: "Event name is required" }, 400); } if (!slug) { slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Math.random().toString(36).substring(2, 6); } if (!pinCode) { const randPin = Math.floor(100000 + Math.random() * 900000).toString(); pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); } try { const expiresAt = new Date( Date.now() + Number(lifespanHours) * 3600 * 1000, ); const result = await sqlWrapper.sql` INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at) VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${ Number(maxSeats) }, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt}) RETURNING * `; return c.json({ success: true, event: result[0] }); } catch (e: any) { console.error("[Events] Failed to create event pass:", e); return c.json({ error: "Failed to create event pass" }, 500); } }); eventRoutes.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.json({ error: "Event code or PIN is required" }, 400); } const rawNormalized = code.trim().toLowerCase(); const pinNormalized = code.trim().replace(/[-\s]/g, ""); const clientIp = getClientIp(c); const rateLimitKey = `ratelimit:join:fail:${clientIp}`; if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) { return c.json({ error: "Too Many Requests" }, 429); } try { 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) { await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); return c.json( { error: "Invalid event code or workshop capacity reached" }, 404, ); } const event = eventLookup[0]; const user = await getAuthenticatedUser(c); if (user && user.username?.startsWith(`guest_${event.slug}_`)) { const ttl = (Number(event.lifespan_hours) || 3) * 3600; await valkey.expire(user.sessionId, ttl); deleteCookie(c, "session_id", { path: "/" }); const rpID = Deno.env.get("RP_ID"); const cookieDomain = getCookieDomain(rpID); setCookie(c, "session_id", user.sessionId, { domain: cookieDomain, path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: ttl, }); let appDomain = ""; if (event.app_id) { const apps = await sqlWrapper .sql`SELECT domain FROM apps WHERE id = ${event.app_id}`; if (apps.length > 0) { appDomain = apps[0].domain || ""; } } const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; if ( c.req.header("accept")?.includes("text/html") && !c.req.header("accept")?.includes("application/json") ) { return c.redirect(redirectUrl, 302); } return c.json({ success: true, sessionId: user.sessionId, token: user.sessionId, guestUuid: user.userId, username: user.username, redirectUrl, reused: true, }); } const updateResult = await sqlWrapper.sql` UPDATE event_passes SET seats_claimed = seats_claimed + 1 WHERE id = ${event.id} AND (max_seats = 0 OR seats_claimed < max_seats) RETURNING * `; if (!updateResult || updateResult.length === 0) { await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); return c.json( { error: "Workshop capacity reached" }, 404, ); } const updatedEvent = updateResult[0]; const guestUuid = crypto.randomUUID(); const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`; await sqlWrapper.sql` INSERT INTO users (id, username, display_name, account_status) VALUES (${guestUuid}, ${username}, ${ updatedEvent.name + " Attendee" }, 'guest') ON CONFLICT DO NOTHING `; 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 apps = await sqlWrapper .sql`SELECT name, domain FROM apps WHERE id = ${updatedEvent.app_id}`; if (apps.length > 0) { customScopes = [`app:${apps[0].name}`, updatedEvent.role || "viewer"]; appDomain = apps[0].domain || ""; } } const expiresAt = new Date(Date.now() + ttl * 1000); await sqlWrapper.sql` INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${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"; if ( c.req.header("accept")?.includes("text/html") && !c.req.header("accept")?.includes("application/json") ) { return c.redirect(redirectUrl, 302); } return c.json({ success: true, sessionId, token: sessionId, guestUuid, username, redirectUrl, }); } catch (e: any) { console.error("[Events] Failed to join event:", e); return c.json({ error: "Failed to join event" }, 500); } }); eventRoutes.get("/join/:slug", async (c) => { const slug = c.req.param("slug") || ""; const format = c.req.query("format") || "html"; const rawNormalized = slug.trim().toLowerCase(); const pinNormalized = slug.trim().replace(/[-\s]/g, ""); try { const result = await sqlWrapper.sql` UPDATE event_passes SET seats_claimed = seats_claimed + 1 WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized}) AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW()) AND (max_seats = 0 OR seats_claimed < max_seats) RETURNING * `; if (!result || result.length === 0) { if (format === "env" || format === "json") { return c.text("Invalid slug or workshop capacity reached", 404); } return c.redirect("/join?error=not_found", 302); } const event = result[0]; const guestUuid = crypto.randomUUID(); const username = `guest_${event.slug}_${event.seats_claimed}`; await sqlWrapper.sql` INSERT INTO users (id, username, display_name, account_status) VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') ON CONFLICT DO NOTHING `; const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const sessionId = `ay_sess_${encodeHex(randomBytes)}`; const label = `${event.name} Seat #${event.seats_claimed}`; const ttl = (Number(event.lifespan_hours) || 3) * 3600; let customScopes = ["guest", "trial"]; if (event.app_id) { const apps = await sqlWrapper .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; if (apps.length > 0) { customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; } } const expiresAt = new Date(Date.now() + ttl * 1000); await sqlWrapper.sql` INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) `; await valkey.setex( sessionId, ttl, JSON.stringify({ uuid: guestUuid, username, account_status: "guest", customScopes, }), ); auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli", }, getClientIp(c)); if (format === "env") { return c.text( `export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`, ); } else if (format === "json") { return c.json({ success: true, token: sessionId, username, expiresAt: expiresAt.toISOString(), }); } deleteCookie(c, "session_id", { path: "/" }); const rpID = Deno.env.get("RP_ID"); setCookie(c, "session_id", sessionId, { domain: getCookieDomain(rpID), path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: ttl, }); return c.redirect("/dashboard", 302); } catch (e: any) { console.error("[Events] Failed to execute CLI join:", e); return c.text("Internal Server Error", 500); } }); eventRoutes.get("/join", (c) => { return c.html(EventJoinPage()); }); eventRoutes.get("/e/:slug", async (c) => { const slug = c.req.param("slug"); try { const result = await sqlWrapper.sql` SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE `; if (!result || result.length === 0) { return c.redirect("/join?error=event_not_found", 302); } return c.html(EventSplashPage({ event: result[0] })); } catch (_e) { return c.redirect("/join?error=db_error", 302); } });