All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
184 lines
6.6 KiB
TypeScript
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 };
|
|
},
|
|
});
|