initial
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Supabase Edge Functions env. Loaded by `supabase functions serve`.
|
||||
# Rotate LIVEKIT_API_SECRET before any prod deployment.
|
||||
|
||||
LIVEKIT_URL=ws://localhost:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=devsecret-at-least-32-bytes-long-please
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user