Supabase Setup
Create the project, apply the schema, and deploy Edge Functions.
Supabase backs the Electron template's email auth, profile data, avatar storage, todos example, and account-bound billing read model. The app uses the Supabase publishable key from the renderer, but all authorization must be enforced with Postgres RLS and Storage policies.
The repository currently ships only the billing migration. The app also
queries profiles, todos, and a private avatars bucket, so this page
includes the missing SQL needed for a working backend.
Create the project
Create a Supabase project
Create a project in the Supabase dashboard, then copy the project URL and
publishable key from Project Settings -> API into soar-electron/.env:
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-anon-keyThese values are public client config. Never put a service-role key in the
desktop app .env.
Install and link the CLI
brew install supabase/tap/supabase
supabase login
supabase link --project-ref <ref>supabase/config.toml stores the project ref and the Edge Function JWT
settings. Replace project_id with your ref if you do not use supabase link.
Apply the billing migration
supabase db push --project-ref <ref>This applies supabase/migrations/20260614120000_billing_entitlements.sql.
The migration creates subscriptions and payments with a service-role-write,
client-read model: authenticated clients can select only their own rows, and
they cannot insert, update, or delete billing records. Edge Functions write the
entitlement rows with the service role.
Add the app schema
Paste the SQL below into the Supabase SQL editor. It creates the rows and
Storage policies that app/services/profile.ts, app/services/todos.ts, and
app/services/avatar.ts expect.
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
bio text,
phone text,
location text,
avatar_url text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
drop policy if exists "Users can view own profile" on public.profiles;
create policy "Users can view own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);
drop policy if exists "Users can insert own profile" on public.profiles;
create policy "Users can insert own profile"
on public.profiles for insert
to authenticated
with check ((select auth.uid()) = id);
drop policy if exists "Users can update own profile" on public.profiles;
create policy "Users can update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, full_name)
values (new.id, new.raw_user_meta_data ->> 'full_name')
on conflict (id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
create table if not exists public.todos (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
title text not null,
completed boolean not null default false,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists todos_user_id_idx on public.todos (user_id);
create index if not exists todos_user_sort_idx
on public.todos (user_id, sort_order, created_at desc);
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists set_profiles_updated_at on public.profiles;
create trigger set_profiles_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
drop trigger if exists set_todos_updated_at on public.todos;
create trigger set_todos_updated_at
before update on public.todos
for each row execute function public.set_updated_at();
alter table public.todos enable row level security;
drop policy if exists "Users can view their todos" on public.todos;
create policy "Users can view their todos"
on public.todos for select
to authenticated
using ((select auth.uid()) = user_id);
drop policy if exists "Users can insert their todos" on public.todos;
create policy "Users can insert their todos"
on public.todos for insert
to authenticated
with check ((select auth.uid()) = user_id);
drop policy if exists "Users can update their todos" on public.todos;
create policy "Users can update their todos"
on public.todos for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
drop policy if exists "Users can delete their todos" on public.todos;
create policy "Users can delete their todos"
on public.todos for delete
to authenticated
using ((select auth.uid()) = user_id);
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'avatars',
'avatars',
false,
5242880,
array['image/png', 'image/jpeg', 'image/webp']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'storage'
and tablename = 'objects'
and policyname = 'Users can read own avatar objects'
) then
execute $policy$
create policy "Users can read own avatar objects"
on storage.objects for select
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
$policy$;
end if;
if not exists (
select 1 from pg_policies
where schemaname = 'storage'
and tablename = 'objects'
and policyname = 'Users can upload own avatar objects'
) then
execute $policy$
create policy "Users can upload own avatar objects"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
$policy$;
end if;
if not exists (
select 1 from pg_policies
where schemaname = 'storage'
and tablename = 'objects'
and policyname = 'Users can update own avatar objects'
) then
execute $policy$
create policy "Users can update own avatar objects"
on storage.objects for update
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
$policy$;
end if;
if not exists (
select 1 from pg_policies
where schemaname = 'storage'
and tablename = 'objects'
and policyname = 'Users can delete own avatar objects'
) then
execute $policy$
create policy "Users can delete own avatar objects"
on storage.objects for delete
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
$policy$;
end if;
end $$;Database types
app/lib/supabase/database.types.ts is hand-maintained in this template today.
After applying or changing schema, either update that file carefully or
regenerate it:
npx supabase gen types typescript --project-id <ref> > app/lib/supabase/database.types.tsReview the diff before committing. The current file includes extra sample tables that are not backed by committed Electron migrations, so generation may remove types for code you have already deleted or still need to port.
Auth URLs
Current email flows use six-digit OTP entry inside the app:
- Sign-up verifies with
supabase.auth.verifyOtp({ type: 'signup' }). - Password reset verifies with
supabase.auth.verifyOtp({ type: 'recovery' }). app/renderer.tsxsetsdetectSessionInUrl: falseon the Supabase client.
That means the current code path does not consume a Supabase auth callback deep link. Still configure the app scheme if you later switch Supabase email templates to link-based confirmation or OAuth:
soar-electron://Use the rebranded scheme after running pnpm rebrand --scheme. The active
deep-link route allow-list currently accepts shell routes such as
soar-electron://settings, soar-electron://account, and
soar-electron://billing; it does not include /login or /reset-password.
Edge Functions
Deploy billing functions from supabase/functions with the helper script:
pnpm supabase:deploy
pnpm supabase:deploy <project-ref>| Function | JWT | Purpose |
|---|---|---|
create-checkout | On | Signed-in users start a Creem checkout. |
customer-portal | On | Signed-in users open the Creem customer portal. |
activate-license | On | Optional license-key activation when license mode is enabled. |
creem-webhook | Off | Creem calls it without a Supabase JWT; the handler verifies the webhook signature before writing billing rows. |
functions/_shared/ is bundled into functions that import it. Do not deploy
_shared on its own. Provider details live on the Creem Setup page.
Verify the backend
- Launch the app with the Supabase URL/key and confirm the renderer mounts.
- Sign up, enter the email code, and confirm a
profilesrow exists. - Create, complete, and delete todos in the Showcase Todos demo.
- Upload an avatar from Account and confirm the object lands under
avatars/<user-id>/avatar.<ext>. - Run
supabase functions list --project-ref <ref>and confirm the four functions are deployed.
Related pages
Authentication
Email/password auth, OTP flows, secure session storage, and account profile.
Data Layer
Typed Supabase services, TanStack Query, and the subscription read model.
Creem Setup
Provider products, Edge secrets, webhook deploy, and checkout testing.
Deep Links
Protocol routes for desktop intents and billing returns.
