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 { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { eventsRoutes } from "./routes.tsx";
|
import { eventsRoutes } from "./routes.tsx";
|
||||||
|
import { EventSplashPageFragment } from "./splash_fragments.tsx";
|
||||||
|
|
||||||
Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => {
|
Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => {
|
||||||
const req = new Request("http://localhost/join");
|
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");
|
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 () => {
|
Deno.test("[Events] API actions require authentication", async () => {
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
{ method: "POST", path: "/api/events", body: { name: "Test" } },
|
{ 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 eventId = c.req.param("id");
|
||||||
const isAdmin = await isGlobalAdmin(user.userId);
|
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(
|
return c.json(
|
||||||
{ error: "Event not found, inactive, or unauthorized" },
|
{ error: "Event not found, inactive, or unauthorized" },
|
||||||
404,
|
404,
|
||||||
@ -102,11 +102,15 @@ eventsActionsRoutes.post("/api/events/:id/rotate-pin", async (c) => {
|
|||||||
user.userId,
|
user.userId,
|
||||||
"event_ingress_rotated",
|
"event_ingress_rotated",
|
||||||
eventId,
|
eventId,
|
||||||
{},
|
{ new_pin: rotation.pinCode, new_slug: rotation.slug },
|
||||||
getClientIp(c),
|
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 { WorkshopPassDrawerFragment } from "./drawer_fragments.tsx";
|
||||||
export { GuestDrawerAttendeesFragment } from "./attendees_fragments.tsx";
|
export { GuestDrawerAttendeesFragment } from "./attendees_fragments.tsx";
|
||||||
export { EventJoinPageFragment } from "./join_fragments.tsx";
|
export { EventJoinPageFragment } from "./join_fragments.tsx";
|
||||||
|
export { EventSplashPageFragment } from "./splash_fragments.tsx";
|
||||||
|
|||||||
@ -4,21 +4,34 @@ export async function rotateEventPin(
|
|||||||
eventId: string,
|
eventId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
isGlobalAdmin: boolean,
|
isGlobalAdmin: boolean,
|
||||||
): Promise<string | null> {
|
): Promise<{ pinCode: string; slug: string } | null> {
|
||||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
const formattedPin = `${randPin.slice(0, 3)}-${randPin.slice(3)}`;
|
const formattedPin = `${randPin.slice(0, 3)}-${randPin.slice(3)}`;
|
||||||
|
const randSuffix = Math.random().toString(36).substring(2, 6);
|
||||||
|
|
||||||
const result = await sqlWrapper.sql`
|
const existing = await sqlWrapper.sql`
|
||||||
UPDATE event_passes
|
SELECT slug FROM event_passes
|
||||||
SET pin_code = ${formattedPin}
|
|
||||||
WHERE id = ${eventId}
|
WHERE id = ${eventId}
|
||||||
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||||
AND is_active = TRUE
|
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;
|
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(
|
export async function getEventAttendees(
|
||||||
|
|||||||
@ -1,33 +1,36 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import {
|
import { getAuthenticatedUser, getCookieDomain } from "../../core/session.ts";
|
||||||
getAuthenticatedUser,
|
|
||||||
getCookieDomain,
|
|
||||||
hasScope,
|
|
||||||
} from "../../core/session.ts";
|
|
||||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||||
import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts";
|
import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts";
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { auditWrapper } from "../../core/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
import { streamDatastar } from "../../core/sse_adapter.ts";
|
|
||||||
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||||
import * as Queries from "./queries.ts";
|
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 { eventsActionsRoutes } from "./events_actions_routes.ts";
|
||||||
|
import { eventStreamRoutes } from "./stream_routes.ts";
|
||||||
|
|
||||||
export const eventsRoutes = new Hono();
|
export const eventsRoutes = new Hono();
|
||||||
|
|
||||||
// Mount Actions sub-router
|
// Mount Actions and Stream sub-routers
|
||||||
eventsRoutes.route("/", eventsActionsRoutes);
|
eventsRoutes.route("/", eventsActionsRoutes);
|
||||||
|
eventsRoutes.route("/", eventStreamRoutes);
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// Join Event via Code or PIN
|
// Join Event via Code or PIN
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
eventsRoutes.post("/api/join", async (c) => {
|
eventsRoutes.post("/api/join", async (c) => {
|
||||||
let code = "";
|
let code = "";
|
||||||
if (
|
const contentType = c.req.header("content-type") || "";
|
||||||
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
|
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();
|
const fd = await c.req.formData();
|
||||||
code = (fd.get("code") as string) || "";
|
code = (fd.get("code") as string) || "";
|
||||||
} else {
|
} else {
|
||||||
@ -36,6 +39,9 @@ eventsRoutes.post("/api/join", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!code || typeof code !== "string") {
|
if (!code || typeof code !== "string") {
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({ error: "Event code or PIN is required" }, 400);
|
||||||
|
}
|
||||||
return c.html(
|
return c.html(
|
||||||
renderErrorToastFragment("Event code or PIN is required"),
|
renderErrorToastFragment("Event code or PIN is required"),
|
||||||
400,
|
400,
|
||||||
@ -46,6 +52,9 @@ eventsRoutes.post("/api/join", async (c) => {
|
|||||||
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
|
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
|
||||||
|
|
||||||
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
|
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);
|
return c.html(renderErrorToastFragment("Too Many Requests"), 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +63,12 @@ eventsRoutes.post("/api/join", async (c) => {
|
|||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Invalid event code or workshop capacity reached" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
return c.html(
|
return c.html(
|
||||||
renderErrorToastFragment(
|
renderErrorToastFragment(
|
||||||
"Invalid event code or workshop capacity reached",
|
"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));
|
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
|
||||||
|
|
||||||
if (!updatedEvent) {
|
if (!updatedEvent) {
|
||||||
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({ error: "Workshop capacity reached" }, 404);
|
||||||
|
}
|
||||||
return c.html(renderErrorToastFragment("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";
|
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
|
||||||
|
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
username,
|
||||||
|
redirectUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
c.header("HX-Redirect", redirectUrl);
|
c.header("HX-Redirect", redirectUrl);
|
||||||
return c.html("");
|
return c.html("");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("[Events] Failed to join event:", e);
|
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);
|
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) => {
|
eventsRoutes.get("/join/:slug", async (c) => {
|
||||||
const user = await getAuthenticatedUser(c);
|
const slug = c.req.param("slug") || "";
|
||||||
if (!user) return c.text("Unauthorized", 401);
|
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 {
|
try {
|
||||||
const event = await Queries.getEventById(eventId);
|
const event = await Queries.getEventBySlugOrPin(slug);
|
||||||
if (event) {
|
if (!event) {
|
||||||
await stream.write({
|
if (format === "env" || format === "json") {
|
||||||
event: "datastar-fragment",
|
return c.text("Invalid slug or workshop capacity reached", 404);
|
||||||
data:
|
}
|
||||||
`<span id="event-seats-${eventId}">${event.seats_claimed}</span>`,
|
return c.redirect("/join?error=not_found", 302);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriber = valkey.duplicate();
|
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
|
||||||
await subscriber.subscribe(`event:seats:${eventId}`);
|
if (!updatedEvent) {
|
||||||
|
if (format === "env" || format === "json") {
|
||||||
subscriber.on("message", async (_channel: string, message: string) => {
|
return c.text("Workshop capacity reached", 404);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
});
|
return c.redirect("/join?error=capacity_reached", 302);
|
||||||
|
|
||||||
while (!stream.aborted) {
|
|
||||||
await stream.sleep(15000);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error("[Events SSE] Stream error:", err);
|
const guestUuid = crypto.randomUUID();
|
||||||
if (!stream.aborted) {
|
const eventShortId = String(updatedEvent.id).split("-")[0];
|
||||||
const errorHtml = renderErrorToastFragment(
|
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
|
||||||
"Live updates connection lost.",
|
|
||||||
|
await Queries.createGuestUser(
|
||||||
|
guestUuid,
|
||||||
|
username,
|
||||||
|
updatedEvent.name + " Attendee",
|
||||||
|
String(updatedEvent.id),
|
||||||
);
|
);
|
||||||
await stream.write({
|
|
||||||
event: "datastar-fragment",
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
data: await errorHtml,
|
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) {
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
await subscriber.unsubscribe();
|
const rpID = Deno.env.get("RP_ID");
|
||||||
await subscriber.quit();
|
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