Video gallery with Supabase RLS and Ittybit
Every user in your app gets their own video gallery. They upload through a Supabase-authenticated client, an Edge Function dispatches an HLS transcoding task to Ittybit, and row-level security ensures users only ever see their own media. No admin endpoints, no filtering logic in your app code — Postgres handles isolation at the query layer.
Architecture
- User signs in with Supabase Auth and uploads a video to Storage
- Client inserts a row in the
mediatable with theiruser_id - An Edge Function sends the file to Ittybit as an
adaptive_videotask - Ittybit transcodes the video into HLS and sends a webhook on completion
- A second Edge Function receives the webhook and updates the
mediarow - RLS policy ensures
SELECTonly returns rows whereauth.uid() = user_id
Create the media table
create table public.media (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
filename text not null,
source_url text not null,
task_id text,
status text not null default 'pending',
hls_url text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Index for fast per-user queries
create index idx_media_user_id on public.media(user_id);
Enable row-level security
alter table public.media enable row level security;
-- Users can only read their own media
create policy "Users can view own media"
on public.media
for select
using (auth.uid() = user_id);
-- Users can insert media linked to themselves
create policy "Users can insert own media"
on public.media
for insert
with check (auth.uid() = user_id);
-- Users can update their own media
create policy "Users can update own media"
on public.media
for update
using (auth.uid() = user_id);
The service role key bypasses RLS, so your Edge Functions can update any row when processing webhooks. The client library, using the user’s JWT, is always constrained.
Edge Function: dispatch HLS task
When the client uploads a file and inserts a media row, call this Edge Function to kick off transcoding.
// supabase/functions/transcode-video/index.ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
serve(async (req) => {
const { media_id, source_url } = await req.json();
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
// Create an adaptive_video task for HLS output
const res = await fetch("https://api.ittybit.com/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("ITTYBIT_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: source_url,
kind: "adaptive_video",
options: {
format: "hls",
},
metadata: {
media_id,
},
}),
});
const task = await res.json();
// Store the task ID and mark as processing
await supabase
.from("media")
.update({ task_id: task.id, status: "processing" })
.eq("id", media_id);
return new Response(JSON.stringify({ task_id: task.id }), {
headers: { "Content-Type": "application/json" },
});
});
# Test the adaptive_video task directly
curl -X POST https://api.ittybit.com/jobs \
-H "Authorization: Bearer $ITTYBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": "https://your-project.supabase.co/storage/v1/object/public/videos/demo.mp4",
"kind": "adaptive_video",
"options": {
"format": "hls"
},
"metadata": {
"media_id": "your-media-uuid"
}
}' The adaptive_video kind tells Ittybit to produce a multi-bitrate HLS manifest. The metadata.media_id field passes through to the webhook payload so you can tie the result back to your database row.
Edge Function: receive webhook
When Ittybit finishes transcoding, it POSTs to your webhook endpoint. This function updates the media row with the HLS URL.
// supabase/functions/ittybit-webhook/index.ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
serve(async (req) => {
const payload = await req.json();
const mediaId = payload.metadata?.media_id;
if (!mediaId) {
return new Response("Missing media_id", { status: 400 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
if (payload.status === "completed") {
await supabase
.from("media")
.update({
status: "completed",
hls_url: payload.output?.url ?? null,
updated_at: new Date().toISOString(),
})
.eq("id", mediaId);
} else {
await supabase
.from("media")
.update({
status: "failed",
updated_at: new Date().toISOString(),
})
.eq("id", mediaId);
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
});
# Register the webhook endpoint
curl -X POST https://api.ittybit.com/webhooks \
-H "Authorization: Bearer $ITTYBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-project.supabase.co/functions/v1/ittybit-webhook",
"events": ["job.succeeded", "job.failed"]
}' Client: upload and trigger
On the client side, upload the file, insert the media row, and invoke the Edge Function.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
);
async function uploadVideo(file: File) {
const user = (await supabase.auth.getUser()).data.user;
if (!user) throw new Error('Not authenticated');
const path = `${user.id}/${file.name}`;
// 1. Upload to Storage
const { error: uploadError } = await supabase.storage.from('videos').upload(path, file);
if (uploadError) throw uploadError;
// 2. Get the public URL
const { data: urlData } = supabase.storage.from('videos').getPublicUrl(path);
// 3. Insert media row (RLS ensures user_id = auth.uid())
const { data: media, error: insertError } = await supabase
.from('media')
.insert({
user_id: user.id,
filename: file.name,
source_url: urlData.publicUrl,
})
.select()
.single();
if (insertError) throw insertError;
// 4. Invoke the Edge Function to start transcoding
await supabase.functions.invoke('transcode-video', {
body: {
media_id: media.id,
source_url: urlData.publicUrl,
},
});
return media;
}
Query the gallery
Because RLS is enabled, a simple SELECT * returns only the current user’s videos. No WHERE clause needed in your app code.
const { data: videos } = await supabase
.from('media')
.select('id, filename, status, hls_url, created_at')
.order('created_at', { ascending: false });
Completed videos have an hls_url you can pass directly to any HLS-compatible player (hls.js, Video.js, native Safari).
Deploy
supabase functions deploy transcode-video
supabase functions deploy ittybit-webhook
Set the Ittybit API key:
supabase secrets set ITTYBIT_API_KEY=your_ittybit_api_key
See also
- Auto-process Supabase uploads — trigger processing on every upload
- Create HLS streams — adaptive bitrate streaming options
- Build a user upload pipeline — multi-task processing for uploads