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