Supabase Setup

Create the Supabase backend, schema, Storage bucket, and Edge Functions the desktop app expects.

The Tauri template uses Supabase for email authentication, profiles, example todos, avatar storage, and the Creem entitlement read model. This guide leaves you with those surfaces working against your project.

The repository ships one migration: the billing subscriptions and payments tables. It does not ship SQL for profiles, todos, or the private avatars bucket, even though the app uses all three. Run the SQL in this guide before testing authenticated app surfaces.

Create and connect a project

Create a project in Supabase, then copy its project URL and publishable key into the root .env file:

VITE_SUPABASE_URL=https://<ref>.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx

Both values are safe to ship in the webview. They identify the project; Row Level Security (RLS), not secrecy, protects user data. Never put a service-role key in the desktop bundle.

Install and log into the CLI, then replace the sample project_id in supabase/config.toml with your project ref:

brew install supabase/tap/supabase # macOS
supabase login
supabase link --project-ref <ref>  # optional, but convenient

config.toml deliberately pins JWT verification per Edge Function:

Functionverify_jwtWhy
create-checkouttrueStarts a purchase for the signed-in user.
customer-portaltrueReturns a portal URL for the signed-in user.
activate-licensetrueActivates a license for the signed-in user.
creem-webhookfalseCreem cannot present a Supabase JWT; the handler verifies Creem’s signature instead.

Apply the database schema

First apply the committed billing migration:

supabase db push --project-ref <ref>

It creates subscriptions and payments, their enums, indexes, update triggers, and RLS. Client code has SELECT only access to its own billing rows. Creem Edge Functions use the server-only service-role client to write the ledger, so a desktop client can never grant itself a plan.

Then open SQL Editor in the Supabase dashboard and run the following SQL. It matches the fields actually selected and written by src/services/profile.ts, src/services/todos.ts, and src/services/avatar.ts.

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()
);

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.profiles enable row level security;
alter table public.todos enable row level security;

drop policy if exists "profiles_select_own" on public.profiles;
create policy "profiles_select_own" on public.profiles
  for select to authenticated using ((select auth.uid()) = id);
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own" on public.profiles
  for update to authenticated using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

drop policy if exists "todos_select_own" on public.todos;
create policy "todos_select_own" on public.todos
  for select to authenticated using ((select auth.uid()) = user_id);
drop policy if exists "todos_insert_own" on public.todos;
create policy "todos_insert_own" on public.todos
  for insert to authenticated with check ((select auth.uid()) = user_id);
drop policy if exists "todos_update_own" on public.todos;
create policy "todos_update_own" on public.todos
  for update to authenticated using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
drop policy if exists "todos_delete_own" on public.todos;
create policy "todos_delete_own" on public.todos
  for delete to authenticated using ((select auth.uid()) = user_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();

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;

drop policy if exists "avatars_select_own" on storage.objects;
create policy "avatars_select_own" on storage.objects
  for select to authenticated using (
    bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text
  );
drop policy if exists "avatars_insert_own" on storage.objects;
create policy "avatars_insert_own" on storage.objects
  for insert to authenticated with check (
    bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text
  );
drop policy if exists "avatars_update_own" on storage.objects;
create policy "avatars_update_own" 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
  );
drop policy if exists "avatars_delete_own" on storage.objects;
create policy "avatars_delete_own" on storage.objects
  for delete to authenticated using (
    bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text
  );

The bucket is private. Objects live at <user-id>/avatar.png, .jpg, or .webp; the policies require the first path segment to be the caller’s ID. The app creates seven-day signed URLs when it displays an avatar.

Regenerate the types

src/lib/supabase/database.types.ts is hand-maintained and currently includes unrelated chat, referral, and sample-upload types from a development project. After applying the schema, replace it with types from your project:

supabase gen types typescript --project-id <ref> \
  > src/lib/supabase/database.types.ts

Expect a smaller, correct file whose relevant tables are profiles, todos, subscriptions, and payments. Review and commit the generated diff; do not copy the unrelated tables into your product schema.

The included sign-up and password-reset flows are six-digit OTP flows. Configure the Confirm signup and Reset password templates in Supabase Authentication → Email Templates to include {{ .Token }}. The client uses detectSessionInUrl: false, so these flows do not need a redirect URL today.

If you add magic links or OAuth, register the scheme you ship in Authentication → URL Configuration → Redirect URLs, for example:

soar-tauri://auth/callback

Run pnpm rebrand --scheme <your-scheme> first if you have rebranded. Also add the callback route to src/lib/intents/routes.ts; the current allow-list does not accept /auth/callback, so adding it in Supabase alone is not sufficient.

Deploy Edge Functions

The billing functions are not part of the app bundle. Set their server-only secrets, then deploy them with the helper:

supabase secrets set \
  CREEM_API_KEY=creem_live_xxx \
  CREEM_WEBHOOK_SECRET=whsec_xxx \
  CREEM_SERVER_IDX=0 \
  --project-ref <ref>

pnpm supabase:deploy <ref>
supabase functions list --project-ref <ref>

scripts/deploy-edge-functions.sh deploys every non-underscore folder under supabase/functions/; _shared/ is bundled into functions that import it and is never deployed alone. See Creem Setup for product, webhook, and checkout configuration.

Verify the backend

  • Register with email, enter the six-digit code, and return to Home.
  • Confirm a matching profiles row was created.
  • Create, complete, and delete a todo in Showcase → Todos.
  • Upload an avatar in Account and confirm it lands in avatars/<user-id>/.
  • Confirm supabase functions list reports all four functions.

On this page