はじめに
音楽好きとミュージシャンのための交流アプリ OTOMO を個人で作っています。
作品への感想、バンドメンバー募集、ライブ告知ができるWebアプリです(React + Vite + Supabase + Vercel)。
機能はひととおりそろったものの、「もしチームで運営したら何が危ないか」を洗い出してみると、機能より先に直すべき土台がいくつも見つかりました。この記事では、優先度が一番高かった3つを Claude Code と一緒に片付けた記録を書きます。
- コードのバックアップ(GitHub)
- 利用規約・プライバシーポリシー
- 退会機能(アカウントと全データの削除)
リスクの洗い出しで分かったこと
チーム運営を前提に見直したところ、次のような状態でした。
| リスク | 状態 |
|---|---|
| コードの置き場所 | 自分の Mac の中だけ(と思っていた) |
| アカウント | Vercel・Supabase・Search Console がすべて個人アカウント |
| 規約・ポリシー | なし。メールアドレスという個人情報を預かっているのに |
| 退会 | ユーザーが自分でアカウントを消せない |
| バックアップ | Supabase 無料プランには自動バックアップがない |
「規約がない」「退会できない」は、ユーザーが増えてからでは対応しづらい、法律にもかかわる部分です。ここから手を付けました。
1. GitHub へのバックアップ
確認してみると、実は別の作業でリポジトリはすでに作ってありました。ただし 公開リポジトリ で、手元には まだ push していないコミットが5つ ありました。
公開のままでよいかは、まず「秘密情報が履歴に入っていないか」で判断しました。
# 秘密鍵らしき文字列が、全履歴のどこかに入っていないか
git grep -nIE 'sb_secret|service_role_key|eyJhbGciOi' $(git rev-list --all)
# .env 系のファイルが一度でもコミットされていないか
git log --all --name-only --format= | sort -u | grep -iE '(^|/)\.env|\.vercel'
フロントエンドに置いているのは Supabase の publishable key(公開してよい前提の鍵)だけで、.env は最初から .gitignore 済み。データの保護は RLS(行単位のアクセス制御)で行っているので、ソースが公開されていても問題ない構成でした。ポートフォリオからもリンクしているリポジトリなので、公開のまま push しました。
学び:「バックアップはある」と思い込まず、
git status -sbで ahead(未 push) がないかを見る。
2. 利用規約・プライバシーポリシー
ひな形をそのまま貼るのではなく、このアプリが実際に何を集め、どこに保存しているかから書き起こしました。
- 取得する情報:メールアドレス、パスワード(ハッシュ化され運営者も見られない)、プロフィール、アイコン画像、投稿・コメント・リアクション、ブロックと通報、通知、アクセスログ、Cookie を使わないアクセス解析
- 公開範囲:プロフィールと投稿は公開、メールアドレスは非公開、リアクションは送り手と受け手だけ
- 委託先と保存場所:Supabase(米国企業・データは韓国のリージョン)、Vercel(米国)。メール通知を有効にした場合は Resend(米国)も
- 保存期間:退会まで。通知は既読90日・作成180日で自動削除(実際に pg_cron でそう動かしている内容と一致させた)
- 埋め込みプレーヤー:YouTube・SoundCloud・Spotify が Cookie を保存しうること
日本の個人情報保護法では、外国にある第三者に個人データを提供する場合(外国の事業者に保存を委託する場合を含む)、本人の同意を得るか、提供先が一定の基準に合う体制を整えていることを確認する必要があり、移転先の国やその国の制度についての情報提供も求められます。そこで、委託先の国名と、個人情報保護委員会の各国制度ページへのリンクを入れました。投稿サイトなので、利用規約には禁止事項・投稿の権利・削除の申し立て窓口・ユーザー間トラブルの扱いも書いています。
新規登録フォームには、同意チェックがないと「登録する」を押せないようにしました。
<label className="check">
<input type="checkbox" required checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<Link to="/terms" target="_blank">利用規約</Link>と<Link to="/privacy" target="_blank">プライバシーポリシー</Link>に同意します
</label>
<button className="btn wide" disabled={busy || (mode === 'signup' && !agreed)}>登録する</button>
※ 規約は法律の専門家のレビューを受けたものではありません。収益化や規模拡大の前には見てもらう予定です。
3. 退会機能
方針:連鎖削除に任せる。ただし穴を埋める
OTOMO のテーブルは、ほぼすべて profiles(auth.users と1対1)を外部キーで参照していて、on delete cascade を付けています。
create table public.tracks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references public.profiles (id) on delete cascade,
...
);
なので auth.users から本人を消せば、プロフィール → 投稿・作品・募集・イベント・コメント・リアクション・参加表明・ブロック・通報・通知が連鎖して消えます。
ところが穴が1つありました。 コメントと通知は「投稿・作品・募集・イベントのどれか」を target_type + target_id で指す ポリモーフィックな参照 にしていて、外部キーがありません。そのため、
退会した人の作品に、ほかの人が付けたコメント が孤児として残る
という状態になります。退会前にこれを掃除する関数を用意しました。
create function public.prepare_account_deletion(target_user uuid)
returns void
language sql
security definer set search_path = ''
as $$
with owned as (
select 'post' as target_type, id as target_id from public.posts where user_id = target_user
union all select 'track', id from public.tracks where user_id = target_user
union all select 'recruitment', id from public.recruitments where user_id = target_user
union all select 'event', id from public.events where user_id = target_user
),
del_comments as (
delete from public.comments c using owned o
where c.target_type = o.target_type and c.target_id = o.target_id
)
delete from public.notifications n using owned o
where n.target_type = o.target_type and n.target_id = o.target_id;
$$;
-- ブラウザからは呼べない。Edge Function(service role)だけが呼ぶ
revoke execute on function public.prepare_account_deletion(uuid) from public, anon, authenticated;
grant execute on function public.prepare_account_deletion(uuid) to service_role;
Edge Function で「本人確認 → 掃除 → 画像 → アカウント」
アカウントの削除には service role が必要なので、ブラウザではなく Supabase Edge Function で行います。呼び出し元の確認は、送られてきたログイントークンを Auth に問い合わせて行います。
Deno.serve(async (req) => {
const admin = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
const token = (req.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, '')
const { data, error } = await admin.auth.getUser(token)
const user = data?.user
if (error || !user) return json({ error: 'unauthorized' }, 401)
// 1. 自分の投稿に付いた他の人のコメント・通知
await admin.rpc('prepare_account_deletion', { target_user: user.id })
// 2. アイコン画像(Storage はデータベースの連鎖削除の対象外)
const { data: files } = await admin.storage.from('avatars').list(user.id)
if (files?.length) await admin.storage.from('avatars').remove(files.map((f) => `${user.id}/${f.name}`))
// 3. アカウント本体 → profiles 以下が連鎖して消える
await admin.auth.admin.deleteUser(user.id)
return json({ deleted: true })
})
ポイントは2つです。
-
Storage は連鎖削除されない。アイコン画像は
avatars/<ユーザーID>/に置いているので、フォルダごと消しています。 -
verify_jwtは false にして、関数の中でauth.getUser(token)を使って確かめています。トークンなし・偽のトークン・publishable key を送った場合は、どれも 401 になることを確認しました。
画面側は、誤操作を防ぐために「退会する」と入力しないとボタンが押せないようにしています。
本番DBで、データを残さずにテストする
「本当に全部消えるか」は、本番のデータベースで確かめたいところです。そこで、最後に例外を投げてすべてロールバックする DO ブロック で、使い捨てのユーザーを作ってテストしました。
do $$
declare a uuid := gen_random_uuid(); b uuid := gen_random_uuid(); a_track uuid; n int; res text := '';
begin
insert into auth.users (id, email, aud, role) values
(a, 'del-a@example.invalid', 'authenticated', 'authenticated'),
(b, 'del-b@example.invalid', 'authenticated', 'authenticated');
-- RLS を効かせた状態で、A と B として操作する
set local role authenticated;
perform set_config('request.jwt.claims', json_build_object('sub', a, 'role', 'authenticated')::text, true);
insert into public.tracks (title, url) values ('Aの曲', 'https://example.com') returning id into a_track;
perform set_config('request.jwt.claims', json_build_object('sub', b, 'role', 'authenticated')::text, true);
insert into public.comments (target_type, target_id, body) values ('track', a_track, 'BがAの曲にコメント');
reset role;
-- 退会処理(Edge Function と同じ順番)
perform public.prepare_account_deletion(a);
delete from auth.users where id = a;
select count(*) into n from public.comments where target_id = a_track;
res := res || ' others_comments_on_A=' || n;
-- ...ほかのテーブルも同様に数える
raise exception 'RESULT:%', res; -- 結果を見せつつ、全部なかったことにする
end $$;
結果は、A の投稿・コメント・リアクション・参加表明・ブロック・通報・通知、そして B が A の作品に付けたコメント まですべて0件。B 自身の作品やイベントは残っていました。
set local role authenticated と request.jwt.claims を組み合わせると、RLS が効いた状態で別人として振る舞えるので、権限のテストにも便利です(ブロックした相手からのコメントが拒否されるか、なども同じ方法で確認しています)。
テストで見つかったバグ
画面のテストでは、退会フォームの useState を 早期 return(if (!form) return null)の後ろ に書いてしまい、「Rendered more hooks than during the previous render」で設定画面ごと真っ白になるバグが見つかりました。フックはコンポーネントの先頭にまとめる、という基本を改めて確認しました。ビルドは通っていたので、実際に操作してみるテストの大切さを感じた場面です。
まとめ
- 「チームで運営したら?」と考えると、機能より先に バックアップ・規約・退会 といった土台の不足が見えてくる
- 規約・ポリシーは、ひな形ではなく 実際のデータの流れ から書く(保存場所の国、保存期間、公開範囲)
- Supabase の退会は
on delete cascadeに任せられるが、ポリモーフィック参照と Storage は連鎖しない ので明示的に消す - 本番DBのテストは「最後に例外で巻き戻す DO ブロック」で、データを残さずにできる
次は、アカウントのチーム化(組織・2段階認証)、CAPTCHA、そしてバックアップのある構成への移行を進める予定です。
追記(2026-10-25):委託先(メール送信は有効にした場合のみ)と、個人情報保護法の外国移転についての説明を、より正確な表現に直しました。