feat: add LiveKit join flow with waiting room and host admission

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
WilandClaude Sonnet 5 committed 2026-10-29 12:44:33 -05:00
1 parent e26cfe624d
commit 3c19455bdd
16 files changed
+1367

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import "server-only"
import { AccessToken } from "livekit-server-sdk"
// Server-side LiveKit integration: minting access tokens for participants
// to connect to the self-hosted LiveKit SFU (see LIVEKIT_URL/API_KEY/SECRET
// in .env). Room name convention: the meeting's `uuid` is used directly as
// the LiveKit room name (stable, unique, matches the original Laravel
// version's approach — no separate mapping table needed).
const apiKey = process.env.LIVEKIT_API_KEY
const apiSecret = process.env.LIVEKIT_API_SECRET
const url = process.env.LIVEKIT_URL
if (!apiKey || !apiSecret || !url) {
throw new Error(
"LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET environment variables are not set. Add them to .env (see .env.example)."
)
}
export const LIVEKIT_URL = url
interface MintAccessTokenOptions {
/** LiveKit room name — use the meeting's `uuid`. */
roomName: string
/** Stable per-participant identity, e.g. `user-{id}` or `guest-{uuid}`. */
identity: string
/** Shown to other participants as `Participant.name`. */
displayName: string
canPublish?: boolean
canSubscribe?: boolean
/** Host/co-host get room-admin grants; everyone else gets standard publish/subscribe. */
isHost?: boolean
}
/**
* Mints a signed LiveKit access token (JWT) authorizing a participant to
* join a room. This only requires the token's signing key/secret to match
* the LiveKit server's configuration — it does not itself open a network
* connection, so minting succeeds even if the actual WebSocket handshake
* against the server would fail due to mismatched credentials.
*/
export async function mintAccessToken(
options: MintAccessTokenOptions
): Promise<string> {
const at = new AccessToken(apiKey, apiSecret, {
identity: options.identity,
name: options.displayName,
ttl: "6h",
})
at.addGrant({
room: options.roomName,
roomJoin: true,
roomAdmin: options.isHost ?? false,
canPublish: options.canPublish ?? true,
canSubscribe: options.canSubscribe ?? true,
})
return at.toJwt()
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only"
import type { Meeting } from "@prisma/client"
import { prisma } from "@/lib/prisma"
/**
* True if `userId` is the meeting's host, or an admitted co-host of it.
* Used to gate host-only actions (listing/admitting/denying participants)
* server-side — never trust a client-side role check alone.
*/
export async function isHostOrCoHost(
meeting: Meeting,
userId: number
): Promise<boolean> {
if (meeting.hostUserId === userId) {
return true
}
const participant = await prisma.meetingParticipant.findUnique({
where: {
meetingId_identity: {
meetingId: meeting.id,
identity: `user-${userId}`,
},
},
})
return (
!!participant &&
participant.status === "admitted" &&
(participant.role === "host" || participant.role === "co_host")
)
}
+58
View File
@@ -0,0 +1,58 @@
import "server-only"
import { cookies } from "next/headers"
import { auth } from "@/auth"
// Resolves "who is looking at this meeting" without trusting anything the
// client sends directly: an authenticated user's identity is derived from
// their session, and an anonymous guest's identity is derived from an
// httpOnly cookie set server-side after they submit the name-entry form
// (see /api/meetings/[meetingUuid]/join-request). Neither path lets the
// caller spoof another participant's identity.
export function guestIdentityCookieName(meetingUuid: string): string {
return `meet-guest-${meetingUuid}`
}
// Matches the join-link token grace period (src/lib/join-link.ts) so a
// guest's identity cookie doesn't outlive the link that let them in.
export const GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS = 6 * 60 * 60
export type ViewerIdentity =
| {
kind: "user"
identity: string
userId: number
displayName: string
}
| {
kind: "guest"
identity: string
}
export async function resolveViewerIdentity(
meetingUuid: string
): Promise<ViewerIdentity | null> {
const session = await auth()
if (session?.user?.id) {
return {
kind: "user",
identity: `user-${session.user.id}`,
userId: Number(session.user.id),
displayName: session.user.displayName ?? session.user.email ?? "Usuario",
}
}
const cookieStore = await cookies()
const guestIdentity = cookieStore.get(
guestIdentityCookieName(meetingUuid)
)?.value
if (guestIdentity) {
return { kind: "guest", identity: guestIdentity }
}
return null
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod"
export const guestNameSchema = z.object({
guestName: z
.string()
.trim()
.min(1, "El nombre es obligatorio")
.max(80, "El nombre es demasiado largo"),
})
export type GuestNameInput = z.infer<typeof guestNameSchema>