Supabase 配置

创建项目、应用数据库结构并部署 Edge Functions。

Supabase 支撑 Electron 模板里的邮箱认证、profile 数据、avatar 存储、todos 示例,以及按账号绑定的计费权益读模型。应用在 renderer 中使用 Supabase publishable key,但真正的授权必须由 Postgres RLS 和 Storage policies 保证。

当前仓库只提交了计费 migration。应用还会查询 profilestodos 和私有 avatars bucket,所以本页提供了让后端可运行所需的补充 SQL。

创建项目

创建 Supabase 项目

在 Supabase dashboard 创建项目,然后从 Project Settings -> API 复制项目 URL 和 publishable key 到 soar-electron/.env

VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-anon-key

这些值是公开客户端配置。不要把 service-role key 放进桌面应用 .env

安装并关联 CLI

brew install supabase/tap/supabase
supabase login
supabase link --project-ref <ref>

supabase/config.toml 保存 project ref 和 Edge Function JWT 设置。如果不使用 supabase link,就把 project_id 改成你的 ref。

应用计费 migration

supabase db push --project-ref <ref>

这会应用 supabase/migrations/20260614120000_billing_entitlements.sql。 该 migration 创建 subscriptionspayments,模型是 service-role-write、 client-read:已登录客户端只能 select 自己的行,不能 insert、update 或 delete 计费记录。Edge Functions 使用 service role 写入权益行。

添加应用 schema

把下面的 SQL 粘贴到 Supabase SQL editor。它创建 app/services/profile.tsapp/services/todos.tsapp/services/avatar.ts 所需的表与 Storage policies。

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 $$;

数据库类型

app/lib/supabase/database.types.ts 目前在这个模板中是手工维护的。应用或修改 schema 后,可以仔细手改该文件,也可以重新生成:

npx supabase gen types typescript --project-id <ref> > app/lib/supabase/database.types.ts

提交前检查 diff。当前文件包含一些 Electron 已提交 migration 未创建的 sample tables,所以重新生成可能会移除你已经删除或仍准备迁移的类型。

Auth URLs

当前邮箱流程使用应用内输入六位 OTP:

  • 注册验证调用 supabase.auth.verifyOtp({ type: 'signup' })
  • 密码重置调用 supabase.auth.verifyOtp({ type: 'recovery' })
  • app/renderer.tsx 为 Supabase client 设置了 detectSessionInUrl: false

也就是说,当前代码路径不消费 Supabase auth callback deep link。如果之后把 Supabase 邮件模板改成链接式确认或接入 OAuth,仍应允许应用 scheme:

soar-electron://

运行 pnpm rebrand --scheme 后使用改名后的 scheme。当前 deep-link route allow-list 接受 soar-electron://settingssoar-electron://accountsoar-electron://billing 等 shell routes;不包含 /login/reset-password

Edge Functions

使用 helper script 部署 supabase/functions 下的计费函数:

pnpm supabase:deploy
pnpm supabase:deploy <project-ref>
FunctionJWT用途
create-checkout已登录用户创建 Creem checkout。
customer-portal已登录用户打开 Creem customer portal。
activate-license启用 license mode 后进行可选 license-key 激活。
creem-webhookCreem 调用时不带 Supabase JWT;handler 校验 webhook signature 后写入计费行。

functions/_shared/ 会被导入它的函数打包进去。不要单独部署 _shared。Provider 细节放在 Creem Setup 页面。

验证后端

  • 使用 Supabase URL/key 启动应用,确认 renderer 能挂载。
  • 注册账号、输入邮件验证码,并确认创建了 profiles 行。
  • 在 Showcase Todos demo 中创建、完成并删除 todos。
  • 在 Account 上传 avatar,确认对象写入 avatars/<user-id>/avatar.<ext>
  • 运行 supabase functions list --project-ref <ref>,确认四个函数已部署。

相关页面

On this page