Files
backspace/packages/server/src/routes/livekit.ts
T
Jannis Braun aae0b1a74e fix: comprehensive client-side session management for federated DM calls
Four fixes addressing the full state management problem:

1. Passive ready handler: no longer auto-connects to LiveKit on
   page refresh. Prevents identity conflicts when the same user
   has multiple sessions fighting for one LiveKit identity slot.
   The user must re-accept to join; state is shown but not acted on.

2. SoundController sync guard: incomingCallLoading/outgoingCallLoading
   refs prevent multiple playSound calls during async audio load.
   If call is cancelled while sound loads, stops it immediately on
   completion. Eliminates the "5 ringtones at once" bug.

3. Host dm_call_accepted broadcasts now include federatedCallId so
   all clients (including remote instances) can match the event.

4. Removed all diagnostic console.log statements.
2026-04-08 14:12:45 +02:00

129 lines
4.5 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { AccessToken, TrackSource } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelSpaceId, hasPermission, computePermissions, isDmMember, PermissionBits } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
import { getDb, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
/**
* Generate a LiveKit token for a federated call participant.
* Uses homeUserId as identity (stable across instances).
* Short TTL (5 min) — must join quickly.
*/
export async function generateFederatedCallToken(
roomName: string,
homeUserId: string,
displayName: string,
): Promise<string> {
const token = new AccessToken(config.livekit.apiKey!, config.livekit.apiSecret!, {
identity: `${homeUserId}:${displayName}`,
ttl: '5m',
});
token.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canPublishSources: [
TrackSource.MICROPHONE,
TrackSource.CAMERA,
TrackSource.SCREEN_SHARE,
TrackSource.SCREEN_SHARE_AUDIO,
],
canSubscribe: true,
canPublishData: true,
});
return token.toJwt();
}
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', {
preHandler: authenticate,
}, async (request, reply) => {
if (!config.livekit.apiKey || !config.livekit.apiSecret) {
return reply.code(503).send({ error: 'Voice/video is not configured on this server', statusCode: 503 });
}
const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string };
// Determine room name based on channel type
let roomName: string;
// Default: full publish (DM calls always get full permissions)
let canSpeak = true;
let canStream = true;
if (dmChannelId && typeof dmChannelId === 'string') {
// DM call token
if (!isDmMember(dmChannelId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
// Federated DMs use federatedId as room name (cross-instance stable).
// Local-only DMs use dm-{dmChannelId}.
const db = getDb();
const channel = db.select({ federatedId: schema.dmChannels.federatedId })
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, dmChannelId))
.get();
roomName = channel?.federatedId ? channel.federatedId : `dm-${dmChannelId}`;
} else if (channelId && typeof channelId === 'string') {
// Space voice channel token
const spaceId = getChannelSpaceId(channelId);
if (!spaceId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
}
// Check SPEAK and STREAM permissions for granular token grants
const perms = computePermissions(request.userId, spaceId, channelId);
canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
canStream = (perms & PermissionBits.STREAM) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
roomName = channelId;
} else {
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
}
const identity = `${request.userId}:${request.username}`;
const token = new AccessToken(config.livekit.apiKey, config.livekit.apiSecret, {
identity,
ttl: '1h',
});
// Build canPublishSources based on permissions
const canPublishSources: TrackSource[] = [];
if (canSpeak) {
canPublishSources.push(TrackSource.MICROPHONE);
canPublishSources.push(TrackSource.CAMERA);
}
if (canStream) {
canPublishSources.push(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
token.addGrant({
room: roomName,
roomJoin: true,
canPublish: canSpeak || canStream,
canPublishSources,
canSubscribe: true,
canPublishData: true,
});
const jwt = await token.toJwt();
const livekitUrl = config.livekit.url ?? '';
const response: LiveKitTokenResponse = {
token: jwt,
url: livekitUrl
};
return reply.code(200).send(response);
});
}