feat(events): complete audit parity for vanity /e/:slug splash, /join/:slug CLI env ingress, and PIN+slug rotation

This commit is contained in:
Tyler Gillispie 2026-08-27 22:33:39 -07:00
parent fc86565597
commit 2fa8222062
7 changed files with 441 additions and 79 deletions

View File

@ -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" } },

View File

@ -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,
});
});
// ---------------------------------------------------------

View File

@ -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";

View File

@ -4,21 +4,34 @@ export async function rotateEventPin(
eventId: string,
userId: string,
isGlobalAdmin: boolean,
): Promise<string | null> {
): 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 result = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${formattedPin}
const existing = await sqlWrapper.sql`
SELECT slug FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${userId} OR ${isGlobalAdmin})
AND is_active = TRUE
RETURNING pin_code
`;
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}, 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(

View File

@ -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:
`<span id="event-seats-${eventId}">${event.seats_claimed}</span>`,
});
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:
`<span id="event-seats-${eventId}">${parsed.seats_claimed}</span>`,
});
} catch (_err) {
// Ignore malformed messages
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
if (!updatedEvent) {
if (format === "env" || format === "json") {
return c.text("Workshop capacity reached", 404);
}
});
while (!stream.aborted) {
await stream.sleep(15000);
return c.redirect("/join?error=capacity_reached", 302);
}
} catch (err) {
console.error("[Events SSE] Stream error:", err);
if (!stream.aborted) {
const errorHtml = renderErrorToastFragment(
"Live updates connection lost.",
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),
);
await stream.write({
event: "datastar-fragment",
data: await errorHtml,
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(),
});
}
} finally {
if (subscriber) {
await subscriber.unsubscribe();
await subscriber.quit();
}
}
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(<EventSplashPageFragment event={event} />);
} catch (_e) {
return c.redirect("/join?error=db_error", 302);
}
});
// ---------------------------------------------------------

View File

@ -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 (
<AuthLayoutFragment title={`Join ${event.name}`}>
<div>
<div class="brand-header">
<div
class="brand-logo"
style="background: var(--primary-light); color: var(--primary);"
>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
</path>
<path d="M13 5v2"></path>
<path d="M13 17v2"></path>
<path d="M13 11v2"></path>
</svg>
</div>
<h1>{event.name}</h1>
<p class="subtitle">
You've been invited to join this event sandbox session.
</p>
</div>
<div
class="card"
style="margin-bottom: 1.5rem; text-align: center; border: 1px solid var(--border-subtle); background: var(--surface-muted);"
>
<div style="display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap;">
{isFull
? (
<span class="badge badge-danger">
Workshop Full ({seatsClaimed}/{maxSeats})
</span>
)
: seatsRemaining !== null
? (
<span class="badge badge-success">
{seatsRemaining} seats remaining ({seatsClaimed}/{maxSeats})
</span>
)
: (
<span class="badge badge-success">
{seatsClaimed} attendees active (Open Access)
</span>
)}
<span class="badge badge-info">
{event.lifespan_hours || 3}h Session
</span>
</div>
{event.pin_code && (
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.5rem;">
Event PIN:{" "}
<code style="font-weight: 700; color: var(--primary); font-size: 0.95rem;">
{event.pin_code}
</code>
</div>
)}
</div>
{!isFull
? (
<form id="splashForm" data-on-submit="@post('/api/join')">
<input type="hidden" name="code" value={event.slug} />
<button
type="submit"
id="joinSplashBtn"
class="btn-primary"
style="width: 100%; min-height: 52px; font-size: 1.05rem; box-shadow: var(--shadow-sm);"
>
Enter Workshop & Claim Seat
</button>
</form>
)
: (
<button
type="button"
class="btn-outline"
disabled
style="width: 100%; min-height: 52px; opacity: 0.6; cursor: not-allowed;"
>
Workshop at Capacity
</button>
)}
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
Standard account login?{" "}
<a
href="/login"
style="color: var(--primary); text-decoration: none; font-weight: 600;"
>
Sign in with Passkey
</a>
</div>
</div>
</AuthLayoutFragment>
);
};

View File

@ -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:
`<span id="event-seats-${eventId}">${event.seats_claimed}</span>`,
});
}
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:
`<span id="event-seats-${eventId}">${parsed.seats_claimed}</span>`,
});
} 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();
}
}
});
});