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:
1 parent
e26cfe624d
commit
3c19455bdd
16 files changed
+1367
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user