feat(events): complete audit parity for vanity /e/:slug splash, /join/:slug CLI env ingress, and PIN+slug rotation
This commit is contained in:
parent
fc86565597
commit
2fa8222062
@ -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" } },
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
|
||||
116
src/features/events/splash_fragments.tsx
Normal file
116
src/features/events/splash_fragments.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
src/features/events/stream_routes.ts
Normal file
73
src/features/events/stream_routes.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user