diff --git a/src/features/events/events.test.ts b/src/features/events/events.test.ts index 307d1a5..8515459 100644 --- a/src/features/events/events.test.ts +++ b/src/features/events/events.test.ts @@ -1,5 +1,7 @@ import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { Hono } from "jsr:@hono/hono@4"; import { eventsRoutes } from "./routes.tsx"; +import { EventSplashPageFragment } from "./splash_fragments.tsx"; Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => { const req = new Request("http://localhost/join"); @@ -25,6 +27,29 @@ Deno.test("[Events] POST /api/join without code returns HTML error fragment", as assertStringIncludes(html, "Event code or PIN is required"); }); +Deno.test("[Events] EventSplashPageFragment renders event details and capacity", async () => { + const mockEvent = { + name: "Zero Trust Workshop", + slug: "zero-trust-workshop-a1b2", + pin_code: "123-456", + max_seats: 25, + seats_claimed: 10, + lifespan_hours: 4, + }; + + const testApp = new Hono(); + testApp.get( + "/test", + (c) => c.html(EventSplashPageFragment({ event: mockEvent })), + ); + const res = await testApp.fetch(new Request("http://localhost/test")); + const htmlString = await res.text(); + assertStringIncludes(htmlString, "Zero Trust Workshop"); + assertStringIncludes(htmlString, "15 seats remaining (10/25)"); + assertStringIncludes(htmlString, "123-456"); + assertStringIncludes(htmlString, "⏱️ 4h Session"); +}); + Deno.test("[Events] API actions require authentication", async () => { const endpoints = [ { method: "POST", path: "/api/events", body: { name: "Test" } }, diff --git a/src/features/events/events_actions_routes.ts b/src/features/events/events_actions_routes.ts index cd99b12..5703cc1 100644 --- a/src/features/events/events_actions_routes.ts +++ b/src/features/events/events_actions_routes.ts @@ -89,9 +89,9 @@ eventsActionsRoutes.post("/api/events/:id/rotate-pin", async (c) => { const eventId = c.req.param("id"); const isAdmin = await isGlobalAdmin(user.userId); - const newPin = await Queries.rotateEventPin(eventId, user.userId, isAdmin); + const rotation = await Queries.rotateEventPin(eventId, user.userId, isAdmin); - if (!newPin) { + if (!rotation) { return c.json( { error: "Event not found, inactive, or unauthorized" }, 404, @@ -102,11 +102,15 @@ eventsActionsRoutes.post("/api/events/:id/rotate-pin", async (c) => { user.userId, "event_ingress_rotated", eventId, - {}, + { new_pin: rotation.pinCode, new_slug: rotation.slug }, getClientIp(c), ); - return c.json({ success: true, pinCode: newPin }); + return c.json({ + success: true, + pinCode: rotation.pinCode, + slug: rotation.slug, + }); }); // --------------------------------------------------------- diff --git a/src/features/events/fragments.tsx b/src/features/events/fragments.tsx index 4273c56..8dce911 100644 --- a/src/features/events/fragments.tsx +++ b/src/features/events/fragments.tsx @@ -2,3 +2,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"; +export { EventSplashPageFragment } from "./splash_fragments.tsx"; diff --git a/src/features/events/queries.ts b/src/features/events/queries.ts index 09f757f..bb33fea 100644 --- a/src/features/events/queries.ts +++ b/src/features/events/queries.ts @@ -4,21 +4,34 @@ export async function rotateEventPin( eventId: string, userId: string, isGlobalAdmin: boolean, -): Promise { +): Promise<{ pinCode: string; slug: string } | null> { const randPin = Math.floor(100000 + Math.random() * 900000).toString(); const formattedPin = `${randPin.slice(0, 3)}-${randPin.slice(3)}`; + const randSuffix = Math.random().toString(36).substring(2, 6); + + const existing = await sqlWrapper.sql` + SELECT slug FROM event_passes + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + AND is_active = TRUE + `; + if (!existing || existing.length === 0) return null; + + const currentSlug = existing[0].slug || "event"; + const baseSlug = currentSlug.replace(/-[a-z0-9]{4}$/, ""); + const newSlug = `${baseSlug}-${randSuffix}`; 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 - `; + UPDATE event_passes + SET pin_code = ${formattedPin}, slug = ${newSlug} + WHERE id = ${eventId} + AND (created_by = ${userId} OR ${isGlobalAdmin}) + AND is_active = TRUE + RETURNING pin_code, slug + `; if (!result || result.length === 0) return null; - return result[0].pin_code; + return { pinCode: result[0].pin_code, slug: result[0].slug }; } export async function getEventAttendees( diff --git a/src/features/events/routes.tsx b/src/features/events/routes.tsx index 6e5819b..c473cf1 100644 --- a/src/features/events/routes.tsx +++ b/src/features/events/routes.tsx @@ -1,33 +1,36 @@ import { Hono } from "jsr:@hono/hono@4"; -import { - getAuthenticatedUser, - getCookieDomain, - hasScope, -} from "../../core/session.ts"; +import { getAuthenticatedUser, getCookieDomain } from "../../core/session.ts"; import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeHex } from "jsr:@std/encoding@1/hex"; import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts"; import { valkey } from "../../core/valkey.ts"; import { auditWrapper } from "../../core/audit.ts"; -import { streamDatastar } from "../../core/sse_adapter.ts"; import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; import * as Queries from "./queries.ts"; -import { EventJoinPageFragment } from "./fragments.tsx"; +import { + EventJoinPageFragment, + EventSplashPageFragment, +} from "./fragments.tsx"; import { eventsActionsRoutes } from "./events_actions_routes.ts"; +import { eventStreamRoutes } from "./stream_routes.ts"; export const eventsRoutes = new Hono(); -// Mount Actions sub-router +// Mount Actions and Stream sub-routers eventsRoutes.route("/", eventsActionsRoutes); +eventsRoutes.route("/", eventStreamRoutes); // --------------------------------------------------------- // Join Event via Code or PIN // --------------------------------------------------------- eventsRoutes.post("/api/join", async (c) => { let code = ""; - if ( - c.req.header("content-type")?.includes("application/x-www-form-urlencoded") - ) { + const contentType = c.req.header("content-type") || ""; + const accept = c.req.header("accept") || ""; + const wantsJson = accept.includes("application/json") && + !accept.includes("text/html"); + + if (contentType.includes("application/x-www-form-urlencoded")) { const fd = await c.req.formData(); code = (fd.get("code") as string) || ""; } else { @@ -36,6 +39,9 @@ eventsRoutes.post("/api/join", async (c) => { } if (!code || typeof code !== "string") { + if (wantsJson) { + return c.json({ error: "Event code or PIN is required" }, 400); + } return c.html( renderErrorToastFragment("Event code or PIN is required"), 400, @@ -46,6 +52,9 @@ eventsRoutes.post("/api/join", async (c) => { const rateLimitKey = `ratelimit:join:fail:${clientIp}`; if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) { + if (wantsJson) { + return c.json({ error: "Too Many Requests" }, 429); + } return c.html(renderErrorToastFragment("Too Many Requests"), 429); } @@ -54,6 +63,12 @@ eventsRoutes.post("/api/join", async (c) => { if (!event) { await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); + if (wantsJson) { + return c.json( + { error: "Invalid event code or workshop capacity reached" }, + 404, + ); + } return c.html( renderErrorToastFragment( "Invalid event code or workshop capacity reached", @@ -62,10 +77,55 @@ eventsRoutes.post("/api/join", async (c) => { ); } + // NAT-Safe Idempotent Re-entry: check if device already holds active session for this event + const existingUser = await getAuthenticatedUser(c); + if (existingUser && existingUser.username?.startsWith(`guest_`)) { + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + await valkey.expire(existingUser.sessionId, ttl); + + deleteCookie(c, "session_id", { path: "/" }); + const rpID = Deno.env.get("RP_ID"); + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", existingUser.sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + let appDomain = ""; + if (event.app_id) { + const app = await Queries.getAppById(String(event.app_id)); + if (app) { + appDomain = app.domain || ""; + } + } + const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + + if (wantsJson) { + return c.json({ + success: true, + sessionId: existingUser.sessionId, + username: existingUser.username, + redirectUrl, + reused: true, + }); + } + + c.header("HX-Redirect", redirectUrl); + return c.html(""); + } + const updatedEvent = await Queries.incrementEventSeats(String(event.id)); if (!updatedEvent) { await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); + if (wantsJson) { + return c.json({ error: "Workshop capacity reached" }, 404); + } return c.html(renderErrorToastFragment("Workshop capacity reached"), 404); } @@ -139,77 +199,147 @@ eventsRoutes.post("/api/join", async (c) => { const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + if (wantsJson) { + return c.json({ + success: true, + sessionId, + username, + redirectUrl, + }); + } + c.header("HX-Redirect", redirectUrl); return c.html(""); } catch (e: any) { console.error("[Events] Failed to join event:", e); + if (wantsJson) { + return c.json({ error: "Failed to join event" }, 500); + } return c.html(renderErrorToastFragment("Failed to join event"), 500); } }); // --------------------------------------------------------- -// Live Event Seats SSE Stream +// CLI 1-Liner / Environment Ingress (/join/:slug) // --------------------------------------------------------- -eventsRoutes.get("/api/events/:id/stream", async (c) => { - const user = await getAuthenticatedUser(c); - if (!user) return c.text("Unauthorized", 401); +eventsRoutes.get("/join/:slug", async (c) => { + const slug = c.req.param("slug") || ""; + const format = c.req.query("format") || "html"; - 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 { - const event = await Queries.getEventById(eventId); - if (event) { - await stream.write({ - event: "datastar-fragment", - data: - `${event.seats_claimed}`, - }); + try { + const event = await Queries.getEventBySlugOrPin(slug); + if (!event) { + if (format === "env" || format === "json") { + return c.text("Invalid slug or workshop capacity reached", 404); } + return c.redirect("/join?error=not_found", 302); + } - 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); + const updatedEvent = await Queries.incrementEventSeats(String(event.id)); + if (!updatedEvent) { + if (format === "env" || format === "json") { + return c.text("Workshop capacity reached", 404); } - } 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(); + return c.redirect("/join?error=capacity_reached", 302); + } + + 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"]; + if (updatedEvent.app_id) { + const app = await Queries.getAppById(String(updatedEvent.app_id)); + if (app) { + customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"]; } } - }); + + 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: "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); + } +}); + +// --------------------------------------------------------- +// Vanity Direct Workshop Entrance (/e/:slug) +// --------------------------------------------------------- +eventsRoutes.get("/e/:slug", async (c) => { + const slug = c.req.param("slug"); + try { + const event = await Queries.getEventBySlug(slug); + if (!event) { + return c.redirect("/join?error=event_not_found", 302); + } + return c.html(); + } catch (_e) { + return c.redirect("/join?error=db_error", 302); + } }); // --------------------------------------------------------- diff --git a/src/features/events/splash_fragments.tsx b/src/features/events/splash_fragments.tsx new file mode 100644 index 0000000..6deb897 --- /dev/null +++ b/src/features/events/splash_fragments.tsx @@ -0,0 +1,116 @@ +import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx"; + +export const EventSplashPageFragment = ({ event }: { event: any }) => { + const maxSeats = Number(event.max_seats) || 0; + const seatsClaimed = Number(event.seats_claimed) || 0; + const isFull = maxSeats > 0 && seatsClaimed >= maxSeats; + const seatsRemaining = maxSeats > 0 + ? Math.max(0, maxSeats - seatsClaimed) + : null; + + return ( + +
+
+ +

{event.name}

+

+ You've been invited to join this event sandbox session. +

+
+ +
+
+ {isFull + ? ( + + Workshop Full ({seatsClaimed}/{maxSeats}) + + ) + : seatsRemaining !== null + ? ( + + {seatsRemaining} seats remaining ({seatsClaimed}/{maxSeats}) + + ) + : ( + + {seatsClaimed} attendees active (Open Access) + + )} + + ⏱️ {event.lifespan_hours || 3}h Session + +
+ + {event.pin_code && ( +
+ Event PIN:{" "} + + {event.pin_code} + +
+ )} +
+ + {!isFull + ? ( +
+ + +
+ ) + : ( + + )} + +
+ Standard account login?{" "} + + Sign in with Passkey + +
+
+
+ ); +}; diff --git a/src/features/events/stream_routes.ts b/src/features/events/stream_routes.ts new file mode 100644 index 0000000..de25910 --- /dev/null +++ b/src/features/events/stream_routes.ts @@ -0,0 +1,73 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; +import { valkey } from "../../core/valkey.ts"; +import { streamDatastar } from "../../core/sse_adapter.ts"; +import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; +import * as Queries from "./queries.ts"; + +export const eventStreamRoutes = new Hono(); + +// --------------------------------------------------------- +// Live Event Seats SSE Stream +// --------------------------------------------------------- +eventStreamRoutes.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 { + const event = await Queries.getEventById(eventId); + if (event) { + await stream.write({ + event: "datastar-fragment", + data: + `${event.seats_claimed}`, + }); + } + + 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); + } + } 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(); + } + } + }); +});