auth-yes/server/routes/events.ts

638 lines
19 KiB
TypeScript

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,
hasScope,
isGlobalAdmin,
} from "../auth-session.ts";
import { getClientIp } from "../middleware.ts";
import { auditWrapper } from "../audit.ts";
import { rateLimitWrapper } from "../ratelimit.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/rotate-pin", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
if (!hasScope(user, "write:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
const eventId = c.req.param("id");
try {
const event = await sqlWrapper.sql`
SELECT slug, name FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
AND is_active = TRUE
`;
if (!event || event.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
// Generate new PIN
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
// Generate new Slug
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
const newSuffix = Math.random().toString(36).substring(2, 6);
const newSlug = `${baseSlug}-${newSuffix}`;
const updateResult = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${newPinCode}, slug = ${newSlug}
WHERE id = ${eventId}
RETURNING pin_code, slug
`;
auditWrapper.auditLog(
user.userId,
"event_ingress_rotated",
eventId,
{},
getClientIp(c),
);
return c.json({
success: true,
pinCode: updateResult[0].pin_code,
slug: updateResult[0].slug,
});
} catch (e: any) {
console.error("[Events] Failed to rotate event PIN:", e);
return c.json({ error: "Failed to rotate PIN" }, 500);
}
});
eventRoutes.post("/api/events/:id/expand", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
if (!hasScope(user, "write:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
const eventId = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
const addSeats = Math.max(Number(body.addSeats) || 5, 1);
try {
const eventResult = await sqlWrapper.sql`
UPDATE event_passes
SET max_seats = max_seats + ${addSeats}
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
AND is_active = TRUE
RETURNING max_seats
`;
if (!eventResult || eventResult.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
auditWrapper.auditLog(user.userId, "event_seats_expanded", eventId, {
added: addSeats,
newMax: eventResult[0].max_seats,
}, getClientIp(c));
return c.json({ success: true, maxSeats: eventResult[0].max_seats });
} catch (e: any) {
console.error("[Events] Failed to expand event seats:", e);
return c.json({ error: "Failed to expand seats" }, 500);
}
});
eventRoutes.get("/api/events/:id/attendees", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
if (!hasScope(user, "read:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
const eventId = c.req.param("id");
try {
const event = await sqlWrapper.sql`
SELECT id, slug, max_seats, expires_at FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
`.then((res: any) => res[0]);
if (!event) {
return c.json({ error: "Event not found or unauthorized" }, 404);
}
const attendees = await sqlWrapper.sql`
SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE u.event_pass_id = ${eventId}
ORDER BY s.created_at DESC
`;
return c.json({
success: true,
attendees,
event: {
max_seats: event.max_seats,
expires_at: event.expires_at,
},
});
} catch (e: any) {
console.error("[Events] Failed to fetch attendees:", e);
return c.json({ error: "Failed to fetch attendees" }, 500);
}
});
eventRoutes.post("/api/events/:id/end", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
if (!hasScope(user, "write:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
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} OR ${await isGlobalAdmin(
user.userId,
)})
RETURNING id
`;
if (!eventResult || eventResult.length === 0) {
return c.json({ error: "Event not found or unauthorized" }, 404);
}
const sessionResult = await sqlWrapper.sql`
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE event_pass_id = ${eventId}
)
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);
if (!hasScope(user, "write:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
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 = GREATEST(expires_at, NOW()) + interval '${extendHours} hours'
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)}) AND is_active = TRUE
RETURNING id, expires_at
`;
if (!eventResult || eventResult.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
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 event_pass_id = ${eventId}
)
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);
if (!hasScope(user, "write:events")) {
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
}
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);
}
const rawNormalized = code.trim().toLowerCase();
const pinNormalized = code.trim().replace(/[-\s]/g, "");
const clientIp = getClientIp(c);
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
return c.json({ error: "Too Many Requests" }, 429);
}
try {
const eventLookup = await sqlWrapper.sql`
SELECT * FROM event_passes
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
`;
if (!eventLookup || eventLookup.length === 0) {
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
return c.json(
{ error: "Invalid event code or workshop capacity reached" },
404,
);
}
const event = eventLookup[0];
const user = await getAuthenticatedUser(c);
if (user && user.username?.startsWith(`guest_${event.slug}_`)) {
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
await valkey.expire(user.sessionId, ttl);
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", user.sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
let appDomain = "";
if (event.app_id) {
const apps = await sqlWrapper
.sql`SELECT domain FROM apps WHERE id = ${event.app_id}`;
if (apps.length > 0) {
appDomain = apps[0].domain || "";
}
}
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: user.sessionId,
token: user.sessionId,
guestUuid: user.userId,
username: user.username,
redirectUrl,
reused: true,
});
}
const updateResult = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE id = ${event.id}
AND (max_seats = 0 OR seats_claimed < max_seats)
RETURNING *
`;
if (!updateResult || updateResult.length === 0) {
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
return c.json(
{ error: "Workshop capacity reached" },
404,
);
}
const updatedEvent = updateResult[0];
const guestUuid = crypto.randomUUID();
const eventShortId = String(updatedEvent.id).split("-")[0];
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
VALUES (${guestUuid}, ${username}, ${
updatedEvent.name + " Attendee"
}, 'guest', ${updatedEvent.id})
ON CONFLICT DO NOTHING
`;
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"];
let appDomain = "";
if (updatedEvent.app_id) {
const apps = await sqlWrapper
.sql`SELECT name, domain FROM apps WHERE id = ${updatedEvent.app_id}`;
if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, updatedEvent.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,
}),
);
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
slug: updatedEvent.slug,
name: updatedEvent.name,
seatNumber: updatedEvent.seats_claimed,
method: "web",
}, getClientIp(c));
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";
const rawNormalized = slug.trim().toLowerCase();
const pinNormalized = slug.trim().replace(/[-\s]/g, "");
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
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 eventShortId = String(event.id).split("-")[0];
const username = `guest_${eventShortId}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
VALUES (${guestUuid}, ${username}, ${
event.name + " Attendee"
}, 'guest', ${event.id})
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,
}),
);
auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, {
slug: event.slug,
name: event.name,
seatNumber: event.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);
}
});
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);
}
});