import "server-only" import { SignJWT, jwtVerify, errors as joseErrors } from "jose" // Signed, time-limited join-link tokens: mirror the original Laravel // version's approach of encoding meeting access in a token rather than // relying on a guessable/incrementing URL. The token carries just enough // info (the meeting's UUID) for the future /join/[meetingUuid] page to // resolve the meeting and admit the bearer. const secret = process.env.JOIN_LINK_SECRET if (!secret) { throw new Error( "JOIN_LINK_SECRET environment variable is not set. Add it to .env (see .env.example)." ) } const encodedSecret = new TextEncoder().encode(secret) // Grace period a join link stays valid for, counted either from issuance // (instant meetings) or from the scheduled start time (scheduled // meetings), whichever is further in the future. const GRACE_PERIOD_MS = 6 * 60 * 60 * 1000 interface JoinLinkTokenPayload { meetingUuid: string } /** * Creates a signed JWT encoding access to a meeting by UUID. * * For instant meetings, pass no `scheduledAt` and the token expires 6 hours * from now. For scheduled meetings, pass the meeting's `scheduledAt` so the * token stays valid through the grace period that follows the scheduled * start time, even if that's further out than 6 hours from now. */ export async function createJoinLinkToken( meetingUuid: string, options?: { scheduledAt?: Date | null } ): Promise { const now = Date.now() let expiresAtMs = now + GRACE_PERIOD_MS if (options?.scheduledAt) { const scheduledExpiry = options.scheduledAt.getTime() + GRACE_PERIOD_MS expiresAtMs = Math.max(expiresAtMs, scheduledExpiry) } return new SignJWT({ meetingUuid } satisfies JoinLinkTokenPayload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(Math.floor(expiresAtMs / 1000)) .sign(encodedSecret) } /** * Verifies a join-link token. Returns `null` on any invalid/expired/ * malformed token rather than throwing, so callers (e.g. the future * /join/[meetingUuid] page) can handle it gracefully (show "link expired", * fall back to requesting admission, etc.) instead of crashing. */ export async function verifyJoinLinkToken( token: string ): Promise<{ meetingUuid: string } | null> { try { const { payload } = await jwtVerify(token, encodedSecret, { algorithms: ["HS256"], }) if (typeof payload.meetingUuid !== "string" || !payload.meetingUuid) { return null } return { meetingUuid: payload.meetingUuid } } catch (error) { if ( error instanceof joseErrors.JWTExpired || error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JWTInvalid ) { return null } // Any other unexpected verification failure: still fail closed. return null } }