diff --git a/supabase/migrations/20260516000007_soundboards.sql b/supabase/migrations/20260516000007_soundboards.sql new file mode 100644 index 0000000..a827f1f --- /dev/null +++ b/supabase/migrations/20260516000007_soundboards.sql @@ -0,0 +1,126 @@ +-- Phase 4C: cloud-synced soundboard. +-- +-- user_soundboards: one row per cloud-known sound. The audio payload itself +-- lives in the `soundboards` storage bucket at `/.bin`, +-- E2E-encrypted with the user's own X25519 key (sealed-to-self). The first +-- 24 bytes of the stored object are the XSalsa20 nonce; the rest is the +-- Poly1305-authenticated ciphertext. +-- +-- Metadata (name, gain, hotkey, category, sort_order) is stored plaintext — +-- not considered sensitive by the spec — so other devices of the same user +-- can read it without holding the private key. + +create table if not exists public.user_soundboards ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + mime text not null, + size bigint not null, + category text null, + hotkey text null, + gain real not null default 1.0, + sort_order integer not null default 0, + storage_path text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_soundboards_name_len check (length(name) between 1 and 200), + constraint user_soundboards_size_pos check (size > 0) +); + +create index if not exists user_soundboards_user_idx + on public.user_soundboards(user_id, updated_at desc); + +alter table public.user_soundboards enable row level security; + +drop policy if exists user_soundboards_select on public.user_soundboards; +drop policy if exists user_soundboards_insert on public.user_soundboards; +drop policy if exists user_soundboards_update on public.user_soundboards; +drop policy if exists user_soundboards_delete on public.user_soundboards; + +create policy user_soundboards_select + on public.user_soundboards + for select + using (user_id = auth.uid()); + +create policy user_soundboards_insert + on public.user_soundboards + for insert + with check (user_id = auth.uid()); + +create policy user_soundboards_update + on public.user_soundboards + for update + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy user_soundboards_delete + on public.user_soundboards + for delete + using (user_id = auth.uid()); + +alter table public.user_soundboards replica identity full; + +do $$ +begin + if not exists ( + select 1 + from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'user_soundboards' + ) then + execute 'alter publication supabase_realtime add table public.user_soundboards'; + end if; +end +$$; + +-- Storage bucket: private, owner-only. +do $$ +begin + if not exists (select 1 from storage.buckets where id = 'soundboards') then + insert into storage.buckets (id, name, public) + values ('soundboards', 'soundboards', false); + end if; +end +$$; + +drop policy if exists soundboards_select on storage.objects; +drop policy if exists soundboards_insert on storage.objects; +drop policy if exists soundboards_update on storage.objects; +drop policy if exists soundboards_delete on storage.objects; + +create policy soundboards_select + on storage.objects + for select + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_insert + on storage.objects + for insert + with check ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_update + on storage.objects + for update + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ) + with check ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_delete + on storage.objects + for delete + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + );