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_xxxBoth 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 convenientconfig.toml deliberately pins JWT verification per Edge Function:
| Function | verify_jwt | Why |
|---|---|---|
create-checkout | true | Starts a purchase for the signed-in user. |
customer-portal | true | Returns a portal URL for the signed-in user. |
activate-license | true | Activates a license for the signed-in user. |
creem-webhook | false | Creem 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.tsExpect 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.
Email and deep-link configuration
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/callbackRun 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
profilesrow 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 listreports all four functions.
