auth-yes/server/routes/events.ts

379 lines
11 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 } from "../auth-session.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/end", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
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}
RETURNING slug
`;
if (!eventResult || eventResult.length === 0) {
return c.json({ error: "Event not found or unauthorized" }, 404);
}
const slug = eventResult[0].slug;
const sessionResult = await sqlWrapper.sql`
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
)
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);
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 = expires_at + interval '${extendHours} hours'
WHERE id = ${eventId} AND created_by = ${user.userId} AND is_active = TRUE
RETURNING slug, expires_at
`;
if (!eventResult || eventResult.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
const slug = eventResult[0].slug;
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 username LIKE ${"guest_" + slug + "_%"}
)
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);
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);
}
code = code.trim();
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE (slug = ${code} OR pin_code = ${code})
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) {
return c.json(
{ error: "Invalid event code or workshop capacity reached" },
404,
);
}
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
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"];
let appDomain = "";
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"];
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,
}),
);
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";
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE slug = ${slug}
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 username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
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,
}),
);
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);
}
});