119 lines
3.6 KiB
TypeScript
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 },
|
|
});
|
|
}
|