offline · self-host only
BuddsApp
A group chat I built for my friends: invite-only workspaces, real-time messages, voice rooms, stories, and an AI bot with opinions. It isn't hosted anymore. This page is about how it's wired, and what I fixed before open-sourcing it.
- stack
- React 19 · Vite · Supabase · LiveKit · Gemini
- built
- solo, for one friend group
- status
- source available, bring your own keys
What's in it
Workspaces
Invite-only spaces. You only see the members, chats and stories of the workspace you joined.
Real-time chat
Supabase Realtime for messages and typing state. Images, files, voice notes, doodles, and a tic-tac-toe game inside the chat.
Voice rooms
LiveKit rooms for group calls. Room tokens are signed on the server, never in the browser.
Budds AI
A Gemini-backed bot you can call into any chat. It reads the last few messages and answers in character.
Stories
Story-style photo and video posts, scoped to your workspace.
Admin panel
Roles, invite codes, announcements and member management for whoever runs the group.
How it's wired
There's no custom backend server. The browser talks to Supabase directly, and anything that needs a secret goes through an Edge Function. Postgres row-level security decides what each user can read or write.
What I fixed before publishing
The first version worked for a group of friends who trusted each other. Before putting the code in public, I went through it as if a stranger would read the bundle. Four things didn't hold up.
-
The LiveKit secret was in the browser
Voice room tokens were signed client-side with
VITE_LIVEKIT_API_SECRET. Anything prefixed withVITE_ends up in the JavaScript bundle, so anyone could mint tokens for any room. Signing moved to an Edge Function that also checks the caller belongs to that chat.src/utils/livekitToken.js - const apiSecret = import.meta.env.VITE_LIVEKIT_API_SECRET; - const jwt = await new SignJWT({ ... }).sign(secretKey); + const { data } = await supabase.functions + .invoke('livekit-token', { + body: { roomName, participantName, participantId } + });
-
The Gemini key was readable from a table
The AI key sat in a
global_settingsrow that the client fetched before every request. Any signed-in user could read it. Prompts now go through anai-chatfunction that holds the key, fixes the model server-side and caps the request size. The old row gets deleted by the migration.src/services/aiService.js - const { data } = await supabase.from('global_settings') - .select('text_value').eq('id', 'gemini_api_key').single(); + const { data, error } = await supabase.functions + .invoke('ai-chat', { body: { contents, generationConfig } });
-
"Admin" was an email check in the frontend
The app compared the user's email to
VITE_ADMIN_EMAIL, and a hard-coded "easter egg" password could skip the invite step. Both were visible in the bundle. Admin is now arolecolumn, and a trigger stops users from changing their own role or workspace.supabase/migrations/0001_rls_policies.sql if current_user in ('authenticated', 'anon') and not public.is_admin() then + new.role := old.role; + new.workspace_id := old.workspace_id; end if;
-
Invite codes could be listed
The client looked up invites directly, so the table had to be readable, which meant unused codes could be listed. Invites are now admin-only, and joining happens through one
security definerfunction that checks the code, marks it used and assigns the workspace in a single step.src/pages/Invite.jsx - await supabase.from('invites').select('*').eq('id', token) + await supabase.rpc('redeem_invite', { code: token })
Not verified yet: the RLS policies and the trigger are written and reviewed, but they haven't been run against a live database. The repo also doesn't include the table schema yet, so a fresh setup can't fully work until that's exported.
Run it yourself
You'll need free accounts on Supabase, LiveKit Cloud and Google AI Studio. The full guide is in the README.
-
Clone and install.
git clone https://github.com/onuracar-dev/buddsapp cd buddsapp && npm install
-
Fill in
.envwith your public Supabase and LiveKit values.VITE_SUPABASE_URL=... VITE_SUPABASE_ANON_KEY=... VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
-
Store the secrets on Supabase and deploy the functions.
supabase secrets set LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... GEMINI_API_KEY=... supabase functions deploy livekit-token supabase functions deploy ai-chat
-
Apply
supabase/migrations, optionally loadsupabase/seed.sql, then start the app.npm run dev