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-key

These values are public client config. Never put a service-role key in the desktop app .env.

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.ts

Review 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.tsx sets detectSessionInUrl: false on 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>
FunctionJWTPurpose
create-checkoutOnSigned-in users start a Creem checkout.
customer-portalOnSigned-in users open the Creem customer portal.
activate-licenseOnOptional license-key activation when license mode is enabled.
creem-webhookOffCreem 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 profiles row 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.

On this page