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 } from "../auth-session.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/end", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); 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} 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); 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 = expires_at + interval '${extendHours} hours' WHERE id = ${eventId} AND created_by = ${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); 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); } code = code.trim(); try { const result = await sqlWrapper.sql` UPDATE event_passes SET seats_claimed = seats_claimed + 1 WHERE (slug = ${code} OR pin_code = ${code}) 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) { return c.json( { error: "Invalid event code or workshop capacity reached" }, 404, ); } 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"]; let appDomain = ""; 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"]; 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, }), ); 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"; try { const result = await sqlWrapper.sql` UPDATE event_passes SET seats_claimed = seats_claimed + 1 WHERE slug = ${slug} 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, }), ); 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); } });