# Video gallery with Supabase RLS and Ittybit

Build a per-user video gallery with HLS streaming, Supabase Auth, and row-level security

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

1. User signs in with Supabase Auth and uploads a video to Storage
2. Client inserts a row in the `media` table with their `user_id`
3. An Edge Function sends the file to Ittybit as an `adaptive_video` task
4. Ittybit transcodes the video into HLS and sends a webhook on completion
5. A second Edge Function receives the webhook and updates the `media` row
6. RLS policy ensures `SELECT` only returns rows where `auth.uid() = user_id`

## Create the media table

```sql
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

```sql
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.

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
// supabase/functions/transcode-video/index.ts

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" },
});
});

````

```bash
# 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"
    }
  }'
````

</CodeGroup>

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.

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
// supabase/functions/ittybit-webhook/index.ts

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" },
});
});

````

```bash
# 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"]
  }'
````

</CodeGroup>

## Client: upload and trigger

On the client side, upload the file, insert the `media` row, and invoke the Edge Function.

```typescript

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.

```typescript
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

```bash
supabase functions deploy transcode-video
supabase functions deploy ittybit-webhook
```

Set the Ittybit API key:

```bash
supabase secrets set ITTYBIT_API_KEY=your_ittybit_api_key
```

## See also

- [Auto-process Supabase uploads](/guides/auto-process-supabase-uploads) -- trigger processing on every upload
- [Create HLS streams](/guides/create-hls-streams) -- adaptive bitrate streaming options
- [Build a user upload pipeline](/guides/build-a-user-upload-pipeline) -- multi-task processing for uploads