"use node"; import { action } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; import { AccessToken, RoomServiceClient } from "livekit-server-sdk"; import crypto from "crypto"; import type { Id } from "./_generated/dataModel"; // Shapes of the internal queries below, spelled out so voice.ts can typecheck // without depending on `internal.auth.*` inference — otherwise the codegen // cycle (voice exports -> _generated/api -> internal.auth typing -> voice // usage) drops back to `any` and TS7022/TS7023 fire on the whole action. type VoiceTokenUser = { userId: Id<"userProfiles">; username: string; publicSigningKey: string; }; type VoiceTokenChannel = { channelId: Id<"channels">; type: string; }; type VoiceTokenResult = { token: string } | { error: string }; // Signed message the client must sign with their Ed25519 private signing key // to prove they control the userId they're asking a token for. `timestamp` // defeats replay; the channelId binds the signature to a specific room. function buildVoiceTokenMessage( userId: string, channelId: string, timestamp: number, ): string { return `voice-token:${userId}:${channelId}:${timestamp}`; } /** * Generate a LiveKit join token for a voice channel. * * Authorization: caller signs `voice-token:userId:channelId:timestamp` with * their Ed25519 signing key. The server verifies against the userProfile's * `publicSigningKey`, rejects stale timestamps, and confirms the channel * exists and is a voice/dm room. The LiveKit identity is pinned to the * server-resolved user so clients can't impersonate each other even with a * valid signature for their own account. * * LiveKit servers run with `room.auto_create: false` reject joins for * rooms that don't already exist — the client gets a 404 "requested * room does not exist" back from the /rtc/v1/validate endpoint. To * make this deployment-agnostic, we pre-create the room via the * LiveKit Server SDK before minting the token. When auto-create is * enabled the `createRoom` call is idempotent (409 Conflict is * swallowed silently), so the same code path works on both * configurations. * * Requires `LIVEKIT_URL` (or the frontend's `VITE_LIVEKIT_URL` as a * fallback) in the Convex environment so the RoomServiceClient * can talk to the LiveKit API. */ export const getToken = action({ args: { channelId: v.id("channels"), userId: v.id("userProfiles"), timestamp: v.number(), signature: v.string(), }, returns: v.union( v.object({ token: v.string() }), v.object({ error: v.string() }), ), handler: async (ctx, args): Promise => { // Reject timestamps outside a 5-minute window on either side of the // server clock. Matches the recovery action's window and keeps replay // attempts short-lived even if a signature leaks. const now = Date.now(); if (!Number.isFinite(args.timestamp) || Math.abs(now - args.timestamp) > 5 * 60 * 1000) { return { error: "Request expired. Please try again." }; } const user: VoiceTokenUser | null = await ctx.runQuery( internal.auth.getUserForVoiceToken, { userId: args.userId }, ); if (!user) { return { error: "User not found" }; } const channel: VoiceTokenChannel | null = await ctx.runQuery( internal.auth.getChannelForVoiceToken, { channelId: args.channelId }, ); if (!channel) { return { error: "Channel not found" }; } if (channel.type !== "voice" && channel.type !== "dm") { return { error: "Not a voice channel" }; } // Verify the caller actually controls `userId` by checking their // signature over (userId, channelId, timestamp). const message = buildVoiceTokenMessage(user.userId, channel.channelId, args.timestamp); try { const publicKeyObj = crypto.createPublicKey({ key: user.publicSigningKey, format: "pem", type: "spki", }); const isValid = crypto.verify( null, Buffer.from(message), publicKeyObj, Buffer.from(args.signature, "hex"), ); if (!isValid) { return { error: "Invalid signature" }; } } catch { return { error: "Signature verification failed" }; } const apiKey = process.env.LIVEKIT_API_KEY || "devkey"; const apiSecret = process.env.LIVEKIT_API_SECRET || "secret"; const livekitUrl = process.env.LIVEKIT_URL || process.env.VITE_LIVEKIT_URL || ""; // Ensure the room exists. The LiveKit API accepts `http(s)` URLs // for the management endpoint, but the frontend connect URL is a // `wss://` — swap the scheme when needed. if (livekitUrl) { const httpUrl = livekitUrl .replace(/^wss:\/\//i, "https://") .replace(/^ws:\/\//i, "http://"); try { const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret); await roomService.createRoom({ name: channel.channelId, // Empty rooms auto-destroy after 5 minutes with no participants, // matching LiveKit's own default so stale rooms from a crashed // client don't pile up forever. emptyTimeout: 5 * 60, // 50 participants is plenty for a voice channel in this // single-server deployment and keeps any runaway join loop // from hitting the global limit. maxParticipants: 50, }); } catch (err: any) { // 409 / "already exists" is expected when a room has already // been created by an earlier join — swallow it and continue. const errMsg = String(err?.message ?? err ?? ""); const status = err?.status ?? err?.statusCode; const alreadyExists = status === 409 || /already exists/i.test(errMsg) || /AlreadyExists/i.test(errMsg); if (!alreadyExists) { // Non-fatal: log and fall through to token generation. If the // real issue was misconfiguration the client will surface the // 404 it already does. console.warn("LiveKit createRoom failed:", errMsg); } } } // Pin identity + name to the server-resolved user. A forged `username` // or `userId` in the args would have already been rejected by the // signature check, but using the DB values is defence-in-depth. const at: AccessToken = new AccessToken(apiKey, apiSecret, { identity: user.userId, name: user.username, ttl: "24h", }); at.addGrant({ roomJoin: true, room: channel.channelId, canPublish: true, canSubscribe: true, canPublishData: true, }); const token: string = await at.toJwt(); return { token }; }, });