This document explains the auth/profile lifecycle migration:
supabase/migrations/0003_auth_profile_lifecycle.sql
The migration has not been applied to a real Supabase project yet. Task 4A adds admin login and route protection in the app, but CRUD, CMS editing, storage, public data integration, and staff management screens are still deferred.
Supabase Auth stores real sign-in identities in auth.users.
FrameSignal stores staff-facing profile data in public.profiles, including display name, username, avatar URL, bio, role, and activation status.
The profiles.id column references auth.users.id so each staff auth user can have one matching profile row.
The auth.users table belongs to Supabase Auth and can contain sensitive identity data.
Application code should use public.profiles for safe staff profile information, while Supabase Auth handles authentication internally.
The migration creates public.handle_new_user() and attaches it to this trigger:
on_auth_user_created
When a row is inserted into auth.users, the trigger creates a matching public.profiles row.
The profile uses:
id = new.idfull_namefromraw_user_meta_data.full_name, then email prefix, thenFrameSignal Userusernamefromraw_user_meta_data.usernamewhen available and not already usedavatar_urlfromraw_user_meta_data.avatar_urlwhen availablerole = editoris_active = false
FrameSignal does not have public reader accounts in the MVP. Future auth users are staff accounts only.
New users are created as inactive editors so they cannot immediately receive CMS permissions. An admin must activate them first.
This keeps accidental signups, invited-but-unreviewed accounts, and test users from gaining editor access automatically.
After real auth and admin management are built, an active admin can activate a staff profile by setting is_active = true.
Admins can also decide whether a staff profile should remain editor or become admin.
If users could update their own role, an editor could promote themselves to admin.
If users could update their own is_active, an inactive staff account could activate itself.
That is privilege escalation. It must be blocked at the database level, not only hidden in the UI.
The migration adds public.prevent_profile_privilege_escalation().
The trigger runs before updates on public.profiles.
It protects:
roleis_active
Active admins can change those fields. Non-admin users cannot. If a non-admin tries to change either field, the trigger raises this error:
Only active admins can change profile role or activation status.
Non-admin users may still update safe profile fields later if RLS and server-side validation allow it:
full_nameusernameavatar_urlbio
The role helper functions only trust active profiles:
public.current_user_role()public.is_admin()public.is_editor()public.is_admin_or_editor()
An inactive profile returns no staff role, so RLS policies do not treat that user as an editor or admin.
The first admin must be bootstrapped manually after local or cloud Supabase is set up.
Use a carefully checked auth user UUID. This example uses a placeholder only:
update public.profiles
set role = 'admin', is_active = true
where id = '<AUTH_USER_UUID>';Before running this, verify the auth user is the intended owner/admin account. Do not paste a random UUID, and do not run this against the wrong Supabase project.
Manual bootstrap should be done only in a trusted database context. It is not a public UI flow.
This profile lifecycle task does not add:
- Public reader signups
- CRUD
- API routes for content operations
- Admin user management screens
- Supabase reads or writes from the app UI
Those pieces should come later through explicit auth and admin integration tasks.