Files
ChatApp/supabase/functions/mint-livekit-token/index.ts
T
2026-04-18 23:11:35 +02:00

119 lines
3.6 KiB
TypeScript

// Supabase Edge Function — mints a short-lived LiveKit access token after
// verifying that the caller is an accepted member of the target conversation.
//
// The LiveKit secret never leaves the server. Clients POST { conversationId }
// with their Supabase JWT and receive { token, url, roomName, identity }.
// deno-lint-ignore-file no-explicit-any
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.46.0';
import { SignJWT } from 'https://esm.sh/jose@5.9.6';
const CORS_HEADERS = {
'access-control-allow-origin': '*',
'access-control-allow-headers':
'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
};
interface RequestBody {
conversationId?: string;
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
if (req.method !== 'POST') {
return json({ error: 'method-not-allowed' }, 405);
}
const apiKey = Deno.env.get('LIVEKIT_API_KEY');
const apiSecret = Deno.env.get('LIVEKIT_API_SECRET');
const livekitUrl = Deno.env.get('LIVEKIT_URL');
if (!apiKey || !apiSecret || !livekitUrl) {
return json({ error: 'livekit-not-configured' }, 500);
}
const auth = req.headers.get('authorization') ?? '';
const userJwt = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!userJwt) {
return json({ error: 'unauthorized' }, 401);
}
// Per-request client with the caller's JWT so RLS applies.
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const client = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: auth } },
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: userRes, error: userErr } = await client.auth.getUser();
if (userErr || !userRes.user) {
return json({ error: 'unauthorized' }, 401);
}
const userId = userRes.user.id;
const body = (await req.json().catch(() => ({}))) as RequestBody;
const conversationId = body.conversationId?.trim();
if (!conversationId) {
return json({ error: 'conversation-id-required' }, 400);
}
// Membership check (RLS + explicit accepted flag).
const { data: member, error: mErr } = await client
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId)
.eq('user_id', userId)
.maybeSingle();
if (mErr || !member || !member.accepted) {
return json({ error: 'forbidden' }, 403);
}
const { data: profile } = await client
.from('profiles')
.select('username, display_name, banned')
.eq('user_id', userId)
.maybeSingle();
if (profile?.banned) {
return json({ error: 'banned' }, 403);
}
// LiveKit JWT — HS256 signed with API secret.
const nowSec = Math.floor(Date.now() / 1000);
const claims: Record<string, unknown> = {
iss: apiKey,
sub: userId,
name: profile?.display_name ?? profile?.username ?? userId,
nbf: nowSec,
exp: nowSec + 60 * 10, // 10 min
video: {
room: conversationId,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
},
};
const secretBytes = new TextEncoder().encode(apiSecret);
const token = await new SignJWT(claims as any)
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.sign(secretBytes);
return json({
token,
url: livekitUrl,
roomName: conversationId,
identity: userId,
});
});
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json', ...CORS_HEADERS },
});
}