Files
DiscordClone/convex/voice.ts
Bryan1029384756 6813bb40dc
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
1.1.00
2026-04-16 20:14:27 -05:00

184 lines
6.6 KiB
TypeScript

"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<VoiceTokenResult> => {
// 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 };
},
});