fix: harden booking and meeting flows

This commit is contained in:
2026-09-17 02:42:27 +02:00
parent cb30ecade9
commit 66710fc65c
42 changed files with 683 additions and 608 deletions
+42
View File
@@ -0,0 +1,42 @@
import { createHmac } from "node:crypto";
import type { Payload } from "payload";
import type { Event, MeetingSetting } from "@/payload-types";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "./status";
import type { MeetingRole } from "./token";
export function meetingAccessVersion(event: Event): string {
const secret = process.env.MEETING_SESSION_SECRET;
if (!secret) throw new Error("MEETING_SESSION_SECRET is not set");
return createHmac("sha256", secret).update(JSON.stringify([
event.id, event.slug, event.meetingPassword, event.date, event.startTime,
event.endTime, event.isOnline, event._status, event.isPrivateBooking,
])).digest("hex");
}
export function meetingWindow(event: Event, settings: MeetingSetting, role: MeetingRole, now = new Date()) {
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime ? combineDateAndTime(event.date, event.endTime) : new Date(start.getTime() + 3600000);
const closeAt = end.getTime() + settings.meetingCloseAfterMinutes * 60000;
const status = getMeetingStatus({ start, end,
joinWindowMinutes: role === "host" ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes, now });
return { allowed: Number.isFinite(closeAt) && end > start && canJoinMeeting(status), closeAt, status };
}
/** Shared by every issuer: existing credentials are not a substitute for current access. */
export async function authorizeMeeting(payload: Payload, event: Event, role: MeetingRole) {
if (!event.isOnline || (!event.isPrivateBooking && event._status !== "published" && role !== "host")) return null;
if (event.isPrivateBooking) {
const { docs } = await payload.find({ collection: "booking-requests", depth: 0, limit: 1,
where: { and: [
{ linkedEvent: { equals: event.id } }, { status: { equals: "confirmed" } },
{ appointmentType: { equals: "online" } },
] },
});
if (!docs[0]) return null;
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const window = meetingWindow(event, settings, role);
if (!window.allowed) return null;
return { accessVersion: meetingAccessVersion(event), expiresAt: window.closeAt };
}
+10 -4
View File
@@ -17,9 +17,9 @@ function getLiveKitApiUrl(): string {
}
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
export function getLiveKitRoomName(eventSlug: string): string {
export function getLiveKitRoomName(eventSlug: string, accessVersion?: string): string {
const secret = requiredEnv("LIVEKIT_API_SECRET");
return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`;
return `anouma-${createHmac("sha256", secret).update(JSON.stringify([eventSlug, accessVersion])).digest("hex").slice(0, 32)}`;
}
export async function createLiveKitToken(args: {
@@ -28,12 +28,18 @@ export async function createLiveKitToken(args: {
name: string;
role: MeetingRole;
clientId?: string;
accessVersion?: string;
expiresAt: number;
}): Promise<string> {
const room = getLiveKitRoomName(args.eventSlug);
const room = getLiveKitRoomName(args.eventSlug, args.accessVersion);
const remainingSeconds = Math.floor((args.expiresAt - Date.now()) / 1000);
if (remainingSeconds <= 0) throw new Error("Meeting access has expired");
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
identity: args.participantId,
name: args.name,
ttl: "2h",
// Self-hosted LiveKit cannot revoke already issued JWTs. Keep the
// connection credential short-lived; the app re-authorizes reconnects.
ttl: Math.min(60, remainingSeconds),
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
});
token.addGrant({
+19
View File
@@ -0,0 +1,19 @@
import type { CollectionAfterChangeHook } from "payload";
import type { Event } from "@/payload-types";
import { meetingAccessVersion } from "./access";
import { getLiveKitRoomName, getLiveKitRoomService } from "./livekit";
/** Disconnect the previous room when its password, schedule or access changes. */
export const revokeChangedMeeting: CollectionAfterChangeHook<Event> = async ({ doc, previousDoc, req }) => {
if (!previousDoc?.isOnline || !previousDoc.slug || !process.env.LIVEKIT_URL || !process.env.MEETING_SESSION_SECRET) return doc;
const previousVersion = meetingAccessVersion(previousDoc);
if (previousVersion === meetingAccessVersion(doc)) return doc;
try {
const service = getLiveKitRoomService();
const name = getLiveKitRoomName(previousDoc.slug, previousVersion);
if ((await service.listRooms([name])).length) await service.deleteRoom(name);
} catch (err) {
req.payload.logger.error({ err, msg: "Could not disconnect revoked meeting room" });
}
return doc;
};
+2
View File
@@ -36,6 +36,8 @@ export function getMeetingStatus({
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
const t = now.getTime();
if (![openAt, closeAt, t].every(Number.isFinite) || end <= start) return "ended";
if (t >= closeAt) return "ended";
if (t >= start.getTime()) return "live";
if (t >= openAt) return "open";
+9 -1
View File
@@ -17,6 +17,8 @@ export type MeetingTokenPayload = {
clientId?: string;
name: string;
role: MeetingRole;
accessVersion?: string;
testRoom?: boolean;
exp: number; // unix ms
};
@@ -41,6 +43,8 @@ export function createMeetingToken(args: {
role: MeetingRole;
clientId?: string;
ttlMs?: number;
accessVersion?: string;
testRoom?: boolean;
}): { token: string; participantId: string } {
const participantId = randomUUID();
const clientId =
@@ -53,6 +57,8 @@ export function createMeetingToken(args: {
clientId,
name: args.name,
role: args.role,
accessVersion: args.accessVersion,
testRoom: args.testRoom,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
};
const body = base64url(JSON.stringify(payload));
@@ -76,7 +82,9 @@ export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null {
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
if (
!Number.isFinite(payload.exp) ||
payload.exp < Date.now() ||
payload.exp <= Date.now() ||
(payload.accessVersion !== undefined && !/^[0-9a-f]{64}$/.test(payload.accessVersion)) ||
(payload.testRoom !== undefined && typeof payload.testRoom !== "boolean") ||
typeof payload.eventSlug !== "string" ||
payload.eventSlug.length > 200 ||
typeof payload.participantId !== "string" ||