Files
meet/src/lib/join-link.ts
T
WilandClaude Sonnet 5 e26cfe624d feat: add meeting creation and dashboard with join links
Adds the management/CRUD layer for meetings: a signed JWT join-link
token service (jose), instant/scheduled meeting creation endpoints,
and a protected /dashboard page listing the host's active, upcoming,
and past meetings with a "Nueva reunión" action area (instant button +
scheduling dialog). Also fixes a Base UI console warning (missing
nativeButton={false}) on Button instances rendered as Link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-29 11:57:42 -05:00

88 lines
2.8 KiB
TypeScript

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<string> {
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
}
}