import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; export type MeetingRole = "host" | "participant"; export type MeetingTokenPayload = { eventSlug: string; participantId: string; name: string; role: MeetingRole; exp: number; // unix ms }; function secret(): string { const value = process.env.MEETING_SESSION_SECRET; if (!value) throw new Error("MEETING_SESSION_SECRET is not set"); return value; } function base64url(input: Buffer | string): string { return Buffer.from(input).toString("base64url"); } function sign(data: string): string { return createHmac("sha256", secret()).update(data).digest("base64url"); } /** Issues a short-lived, self-contained, signed token for one meeting session. */ export function createMeetingToken(args: { eventSlug: string; name: string; role: MeetingRole; ttlMs?: number; }): { token: string; participantId: string } { const participantId = randomUUID(); const payload: MeetingTokenPayload = { eventSlug: args.eventSlug, participantId, name: args.name, role: args.role, exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default }; const body = base64url(JSON.stringify(payload)); const signature = sign(body); return { token: `${body}.${signature}`, participantId }; } /** Verifies signature + expiry. Returns null if invalid/expired/tampered. */ export function verifyMeetingToken(token: string): MeetingTokenPayload | null { const parts = token.split("."); if (parts.length !== 2) return null; const [body, signature] = parts; const expected = sign(body); const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload; if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null; if (!payload.eventSlug || !payload.participantId || !payload.name || !payload.role) return null; return payload; } catch { return null; } }